76 KiB
全系統自愈閉環 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 修復三大系統性缺口:Prometheus 規則未部署 → 告警靜默;日誌只收集不告警;auto_repair 無法修復主機 Docker 層服務。
Architecture: 方案 A 漸進補強。Sprint 1 補 Prometheus 規則 + 啟動 Sentry + CD 同步;Sprint 2 加 SigNoz log alert;Sprint 3 新增 HostRepairAgent (SSH) 讓 auto_repair_service 能修復 110/188 的 Docker/systemd 服務。
Tech Stack: Prometheus YAML、Gitea Actions、Python asyncio/asyncssh、K8s Secret、SigNoz webhook、awoooi-startup-110.sh bash
檔案結構
Sprint 1 (Infra/Ops)
| 檔案 | 動作 | 說明 |
|---|---|---|
ops/monitoring/alerts-unified.yml |
建立 | 整合所有 Prometheus 規則 (40+條),含統一標籤 |
scripts/ops/deploy-alerts.sh |
建立 | 部署 alerts.yml 到 110 Prometheus 的腳本 |
.gitea/workflows/cd.yaml |
修改 | 加入 deploy-alerts job,paths trigger 含 ops/monitoring/ |
scripts/reboot-recovery/awoooi-startup-110.sh |
修改 | 加入 Step 7 啟動 Sentry (/opt/sentry) |
docs/runbooks/REBOOT-RECOVERY-SOP.md |
修改 | v4.0:Sentry 步驟、診斷樹更新、E2E 驗證腳本更新 |
Sprint 2 (SigNoz + Sentry)
| 檔案 | 動作 | 說明 |
|---|---|---|
ops/signoz/alerting/log-rules.md |
建立 | SigNoz log alert rules 文檔 (SigNoz 無 YAML 匯入) |
apps/api/src/main.py |
修改 | Sentry init 加入 layer/component tags |
Sprint 3 (Host Auto-Repair)
| 檔案 | 動作 | 說明 |
|---|---|---|
apps/api/src/services/host_repair_agent.py |
建立 | SSH 主機修復 Agent |
apps/api/src/models/playbook.py |
修改 | 加入 ActionType.SSH_COMMAND |
apps/api/src/services/auto_repair_service.py |
修改 | _execute_step 加入 SSH_COMMAND 路由 |
scripts/repair-bot/repair-bot-110.sh |
建立 | 110 主機白名單修復腳本 |
scripts/repair-bot/repair-bot-188.sh |
建立 | 188 主機白名單修復腳本 |
k8s/awoooi-prod/04-repair-ssh-secret.yaml |
建立 | SSH key K8s Secret template (不含實際 key) |
apps/api/tests/test_host_repair_agent.py |
建立 | HostRepairAgent 單元測試 |
SPRINT 1:Prometheus 規則部署 + Sentry 啟動 + CD 同步
Task 1:建立統一 Prometheus 規則檔
說明: 將 k8s/monitoring/ 下所有規則轉換為 Docker Prometheus 格式,合併為 ops/monitoring/alerts-unified.yml。加入 layer/host/auto_repair 統一標籤。
Files:
-
Create:
ops/monitoring/alerts-unified.yml -
Step 1.1: 確認來源規則
# 在 awoooi 專案根目錄執行
grep -c "alert:" k8s/monitoring/k3s-alerts.yaml k8s/monitoring/alert-chain-monitor.yaml k8s/monitoring/database-alerts.yaml k8s/monitoring/minio-kali-alerts.yaml k8s/monitoring/k3s-alerts-supplemental.yaml
預期輸出:每個檔案各顯示規則數,總計應 > 40。
- Step 1.2: 建立 alerts-unified.yml
建立 ops/monitoring/alerts-unified.yml,內容如下(完整):
# ops/monitoring/alerts-unified.yml
# AWOOOI 統一 Prometheus 告警規則
# 2026-04-05 Claude Code: 整合所有規則,加入統一 layer 標籤
# 部署目標: 192.168.0.110:/home/wooo/monitoring/alerts.yml
# 部署方式: scripts/ops/deploy-alerts.sh (CD 自動部署)
#
# 標籤規範:
# layer: k8s | docker-110 | docker-188 | systemd-188
# component: 服務名稱
# team: ops | backend | ai | platform
# host: "110" | "188" | "120" | "121"
# auto_repair: "true" | "false"
groups:
# =========================================================================
# 主機層告警 (host_alerts)
# =========================================================================
- name: host_alerts
rules:
- alert: HostDown
expr: up{job=~"node-exporter.*"} == 0
for: 1m
labels:
severity: critical
layer: systemd-188
team: ops
auto_repair: "false"
annotations:
summary: "主機 {{ $labels.host }} 不可達"
description: "Node Exporter 無回應超過 1 分鐘"
- alert: HostHighCpuLoad
expr: 100 - (avg by(host) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 80
for: 5m
labels:
severity: warning
layer: systemd-188
team: ops
auto_repair: "false"
annotations:
summary: "主機 {{ $labels.host }} CPU 高負載"
description: "CPU 使用率超過 80%"
- alert: HostOutOfMemory
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100 > 85
for: 5m
labels:
severity: warning
layer: systemd-188
team: ops
auto_repair: "false"
annotations:
summary: "主機 {{ $labels.host }} 記憶體不足"
description: "記憶體使用率超過 85%"
- alert: HostOutOfDiskSpace
expr: (1 - (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"} / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"})) * 100 > 85
for: 5m
labels:
severity: warning
layer: systemd-188
team: ops
auto_repair: "false"
annotations:
summary: "主機 {{ $labels.host }} 磁碟空間不足"
description: "磁碟使用率超過 85%"
# =========================================================================
# K8s 叢集告警 (kubernetes_alerts)
# =========================================================================
- name: kubernetes_alerts
rules:
- alert: K3sNodeNotReady
expr: kube_node_status_condition{condition="Ready", status="true"} == 0
for: 2m
labels:
severity: critical
layer: k8s
team: ops
auto_repair: "false"
annotations:
summary: "K3s 節點 {{ $labels.node }} 未就緒"
description: "節點超過 2 分鐘未達到 Ready 狀態"
- alert: KubePodCrashLooping
expr: rate(kube_pod_container_status_restarts_total{namespace="awoooi-prod"}[15m]) > 0
for: 5m
labels:
severity: warning
layer: k8s
team: ops
auto_repair: "true"
annotations:
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} 持續重啟"
description: "Pod 在過去 15 分鐘內重啟次數異常"
- alert: KubePodNotReady
expr: kube_pod_status_ready{condition="true",namespace="awoooi-prod"} == 0
for: 5m
labels:
severity: warning
layer: k8s
team: ops
auto_repair: "true"
annotations:
summary: "Pod {{ $labels.namespace }}/{{ $labels.pod }} 未就緒"
description: "Running 中的 Pod 超過 5 分鐘未達到 Ready 狀態"
- alert: KubeDeploymentReplicasMismatch
expr: kube_deployment_spec_replicas{namespace="awoooi-prod"} != kube_deployment_status_replicas_available{namespace="awoooi-prod"}
for: 10m
labels:
severity: warning
layer: k8s
team: ops
auto_repair: "true"
annotations:
summary: "Deployment {{ $labels.namespace }}/{{ $labels.deployment }} 副本數不匹配"
description: "期望副本數與可用副本數不一致超過 10 分鐘"
- alert: VeleroBackupFailed
expr: increase(velero_backup_failure_total[24h]) > 0
for: 5m
labels:
severity: warning
layer: k8s
team: ops
component: velero
auto_repair: "false"
annotations:
summary: "Velero 備份失敗"
description: "過去 24 小時有備份失敗"
- alert: VeleroBackupNotRun
expr: time() - velero_backup_last_successful_timestamp > 86400
for: 10m
labels:
severity: critical
layer: k8s
team: ops
component: velero
auto_repair: "false"
annotations:
summary: "Velero 超過 24 小時未成功備份"
description: "最後一次成功備份超過 24 小時"
# =========================================================================
# 資料庫告警 (database_alerts)
# =========================================================================
- name: database_alerts
rules:
- alert: PostgreSQLDown
expr: up{job="postgres-exporter"} == 0 or pg_up == 0
for: 1m
labels:
severity: critical
layer: systemd-188
component: postgres
host: "188"
team: ops
auto_repair: "false"
annotations:
summary: "PostgreSQL 資料庫離線"
description: "PostgreSQL Exporter 無法連接資料庫超過 1 分鐘"
- alert: RedisDown
expr: up{job="redis-exporter"} == 0 or redis_up == 0
for: 1m
labels:
severity: critical
layer: systemd-188
component: redis
host: "188"
team: ops
auto_repair: "false"
annotations:
summary: "Redis 快取服務離線"
description: "Redis Exporter 無法連接 Redis 超過 1 分鐘"
- alert: PostgreSQLHighConnections
expr: pg_stat_activity_count > 80
for: 5m
labels:
severity: warning
layer: systemd-188
component: postgres
team: ops
auto_repair: "false"
annotations:
summary: "PostgreSQL 連接數過高"
description: "當前連接數 {{ $value }} 超過 80"
- alert: RedisMemoryHigh
expr: redis_memory_used_bytes / redis_memory_max_bytes * 100 > 80
for: 5m
labels:
severity: warning
layer: systemd-188
component: redis
team: ops
auto_repair: "false"
annotations:
summary: "Redis 記憶體使用過高"
description: "Redis 記憶體使用率超過 80%"
# =========================================================================
# 服務可用性告警 (service_alerts) — 含 layer 標籤決定修復路徑
# =========================================================================
- name: service_alerts
rules:
# ---- 188 Docker 層 ----
- alert: OpenClawDown
# 2026-04-05 Claude Code: 修正舊命名 ClawBotDown → OpenClawDown
expr: up{job="clawbot"} == 0
for: 2m
labels:
severity: critical
layer: docker-188
component: openclaw
host: "188"
team: ops
auto_repair: "true"
annotations:
summary: "OpenClaw 服務離線"
description: "OpenClaw (192.168.0.188:8088) 已離線超過 2 分鐘"
- alert: SignOzDown
expr: probe_success{job="blackbox-http", instance=~".*3301.*"} == 0
for: 2m
labels:
severity: warning
layer: docker-188
component: signoz
host: "188"
team: ops
auto_repair: "true"
annotations:
summary: "SignOz 服務離線"
description: "SignOz (192.168.0.188:3301) 已離線超過 2 分鐘"
# ---- 110 Docker 層 ----
- alert: SentryDown
expr: probe_success{job="blackbox-tcp", instance="192.168.0.110:9000"} == 0
for: 2m
labels:
severity: warning
layer: docker-110
component: sentry
host: "110"
team: ops
auto_repair: "true"
annotations:
summary: "Sentry 服務離線"
description: "Sentry (192.168.0.110:9000) 已離線超過 2 分鐘"
- alert: HarborDown
expr: probe_success{job="blackbox-http", instance=~".*5000.*"} == 0
for: 2m
labels:
severity: critical
layer: docker-110
component: harbor
host: "110"
team: ops
auto_repair: "true"
annotations:
summary: "Harbor Registry 離線"
description: "Harbor (192.168.0.110:5000) 已離線超過 2 分鐘,CD pipeline 將無法拉取映像"
- alert: GiteaDown
expr: probe_success{job="blackbox-http", instance="http://192.168.0.110:3001"} == 0
for: 2m
labels:
severity: critical
layer: docker-110
component: gitea
host: "110"
team: ops
auto_repair: "true"
annotations:
summary: "Gitea Git 服務離線"
description: "Gitea (192.168.0.110:3001) 已離線超過 2 分鐘,CD pipeline 失效"
- alert: AlertmanagerDown
expr: probe_success{job="blackbox-tcp", instance="192.168.0.110:9093"} == 0
for: 2m
labels:
severity: critical
layer: docker-110
component: alertmanager
host: "110"
team: ops
auto_repair: "true"
annotations:
summary: "Alertmanager 離線"
description: "Alertmanager (192.168.0.110:9093) 已離線,所有告警將靜默"
# =========================================================================
# 告警鏈路監控 (alert_chain) — 防止 2026-03-26/04-05 事故重演
# =========================================================================
- name: alert_chain
rules:
- alert: AlertChainBroken_Alertmanager
expr: |
sum(rate(awoooi_webhook_requests_total{source="alertmanager",status!="success"}[5m]))
/ sum(rate(awoooi_webhook_requests_total{source="alertmanager"}[5m])) > 0.1
for: 10m
labels:
severity: critical
layer: k8s
team: platform
auto_repair: "false"
annotations:
summary: "Alertmanager Webhook 錯誤率 > 10%"
description: "告警鏈路可能斷裂,請執行 E2E 驗證"
- alert: AlertChainBroken_Sentry
expr: |
sum(rate(awoooi_webhook_requests_total{source="sentry",status!="success"}[5m]))
/ sum(rate(awoooi_webhook_requests_total{source="sentry"}[5m])) > 0.1
for: 10m
labels:
severity: warning
layer: k8s
team: platform
auto_repair: "false"
annotations:
summary: "Sentry Webhook 錯誤率 > 10%"
description: "Sentry 錯誤可能無法正確處理"
- alert: NoAlertsReceived2Hours
expr: time() - max by (source)(awoooi_alert_chain_last_success_timestamp) > 7200
for: 5m
labels:
severity: warning
layer: k8s
team: platform
auto_repair: "false"
annotations:
summary: "2 小時內未收到任何告警 ({{ $labels.source }})"
description: "可能是告警鏈路問題,請執行 Smoke Test"
- alert: AlertChainUnhealthy
expr: awoooi_alert_chain_healthy == 0
for: 5m
labels:
severity: critical
layer: k8s
team: platform
auto_repair: "false"
annotations:
summary: "告警鏈路不健康 ({{ $labels.source }})"
description: "告警鏈路標記為不健康,最近處理失敗"
# =========================================================================
# 自動修復監控 (auto_repair)
# =========================================================================
- name: auto_repair
rules:
- alert: AutoRepairLowSuccessRate
expr: awoooi_auto_repair_success_rate < 0.3
for: 30m
labels:
severity: warning
layer: k8s
team: backend
auto_repair: "false"
annotations:
summary: "自動修復成功率過低 ({{ $value | humanizePercentage }})"
description: "動作 {{ $labels.action }} 的成功率低於 30%,建議檢查 Playbook"
- alert: PermanentFixRequired
expr: sum(rate(awoooi_anomaly_escalation_total{level="PERMANENT_FIX"}[1h])) > 0
for: 1m
labels:
severity: critical
layer: k8s
team: backend
auto_repair: "false"
annotations:
summary: "需要永久修復的異常升級"
description: "有異常升級到 PERMANENT_FIX 級別,需要根本修復"
# =========================================================================
# MinIO / Kali 告警
# =========================================================================
- name: minio_kali_alerts
rules:
- alert: MinIODown
expr: probe_success{job="blackbox-http", instance=~".*9000.*", instance!~".*sentry.*"} == 0
for: 2m
labels:
severity: warning
layer: docker-188
component: minio
host: "188"
team: ops
auto_repair: "true"
annotations:
summary: "MinIO (Velero 備份) 離線"
description: "MinIO (192.168.0.188:9000) 已離線超過 2 分鐘,Velero 備份可能失敗"
- alert: KaliScannerDown
expr: probe_success{job="blackbox-http", instance=~".*192.168.0.112.*"} == 0
for: 5m
labels:
severity: info
layer: docker-188
component: kali
host: "112"
team: ops
auto_repair: "false"
annotations:
summary: "Kali Scanner 離線"
description: "Kali (192.168.0.112:8080) 離線,安全掃描功能暫停"
- Step 1.3: 驗證 YAML 語法
cd /Users/ogt/awoooi
python3 -c "import yaml; yaml.safe_load(open('ops/monitoring/alerts-unified.yml'))" && echo "YAML OK"
預期輸出:YAML OK
- Step 1.4: Commit
git add ops/monitoring/alerts-unified.yml
git commit -m "ops(monitoring): 統一 Prometheus 告警規則 — 40+條含統一 layer 標籤
修正:
- ClawBotDown → OpenClawDown (舊命名廢棄)
- 加入 SentryDown/HarborDown/GiteaDown/AlertmanagerDown
- 所有規則補齊 layer/component/host/auto_repair 統一標籤
- 整合 k8s/monitoring/*.yaml → ops/monitoring/alerts-unified.yml
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 2:部署規則到 110 Prometheus
Files:
-
Create:
scripts/ops/deploy-alerts.sh -
Step 2.1: 建立部署腳本
mkdir -p scripts/ops
建立 scripts/ops/deploy-alerts.sh:
#!/bin/bash
# scripts/ops/deploy-alerts.sh
# 部署統一告警規則到 110 Prometheus
# 2026-04-05 Claude Code: Sprint 1 自動化部署
# 用法: bash scripts/ops/deploy-alerts.sh [--dry-run]
set -eo pipefail
RULES_FILE="ops/monitoring/alerts-unified.yml"
TARGET_HOST="192.168.0.110"
TARGET_PATH="/home/wooo/monitoring/alerts.yml"
PROMETHEUS_URL="http://${TARGET_HOST}:9090"
DRY_RUN="${1:-}"
log() { echo "[$(date '+%H:%M:%S')] $*"; }
# 確認檔案存在
if [ ! -f "$RULES_FILE" ]; then
echo "ERROR: $RULES_FILE not found"
exit 1
fi
# 驗證 YAML 語法
python3 -c "import yaml; yaml.safe_load(open('$RULES_FILE'))" || { echo "ERROR: YAML syntax error"; exit 1; }
log "✅ YAML 語法驗證通過"
# Dry run 模式
if [ "$DRY_RUN" = "--dry-run" ]; then
log "DRY RUN: would deploy $RULES_FILE to ${TARGET_HOST}:${TARGET_PATH}"
RULE_COUNT=$(grep -c "alert:" "$RULES_FILE")
log "規則數量: $RULE_COUNT 條"
exit 0
fi
# 備份現有規則
ssh wooo@${TARGET_HOST} "cp ${TARGET_PATH} ${TARGET_PATH}.bak.\$(date +%Y%m%d%H%M%S) 2>/dev/null || true"
log "✅ 現有規則已備份"
# 部署新規則
scp "$RULES_FILE" wooo@${TARGET_HOST}:${TARGET_PATH}
log "✅ 規則已複製到 ${TARGET_HOST}"
# Reload Prometheus
ssh wooo@${TARGET_HOST} "curl -s -X POST ${PROMETHEUS_URL}/-/reload"
sleep 3
# 驗證規則數量
RULE_COUNT=$(ssh wooo@${TARGET_HOST} "curl -s ${PROMETHEUS_URL}/api/v1/rules | python3 -c \"import sys,json; r=json.load(sys.stdin); print(sum(len(g['rules']) for g in r['data']['groups']))\"")
log "Prometheus 已載入 ${RULE_COUNT} 條規則"
if [ "$RULE_COUNT" -lt 30 ]; then
echo "ERROR: 規則數量異常 ($RULE_COUNT < 30),請檢查"
exit 1
fi
# 驗證關鍵規則存在
KEY_RULES=("SentryDown" "HarborDown" "GiteaDown" "OpenClawDown" "AlertmanagerDown" "AlertChainUnhealthy")
for rule in "${KEY_RULES[@]}"; do
EXISTS=$(ssh wooo@${TARGET_HOST} "curl -s ${PROMETHEUS_URL}/api/v1/rules | python3 -c \"import sys,json; r=json.load(sys.stdin); names=[x['name'] for g in r['data']['groups'] for x in g['rules']]; print('OK' if '$rule' in names else 'MISSING')\"")
if [ "$EXISTS" = "OK" ]; then
log "✅ $rule"
else
echo "❌ $rule 未找到"
exit 1
fi
done
log "🎉 部署完成!所有關鍵規則已生效"
chmod +x scripts/ops/deploy-alerts.sh
- Step 2.2: 執行部署 (dry-run 先確認)
bash scripts/ops/deploy-alerts.sh --dry-run
預期輸出:
[HH:MM:SS] ✅ YAML 語法驗證通過
[HH:MM:SS] DRY RUN: would deploy ops/monitoring/alerts-unified.yml to 192.168.0.110:...
[HH:MM:SS] 規則數量: XX 條
- Step 2.3: 執行實際部署
bash scripts/ops/deploy-alerts.sh
預期輸出:所有規則 ✅,🎉 部署完成!
- Step 2.4: 驗證 Prometheus UI
ssh wooo@192.168.0.110 "curl -s http://localhost:9090/api/v1/rules | python3 -c \"
import sys, json
r = json.load(sys.stdin)
groups = r['data']['groups']
total = sum(len(g['rules']) for g in groups)
print(f'Groups: {len(groups)}, Total rules: {total}')
for g in groups:
print(f' {g[\"name\"]}: {len(g[\"rules\"])} rules')
\""
預期:6+ groups,總規則數 > 30。
- Step 2.5: Commit
git add scripts/ops/deploy-alerts.sh
git commit -m "ops(scripts): 加入 deploy-alerts.sh 自動部署 Prometheus 規則
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 3:CD pipeline 自動同步告警規則
說明: 修改 .gitea/workflows/cd.yaml,加入 deploy-alerts job。當 ops/monitoring/alerts-unified.yml 有變更時,自動部署到 110。
Files:
-
Modify:
.gitea/workflows/cd.yaml -
Step 3.1: 確認 CD 文件末尾
tail -20 .gitea/workflows/cd.yaml
記錄最後一個 job 的名稱(通常是 build-and-deploy)。
- Step 3.2: 加入 deploy-alerts job
在 .gitea/workflows/cd.yaml 的 on.push.paths 區塊加入:
找到:
paths:
# 只有實際影響部署的程式碼才觸發 CD
- 'apps/**'
- 'k8s/**'
- '.gitea/workflows/**'
# docs/、memory/、ADR、ops/ 等不觸發
改為:
paths:
# 只有實際影響部署的程式碼才觸發 CD
- 'apps/**'
- 'k8s/**'
- '.gitea/workflows/**'
- 'ops/monitoring/alerts-unified.yml' # 2026-04-05 Claude Code: 告警規則變更自動部署
在 jobs 末尾加入新 job(在 build-and-deploy: 同層):
deploy-alerts:
name: "Deploy Prometheus Alert Rules"
# 2026-04-05 Claude Code: 告警規則 CD — 不依賴 build-and-deploy
# 觸發條件: ops/monitoring/alerts-unified.yml 有變更
runs-on: ubuntu-latest
timeout-minutes: 5
if: |
contains(github.event.commits[*].modified, 'ops/monitoring/alerts-unified.yml') ||
github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- name: Validate alerts YAML
run: python3 -c "import yaml; yaml.safe_load(open('ops/monitoring/alerts-unified.yml')); print('YAML OK')"
- name: Deploy alerts to Prometheus
run: bash scripts/ops/deploy-alerts.sh
- name: Notify deploy result
if: always()
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run: |
STATUS="${{ job.status }}"
EMOJI="✅"
[ "$STATUS" != "success" ] && EMOJI="❌"
curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
-d "text=${EMOJI} Prometheus 告警規則部署 ${STATUS} (commit: ${GITHUB_SHA::7})" || true
- Step 3.3: Commit 並推送到 Gitea 觸發驗證
git add .gitea/workflows/cd.yaml
git commit -m "ci: 加入 deploy-alerts CD job — 告警規則變更自動部署到 Prometheus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
git push gitea main
- Step 3.4: 監控 Gitea Actions 確認 deploy-alerts job 成功
# 瀏覽器開啟或 curl 確認
curl -s -u 'wooo:TOKEN' 'http://192.168.0.110:3001/api/v1/repos/wooo/awoooi/actions/runs?limit=3' | python3 -c "
import sys, json
runs = json.load(sys.stdin)
for r in runs.get('workflow_runs', [])[:3]:
print(r['name'], r['status'], r['conclusion'])
"
Task 4:啟動 Sentry + 加入 startup-110.sh
Files:
-
Modify:
scripts/reboot-recovery/awoooi-startup-110.sh -
Step 4.1: 手動啟動 Sentry 驗證
ssh wooo@192.168.0.110 "cd /opt/sentry && docker compose up -d 2>&1 | tail -10"
sleep 30
ssh wooo@192.168.0.110 "curl -s -o /dev/null -w '%{http_code}' http://localhost:9000/api/0/projects/"
預期:HTTP 200(或 403,表示 Sentry 已啟動但需登入)。
- Step 4.2: 加入 startup-110.sh Step 7
找到 scripts/reboot-recovery/awoooi-startup-110.sh 的最後 # 完成 區塊:
# ──────────────────────────────────────────────
# 完成
# ──────────────────────────────────────────────
log "=== 192.168.0.110 啟動序列完成 ==="
在它之前插入:
# ──────────────────────────────────────────────
# STEP 7: Sentry(Error Tracking)
# 2026-04-05 Claude Code: 加入 — 解決重開機後 Sentry 未自動啟動
# 安裝位置: /opt/sentry (2026-03-24 已安裝)
# DSN: awoooi-web :2, awoooi-api :3 (見 memory/project_sentry_full_integration.md)
# ──────────────────────────────────────────────
log "[7/7] 啟動 Sentry..."
SENTRY_DIR="/opt/sentry"
if [ -d "$SENTRY_DIR" ]; then
cd "$SENTRY_DIR"
docker compose up -d 2>&1 | tail -5
log "✅ Sentry 啟動指令已發送 (啟動約需 2-3 分鐘)"
sleep 20
# 非阻塞驗證:Sentry 啟動慢,只做快速健康檢查
if curl -sf --max-time 10 http://localhost:9000/api/0/projects/ >/dev/null 2>&1 || \
curl -sf --max-time 10 -o /dev/null -w "%{http_code}" http://localhost:9000/ | grep -q "200\|302\|400"; then
log "✅ Sentry 已回應"
else
log "⚠️ Sentry 尚未就緒(正常現象,通常需 2-3 分鐘)"
fi
else
log "⚠️ 找不到 Sentry 目錄: $SENTRY_DIR"
fi
同時更新末尾日誌行,加入 Sentry URL:
找到:
log "Gitea Runner: docker logs gitea-runner"
改為:
log "Gitea Runner: docker logs gitea-runner"
log "Sentry: http://192.168.0.110:9000"
- Step 4.3: 部署到 110
scp scripts/reboot-recovery/awoooi-startup-110.sh wooo@192.168.0.110:/usr/local/bin/awoooi-startup-110.sh
ssh wooo@192.168.0.110 "chmod +x /usr/local/bin/awoooi-startup-110.sh"
- Step 4.4: Commit
git add scripts/reboot-recovery/awoooi-startup-110.sh
git commit -m "ops(startup): startup-110.sh 加入 Step 7 Sentry 自動啟動
Sentry 已安裝於 /opt/sentry (2026-03-24),但重開機後未自動啟動
加入非阻塞啟動:docker compose up -d + 20s 等待 + 快速健康檢查
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 5:更新 REBOOT-RECOVERY-SOP.md v4.0
Files:
-
Modify:
docs/runbooks/REBOOT-RECOVERY-SOP.md -
Step 5.1: 更新版本號和摘要
找到文件開頭:
> **版本**: v3.0
> **最後更新**: 2026-04-05 (台北時間)
> **更新者**: Claude Code (首席架構師)
> **觸發事件**: 兩次重開機事故後完整盤點 + 告警鏈路根因修復 + Gitea Runner 自動化
改為:
> **版本**: v4.0
> **最後更新**: 2026-04-05 (台北時間)
> **更新者**: Claude Code (首席架構師)
> **觸發事件**: Prometheus 規則未部署根因修復 + Sentry 啟動自動化 + 全系統自愈閉環設計
- Step 5.2: 更新架構圖加入 Sentry
找到:
├── Alertmanager :9093 ← 告警路由
├── Grafana :3002 ← 監控儀表板
└── SignOz ← 可觀測性
改為:
├── Alertmanager :9093 ← 告警路由
├── Grafana :3002 ← 監控儀表板
├── Sentry :9000 ← Error Tracking (2026-03-24,2026-04-05 加入 startup)
└── SignOz ← 可觀測性
- Step 5.3: 更新 110 自動化腳本清單
找到:
**110 (6 步驟)**:
改為:
**110 (7 步驟)**:
在末尾加入:
- **Gitea Act Runner** (自動清除過期 .runner 配置)
- **Sentry** (/opt/sentry,Error Tracking)
- Step 5.4: 更新 110 詳細啟動序列表格
找到 192.168.0.110 啟動序列表格末尾,加入:
| 7 | Sentry | `curl http://localhost:9000/api/0/projects/` | Docker 未啟動 (重開機後) |
- Step 5.5: 更新告警沉默故障排查加入「規則未部署」診斷
在告警沉默診斷樹末尾加入:
**補充診斷 — 特定服務無告警但 Alertmanager 正常:**
確認 Prometheus 是否有該規則: ssh wooo@192.168.0.110 "curl -s http://localhost:9090/api/v1/rules | python3 -c "import sys,json; r=json.load(sys.stdin); names=[x['name'] for g in r['data']['groups'] for x in g['rules']]; [print(n) for n in names if 'Sentry' in n or 'Harbor' in n or 'Gitea' in n]""
若規則不存在: → 規則未部署,執行: bash scripts/ops/deploy-alerts.sh 若規則存在但 inactive: → 檢查 blackbox probe target IP 是否正確 → curl http://192.168.0.110:9090/api/v1/query?query=probe_success
- Step 5.6: 更新 E2E 驗證腳本加入 Sentry + 規則數
在 E2E 驗證腳本的 # 告警鏈路 E2E 之前加入:
check "110 Sentry" "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://192.168.0.110:9000/" "200\|302\|400"
check "Prometheus rules >30" "ssh wooo@192.168.0.110 'curl -s http://localhost:9090/api/v1/rules' | python3 -c \"import sys,json; r=json.load(sys.stdin); n=sum(len(g['rules']) for g in r['data']['groups']); print(n)\"" "[3-9][0-9]"
- Step 5.7: 更新版本歷史
在版本歷史表格加入:
| v4.0 | 2026-04-05 下午 | Prometheus 規則統一部署 + Sentry startup + 診斷樹補充 + E2E 腳本更新 |
- Step 5.8: Commit
git add docs/runbooks/REBOOT-RECOVERY-SOP.md
git commit -m "docs(sop): REBOOT-RECOVERY-SOP.md v4.0
更新:
- 加入 Sentry /opt/sentry 啟動說明 (110 Step 7)
- 告警沉默診斷樹補充「規則未部署」診斷
- E2E 驗證腳本加入 Sentry + Prometheus 規則數驗證
- 架構圖補充 Sentry :9000
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 6:Sprint 1 驗收
- Step 6.1: 執行完整 E2E 驗證
# 確認 Prometheus 規則
ssh wooo@192.168.0.110 "curl -s http://localhost:9090/api/v1/rules | python3 -c \"
import sys, json
r = json.load(sys.stdin)
names = [x['name'] for g in r['data']['groups'] for x in g['rules']]
key = ['SentryDown','HarborDown','GiteaDown','OpenClawDown','AlertmanagerDown','AlertChainUnhealthy']
for k in key:
print(f'{'✅' if k in names else '❌'} {k}')
print(f'Total rules: {len(names)}')
\""
預期:所有 6 條關鍵規則 ✅,Total rules ≥ 30。
- Step 6.2: 確認 Sentry 健康
curl -s -o /dev/null -w "%{http_code}" http://192.168.0.110:9000/
預期:200、302 或 400 (需登入,表示服務正常)。
- Step 6.3: 觸發測試告警確認鏈路
curl -X POST http://192.168.0.121:32334/api/v1/webhooks/alertmanager \
-H 'Content-Type: application/json' \
-d '{
"receiver":"test","status":"firing",
"alerts":[{
"status":"firing",
"labels":{"alertname":"Sprint1ValidationTest","severity":"info","layer":"k8s","component":"test"},
"annotations":{"summary":"Sprint 1 驗收測試,請忽略"},
"startsAt":"2026-04-05T00:00:00Z","endsAt":"0001-01-01T00:00:00Z","generatorURL":""
}],
"groupLabels":{},"commonLabels":{},"commonAnnotations":{},"externalURL":"","version":"4","groupKey":"sprint1-test"
}'
預期:{"success":true,...} 且 Telegram 收到測試告警。
Sprint 1 ✅ 完成條件:規則 ≥ 30、關鍵規則全部存在、Sentry 回應、告警鏈路通。
SPRINT 2:SigNoz Log Alerting + Sentry 整合完善
Task 7:SigNoz Log Alert Rules 建立
說明: SigNoz 不支援 YAML 匯入,alert rules 透過 UI 設定。本 task 建立文檔並逐一在 UI 設定。
Files:
-
Create:
ops/signoz/alerting/log-rules.md -
Step 7.1: 建立 log-rules.md 文檔
建立 ops/signoz/alerting/log-rules.md:
# SigNoz Log-Based Alert Rules
# 2026-04-05 Claude Code: Sprint 2 — 日誌告警
# 設定位置: http://192.168.0.188:3301/alerts (SigNoz UI)
# Webhook: http://192.168.0.121:32334/api/v1/webhooks/signoz
## Rule 1: API 高錯誤日誌率
| 欄位 | 值 |
|------|-----|
| Name | APIHighErrorLogRate |
| Type | Logs Based Alert |
| Query | `service.name = "awoooi-api" AND severity_text = "ERROR"` |
| Condition | Count > 10 per 5m |
| For | 5m |
| Severity | warning |
| Labels | layer=k8s, component=api, team=backend |
## Rule 2: Worker 任務失敗
| 欄位 | 值 |
|------|-----|
| Name | WorkerTaskFailed |
| Type | Logs Based Alert |
| Query | `service.name = "awoooi-worker" AND (body CONTAINS "task_failed" OR body CONTAINS "Unhandled exception")` |
| Condition | Count > 5 per 5m |
| For | 5m |
| Severity | warning |
| Labels | layer=k8s, component=worker, team=backend |
## Rule 3: Pod OOM Kill
| 欄位 | 值 |
|------|-----|
| Name | PodOOMKilled |
| Type | Logs Based Alert |
| Query | `body CONTAINS "OOMKilled" OR body CONTAINS "OutOfMemory"` |
| Condition | Count > 0 per 1m |
| For | 1m |
| Severity | critical |
| Labels | layer=k8s, component=k8s, team=ops |
## Rule 4: Telegram Polling 失敗
| 欄位 | 值 |
|------|-----|
| Name | TelegramPollingFailed |
| Type | Logs Based Alert |
| Query | `service.name = "awoooi-api" AND body CONTAINS "telegram_polling_error"` |
| Condition | Count > 3 per 5m |
| For | 5m |
| Severity | critical |
| Labels | layer=k8s, component=api, team=platform |
## Rule 5: Nemotron 全部超時
| 欄位 | 值 |
|------|-----|
| Name | NemotronAllTimeout |
| Type | Logs Based Alert |
| Query | `service.name = "awoooi-api" AND body CONTAINS "nemotron_tool_call_timeout"` |
| Condition | Count > 5 per 5m |
| For | 5m |
| Severity | warning |
| Labels | layer=k8s, component=ai, team=ai |
## 設定步驟
1. 開啟 http://192.168.0.188:3301/alerts
2. 點擊 "New Alert Rule"
3. 選擇 "Logs Based Alert"
4. 填入上述欄位
5. Notification Channel: 選擇 awoooi-api webhook
6. 保存並啟用
- Step 7.2: 在 SigNoz UI 設定 Rule 1 (APIHighErrorLogRate)
開啟 http://192.168.0.188:3301/alerts,依照上面 log-rules.md 的 Rule 1 設定。
設定完成後,在 UI 確認 rule 狀態為 inactive (表示已設定,目前無觸發)。
- Step 7.3: 設定其餘 4 條規則 (Rule 2-5)
依照 log-rules.md 逐一設定 Rule 2、3、4、5。
- Step 7.4: 驗證 SigNoz webhook 鏈路
curl -X POST http://192.168.0.121:32334/api/v1/webhooks/signoz \
-H 'Content-Type: application/json' \
-d '{
"alerts": [{
"labels": {"alertname": "Sprint2LogAlertTest", "severity": "info", "layer": "k8s"},
"annotations": {"summary": "Sprint 2 log alert 驗證,請忽略"},
"status": "firing"
}],
"version": "4"
}'
預期:{"success":true}
- Step 7.5: Commit log-rules.md
git add ops/signoz/alerting/log-rules.md
git commit -m "ops(signoz): 建立 log-based alert rules 文檔 (Sprint 2)
5 條規則: APIHighErrorLogRate/WorkerTaskFailed/PodOOMKilled/
TelegramPollingFailed/NemotronAllTimeout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 8:Sentry SDK Tags 補全
說明: 在 API 的 Sentry init 加入統一標籤,讓 Sentry 事件攜帶 layer/component 資訊。
Files:
-
Modify:
apps/api/src/main.py -
Step 8.1: 確認 main.py Sentry 初始化位置
grep -n "sentry_sdk\|sentry.init\|SENTRY_DSN" apps/api/src/main.py | head -10
- Step 8.2: 加入統一 tags
找到 Sentry init 的設定(通常是 sentry_sdk.init(...) 或在 configure_sentry() 函式)。
在 sentry_sdk.init() 呼叫後加入:
# 2026-04-05 Claude Code: 加入統一標籤,對齊 Prometheus/auto_repair layer 規範
import sentry_sdk
sentry_sdk.set_tag("layer", "k8s")
sentry_sdk.set_tag("component", "api")
sentry_sdk.set_tag("host", "k8s-awoooi-prod")
sentry_sdk.set_tag("team", "backend")
- Step 8.3: 驗證 (啟動 API 後確認 Sentry 事件有 tag)
# 在 API pod 內觸發一個測試 exception
kubectl exec -n awoooi-prod deploy/awoooi-api -- python3 -c "
import sentry_sdk
sentry_sdk.capture_message('Sprint2 tag validation', level='info')
print('sent')
"
在 Sentry UI (http://192.168.0.110:9000) 確認事件有 layer: k8s tag。
- Step 8.4: Commit
git add apps/api/src/main.py
git commit -m "feat(api): Sentry init 加入統一 layer/component 標籤
對齊 Prometheus 告警標籤規範 (layer/component/team)
讓 Sentry 事件與 auto_repair 路由決策保持一致
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
SPRINT 3:Host Auto-Repair Agent (SSH)
Task 9:主機白名單修復腳本
說明: 在 110/188 建立嚴格白名單的修復腳本,SSH key 的 command= 限制只能執行此腳本,防止任意命令執行。
Files:
-
Create:
scripts/repair-bot/repair-bot-110.sh -
Create:
scripts/repair-bot/repair-bot-188.sh -
Step 9.1: 建立修復腳本目錄
mkdir -p scripts/repair-bot
- Step 9.2: 建立 repair-bot-110.sh
建立 scripts/repair-bot/repair-bot-110.sh:
#!/bin/bash
# scripts/repair-bot/repair-bot-110.sh
# 修復機器人白名單腳本 — 110 主機 (DevOps 金庫)
# 2026-04-05 Claude Code: Sprint 3 Host Auto-Repair
#
# 安全設計:
# - SSH authorized_keys 的 command= 指向此腳本
# - 只允許執行 ALLOWED_COMMANDS 中定義的修復指令
# - 格式: repair:<component>
# - SSH key 洩漏也只能執行白名單內的 docker compose up -d
#
# 部署位置: /home/wooo/bin/repair-bot-110.sh (on 192.168.0.110)
# 使用者: wooo
LOG="/var/log/awoooi-repair-bot.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
# 白名單: component → compose dir
declare -A COMPOSE_DIRS=(
["sentry"]="/opt/sentry"
["harbor"]="/home/wooo/harbor/harbor"
["gitea"]="/home/wooo/gitea"
["gitea-runner"]="/home/wooo/act-runner"
["langfuse"]="/home/wooo/langfuse"
["alertmanager"]="/home/wooo/monitoring"
["signoz"]="/home/wooo/signoz/deploy/docker"
)
CMD="${SSH_ORIGINAL_COMMAND:-}"
log "repair-bot-110 invoked: CMD=$CMD"
if [[ "$CMD" =~ ^repair:([a-z0-9_-]+)$ ]]; then
COMPONENT="${BASH_REMATCH[1]}"
DIR="${COMPOSE_DIRS[$COMPONENT]}"
if [ -z "$DIR" ]; then
log "DENIED: unknown component '$COMPONENT'"
echo "REPAIR_DENIED:unknown_component:$COMPONENT"
exit 1
fi
if [ ! -d "$DIR" ]; then
log "DENIED: directory not found '$DIR'"
echo "REPAIR_DENIED:dir_not_found:$DIR"
exit 1
fi
log "EXECUTING: cd $DIR && docker compose up -d"
cd "$DIR" && docker compose up -d 2>&1 | tail -5
EXIT_CODE=$?
if [ $EXIT_CODE -eq 0 ]; then
log "REPAIR_OK: $COMPONENT"
echo "REPAIR_OK:$COMPONENT"
else
log "REPAIR_FAIL: $COMPONENT (exit $EXIT_CODE)"
echo "REPAIR_FAIL:$COMPONENT:exit_$EXIT_CODE"
exit 1
fi
elif [ "$CMD" = "health" ]; then
# 健康檢查 — 允許連線測試
echo "REPAIR_BOT_HEALTHY:110"
else
log "DENIED: invalid command '$CMD'"
echo "REPAIR_DENIED:invalid_command"
exit 1
fi
- Step 9.3: 建立 repair-bot-188.sh
建立 scripts/repair-bot/repair-bot-188.sh:
#!/bin/bash
# scripts/repair-bot/repair-bot-188.sh
# 修復機器人白名單腳本 — 188 主機 (主服務主機)
# 2026-04-05 Claude Code: Sprint 3 Host Auto-Repair
#
# 部署位置: /home/ollama/bin/repair-bot-188.sh (on 192.168.0.188)
# 使用者: ollama
LOG="/var/log/awoooi-repair-bot.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG"; }
# 白名單: component → 修復方式
declare -A COMPOSE_DIRS=(
["openclaw"]="/home/ollama/clawbot-v5"
["minio"]="/home/ollama/minio"
["signoz"]="/home/ollama/signoz/deploy/docker"
)
declare -A SYSTEMD_SERVICES=(
["redis"]="redis-server"
["nginx"]="nginx"
["ollama"]="ollama"
)
CMD="${SSH_ORIGINAL_COMMAND:-}"
log "repair-bot-188 invoked: CMD=$CMD"
if [[ "$CMD" =~ ^repair:([a-z0-9_-]+)$ ]]; then
COMPONENT="${BASH_REMATCH[1]}"
# Docker Compose 類
DIR="${COMPOSE_DIRS[$COMPONENT]}"
if [ -n "$DIR" ]; then
if [ ! -d "$DIR" ]; then
log "DENIED: directory not found '$DIR'"
echo "REPAIR_DENIED:dir_not_found:$DIR"
exit 1
fi
log "EXECUTING: cd $DIR && docker compose up -d"
cd "$DIR" && docker compose up -d 2>&1 | tail -5
EXIT_CODE=$?
[ $EXIT_CODE -eq 0 ] && echo "REPAIR_OK:$COMPONENT" || { echo "REPAIR_FAIL:$COMPONENT"; exit 1; }
exit 0
fi
# Systemd 類
SVC="${SYSTEMD_SERVICES[$COMPONENT]}"
if [ -n "$SVC" ]; then
log "EXECUTING: sudo systemctl restart $SVC"
sudo systemctl restart "$SVC" 2>&1
EXIT_CODE=$?
[ $EXIT_CODE -eq 0 ] && echo "REPAIR_OK:$COMPONENT" || { echo "REPAIR_FAIL:$COMPONENT"; exit 1; }
exit 0
fi
log "DENIED: unknown component '$COMPONENT'"
echo "REPAIR_DENIED:unknown_component:$COMPONENT"
exit 1
elif [ "$CMD" = "health" ]; then
echo "REPAIR_BOT_HEALTHY:188"
else
log "DENIED: invalid command '$CMD'"
echo "REPAIR_DENIED:invalid_command"
exit 1
fi
- Step 9.4: 部署腳本到主機
# 部署到 110
ssh wooo@192.168.0.110 "mkdir -p /home/wooo/bin"
scp scripts/repair-bot/repair-bot-110.sh wooo@192.168.0.110:/home/wooo/bin/repair-bot-110.sh
ssh wooo@192.168.0.110 "chmod +x /home/wooo/bin/repair-bot-110.sh"
# 部署到 188
ssh ollama@192.168.0.188 "mkdir -p /home/ollama/bin"
scp scripts/repair-bot/repair-bot-188.sh ollama@192.168.0.188:/home/ollama/bin/repair-bot-188.sh
ssh ollama@192.168.0.188 "chmod +x /home/ollama/bin/repair-bot-188.sh"
- Step 9.5: Commit
git add scripts/repair-bot/
git commit -m "ops(repair-bot): 主機白名單修復腳本 (Sprint 3)
110: sentry/harbor/gitea/gitea-runner/langfuse/alertmanager
188: openclaw/minio/signoz/redis/nginx/ollama
安全設計: command= 限制 + 嚴格白名單 + 操作日誌
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 10:SSH Key 基礎設施
Files:
-
Create:
k8s/awoooi-prod/04-repair-ssh-secret.yaml -
Step 10.1: 生成 SSH keypair
# 在 Mac 本機執行(不上傳私鑰到 Git)
ssh-keygen -t ed25519 -C "awoooi-repair-bot-2026" -f /tmp/awoooi_repair_bot -N ""
echo "Public key:"
cat /tmp/awoooi_repair_bot.pub
記錄公鑰內容(格式:ssh-ed25519 AAAA... awoooi-repair-bot-2026)。
- Step 10.2: 在 110 設定受限 authorized_keys
# 將以下內容加入 110 的 authorized_keys(替換 PUBKEY 為實際公鑰)
PUBKEY=$(cat /tmp/awoooi_repair_bot.pub)
ssh wooo@192.168.0.110 "echo 'command=\"/home/wooo/bin/repair-bot-110.sh\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${PUBKEY}' >> ~/.ssh/authorized_keys"
# 驗證
ssh -i /tmp/awoooi_repair_bot -o StrictHostKeyChecking=accept-new wooo@192.168.0.110 "health"
預期輸出:REPAIR_BOT_HEALTHY:110
- Step 10.3: 在 188 設定受限 authorized_keys
PUBKEY=$(cat /tmp/awoooi_repair_bot.pub)
ssh ollama@192.168.0.188 "echo 'command=\"/home/ollama/bin/repair-bot-188.sh\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding ${PUBKEY}' >> ~/.ssh/authorized_keys"
# 驗證
ssh -i /tmp/awoooi_repair_bot -o StrictHostKeyChecking=accept-new ollama@192.168.0.188 "health"
預期輸出:REPAIR_BOT_HEALTHY:188
- Step 10.4: 存入 K8s Secret
kubectl create secret generic awoooi-repair-ssh-key \
-n awoooi-prod \
--from-file=id_ed25519=/tmp/awoooi_repair_bot \
--from-file=id_ed25519.pub=/tmp/awoooi_repair_bot.pub
# 驗證
kubectl get secret awoooi-repair-ssh-key -n awoooi-prod
- Step 10.5: 建立 Secret YAML template (不含實際 key)
建立 k8s/awoooi-prod/04-repair-ssh-secret.yaml:
# k8s/awoooi-prod/04-repair-ssh-secret.yaml
# SSH Secret Template — 不含實際私鑰 (存於 K8s,不上 Git)
# 2026-04-05 Claude Code: Sprint 3 Host Auto-Repair
#
# 建立方式:
# ssh-keygen -t ed25519 -C "awoooi-repair-bot-2026" -f /tmp/awoooi_repair_bot -N ""
# kubectl create secret generic awoooi-repair-ssh-key \
# -n awoooi-prod \
# --from-file=id_ed25519=/tmp/awoooi_repair_bot \
# --from-file=id_ed25519.pub=/tmp/awoooi_repair_bot.pub
#
# 主機配置 (已完成):
# 110 ~/.ssh/authorized_keys: command="/home/wooo/bin/repair-bot-110.sh",... <pubkey>
# 188 ~/.ssh/authorized_keys: command="/home/ollama/bin/repair-bot-188.sh",... <pubkey>
apiVersion: v1
kind: Secret
metadata:
name: awoooi-repair-ssh-key
namespace: awoooi-prod
annotations:
# 此 template 不含實際私鑰,需手動 kubectl create secret 建立
awoooi.io/secret-type: "ssh-repair-bot"
awoooi.io/created: "2026-04-05"
type: Opaque
# data: 不在版控中 — 使用 kubectl create secret 建立
- Step 10.6: Commit (只 commit template,不 commit 私鑰)
# 確認私鑰不在 git 追蹤中
git status | grep -v awoooi_repair_bot # 確認 /tmp/ 的 key 不會被加入
git add k8s/awoooi-prod/04-repair-ssh-secret.yaml
git commit -m "k8s(secret): 加入 repair-ssh-key Secret template (不含實際私鑰)
實際私鑰透過 kubectl create secret 手動建立,不上 Git
主機 110/188 的 authorized_keys 已設定 command= 限制
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 11:HostRepairAgent Python 模組
Files:
-
Create:
apps/api/src/services/host_repair_agent.py -
Create:
apps/api/tests/test_host_repair_agent.py -
Step 11.1: 寫測試 (先)
建立 apps/api/tests/test_host_repair_agent.py:
"""
tests/test_host_repair_agent.py
Host Repair Agent 單元測試
不需要實際 SSH 連線 — 測試路由邏輯和命令組裝
"""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
# =============================================================================
# 測試 HostRepairConfig 路由
# =============================================================================
class TestHostRepairConfig:
def test_layer_docker_110_routes_to_110(self):
from src.services.host_repair_agent import get_ssh_config_for_layer
config = get_ssh_config_for_layer("docker-110")
assert config["user"] == "wooo"
assert config["host"] == "192.168.0.110"
def test_layer_docker_188_routes_to_188(self):
from src.services.host_repair_agent import get_ssh_config_for_layer
config = get_ssh_config_for_layer("docker-188")
assert config["user"] == "ollama"
assert config["host"] == "192.168.0.188"
def test_layer_systemd_188_routes_to_188(self):
from src.services.host_repair_agent import get_ssh_config_for_layer
config = get_ssh_config_for_layer("systemd-188")
assert config["user"] == "ollama"
assert config["host"] == "192.168.0.188"
def test_unknown_layer_raises(self):
from src.services.host_repair_agent import get_ssh_config_for_layer
with pytest.raises(ValueError, match="Unknown layer"):
get_ssh_config_for_layer("unknown-layer")
def test_k8s_layer_raises(self):
"""k8s layer 不走 SSH,應 raise"""
from src.services.host_repair_agent import get_ssh_config_for_layer
with pytest.raises(ValueError, match="layer 'k8s' is handled by kubectl"):
get_ssh_config_for_layer("k8s")
# =============================================================================
# 測試 SSH 命令組裝
# =============================================================================
class TestSSHCommandBuilding:
def test_repair_command_format(self):
from src.services.host_repair_agent import build_repair_command
cmd = build_repair_command("sentry")
assert cmd == "repair:sentry"
def test_repair_command_component_sanitized(self):
"""防止 command injection"""
from src.services.host_repair_agent import build_repair_command
with pytest.raises(ValueError, match="Invalid component"):
build_repair_command("sentry; rm -rf /")
def test_repair_command_valid_components(self):
from src.services.host_repair_agent import build_repair_command
valid = ["sentry", "harbor", "gitea", "openclaw", "gitea-runner", "alertmanager", "redis", "nginx"]
for component in valid:
cmd = build_repair_command(component)
assert cmd == f"repair:{component}"
# =============================================================================
# 測試 HostRepairAgent.repair() 路由
# =============================================================================
class TestHostRepairAgent:
@pytest.mark.asyncio
async def test_repair_success_returns_ok(self):
from src.services.host_repair_agent import HostRepairAgent
agent = HostRepairAgent()
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
mock_ssh.return_value = "REPAIR_OK:sentry"
result = await agent.repair(layer="docker-110", component="sentry")
assert result.success is True
assert result.component == "sentry"
assert result.layer == "docker-110"
mock_ssh.assert_called_once_with(
host="192.168.0.110",
user="wooo",
command="repair:sentry"
)
@pytest.mark.asyncio
async def test_repair_fail_returns_failure(self):
from src.services.host_repair_agent import HostRepairAgent
agent = HostRepairAgent()
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
mock_ssh.return_value = "REPAIR_FAIL:harbor:exit_1"
result = await agent.repair(layer="docker-110", component="harbor")
assert result.success is False
assert "REPAIR_FAIL" in result.error
@pytest.mark.asyncio
async def test_repair_ssh_timeout_returns_failure(self):
from src.services.host_repair_agent import HostRepairAgent
import asyncio
agent = HostRepairAgent()
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
mock_ssh.side_effect = asyncio.TimeoutError()
result = await agent.repair(layer="docker-110", component="sentry")
assert result.success is False
assert "timeout" in result.error.lower()
@pytest.mark.asyncio
async def test_repair_denied_returns_failure(self):
from src.services.host_repair_agent import HostRepairAgent
agent = HostRepairAgent()
with patch.object(agent, "_ssh_execute", new_callable=AsyncMock) as mock_ssh:
mock_ssh.return_value = "REPAIR_DENIED:unknown_component:badcomponent"
result = await agent.repair(layer="docker-110", component="badcomponent")
assert result.success is False
- Step 11.2: 執行測試確認全部 FAIL(模組未建立)
cd apps/api
python -m pytest tests/test_host_repair_agent.py -v 2>&1 | head -20
預期:ImportError 或 ModuleNotFoundError。
- Step 11.3: 建立 host_repair_agent.py
建立 apps/api/src/services/host_repair_agent.py:
"""
Host Repair Agent
=================
透過 SSH 執行主機層 (Docker/systemd) 修復動作
安全設計:
- SSH key 限制: authorized_keys 的 command= 強制只能執行白名單腳本
- Component 白名單驗證: 防止 command injection
- Timeout: 60 秒超時防止卡住
Layer 對應:
docker-110 → SSH wooo@192.168.0.110 → repair-bot-110.sh
docker-188 → SSH ollama@192.168.0.188 → repair-bot-188.sh
systemd-188 → SSH ollama@192.168.0.188 → repair-bot-188.sh
建立時間: 2026-04-05 (台北時區)
建立者: Claude Code (Sprint 3 Host Auto-Repair)
"""
import asyncio
import re
from dataclasses import dataclass, field
from pathlib import Path
import structlog
logger = structlog.get_logger(__name__)
# SSH key 路徑 (從 K8s Secret 掛載)
SSH_KEY_PATH = "/etc/repair-ssh/id_ed25519"
SSH_TIMEOUT = 60 # 秒
# Layer → SSH 配置
LAYER_SSH_CONFIG: dict[str, dict[str, str]] = {
"docker-110": {"user": "wooo", "host": "192.168.0.110"},
"docker-188": {"user": "ollama", "host": "192.168.0.188"},
"systemd-188": {"user": "ollama", "host": "192.168.0.188"},
}
# 允許的 component 名稱 (白名單)
VALID_COMPONENT_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{0,30}$")
# =============================================================================
# Data classes
# =============================================================================
@dataclass
class HostRepairResult:
"""SSH 修復執行結果"""
success: bool
component: str
layer: str
output: str = ""
error: str | None = None
# =============================================================================
# 純函式 (純邏輯,可單元測試)
# =============================================================================
def get_ssh_config_for_layer(layer: str) -> dict[str, str]:
"""
根據 layer 返回 SSH 連線配置
Raises:
ValueError: layer 不支援 SSH 修復
"""
if layer == "k8s":
raise ValueError("layer 'k8s' is handled by kubectl, not SSH")
config = LAYER_SSH_CONFIG.get(layer)
if config is None:
raise ValueError(f"Unknown layer: '{layer}'. Valid: {list(LAYER_SSH_CONFIG.keys())}")
return config
def build_repair_command(component: str) -> str:
"""
組裝修復命令,並驗證 component 格式防止 injection
Raises:
ValueError: component 格式不合法
"""
if not VALID_COMPONENT_PATTERN.match(component):
raise ValueError(f"Invalid component name: '{component}'. Must match [a-z0-9-]+")
return f"repair:{component}"
# =============================================================================
# HostRepairAgent
# =============================================================================
class HostRepairAgent:
"""
透過 SSH 執行主機修復。
使用 command= 受限的 SSH key,只能觸發白名單腳本。
"""
async def repair(
self,
layer: str,
component: str,
timeout: int = SSH_TIMEOUT,
) -> HostRepairResult:
"""
執行修復。
Args:
layer: "docker-110" | "docker-188" | "systemd-188"
component: 服務名稱,如 "sentry", "harbor"
timeout: SSH 命令超時秒數
Returns:
HostRepairResult
"""
log = logger.bind(layer=layer, component=component)
log.info("host_repair_start")
try:
config = get_ssh_config_for_layer(layer)
command = build_repair_command(component)
except ValueError as e:
log.error("host_repair_invalid_params", error=str(e))
return HostRepairResult(
success=False, component=component, layer=layer, error=str(e)
)
try:
output = await asyncio.wait_for(
self._ssh_execute(
host=config["host"],
user=config["user"],
command=command,
),
timeout=timeout,
)
except asyncio.TimeoutError:
log.error("host_repair_timeout", timeout=timeout)
return HostRepairResult(
success=False,
component=component,
layer=layer,
error=f"SSH command timeout after {timeout}s",
)
except Exception as e:
log.error("host_repair_ssh_error", error=str(e))
return HostRepairResult(
success=False, component=component, layer=layer, error=str(e)
)
success = output.startswith("REPAIR_OK:")
log.info(
"host_repair_complete",
success=success,
output=output[:100],
)
return HostRepairResult(
success=success,
component=component,
layer=layer,
output=output,
error=None if success else output,
)
async def health_check(self, layer: str) -> bool:
"""驗證 SSH 連線是否正常"""
try:
config = get_ssh_config_for_layer(layer)
output = await asyncio.wait_for(
self._ssh_execute(host=config["host"], user=config["user"], command="health"),
timeout=10,
)
return "REPAIR_BOT_HEALTHY" in output
except Exception:
return False
async def _ssh_execute(self, host: str, user: str, command: str) -> str:
"""
執行 SSH 命令。
使用 subprocess 呼叫 ssh,key 從 SSH_KEY_PATH 讀取。
Note: 使用 subprocess 而非 paramiko,避免引入額外依賴。
command= 限制確保 SSH 只能執行白名單腳本。
"""
key_path = SSH_KEY_PATH
if not Path(key_path).exists():
# 開發環境 fallback (測試用)
raise FileNotFoundError(f"SSH key not found: {key_path}")
proc = await asyncio.create_subprocess_exec(
"ssh",
"-i", key_path,
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=10",
"-o", "BatchMode=yes",
f"{user}@{host}",
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
output = stdout.decode().strip()
if proc.returncode != 0 and not output:
output = stderr.decode().strip() or f"SSH exit code {proc.returncode}"
return output
# =============================================================================
# Singleton
# =============================================================================
_host_repair_agent: HostRepairAgent | None = None
def get_host_repair_agent() -> HostRepairAgent:
global _host_repair_agent
if _host_repair_agent is None:
_host_repair_agent = HostRepairAgent()
return _host_repair_agent
- Step 11.4: 執行測試確認通過
cd apps/api
python -m pytest tests/test_host_repair_agent.py -v
預期:所有測試 PASS。
- Step 11.5: Commit
git add apps/api/src/services/host_repair_agent.py apps/api/tests/test_host_repair_agent.py
git commit -m "feat(api): HostRepairAgent — SSH 主機層修復 Agent
透過 command= 受限 SSH key 觸發 110/188 白名單修復腳本
支援 layer: docker-110 / docker-188 / systemd-188
component 格式驗證防止 command injection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 12:整合 HostRepairAgent 到 auto_repair_service
Files:
-
Modify:
apps/api/src/models/playbook.py -
Modify:
apps/api/src/services/auto_repair_service.py -
Step 12.1: 加入 ActionType.SSH_COMMAND
找到 apps/api/src/models/playbook.py 的 ActionType:
class ActionType(str, Enum):
KUBECTL = "kubectl"
SCRIPT = "script"
MANUAL = "manual"
改為:
class ActionType(str, Enum):
KUBECTL = "kubectl"
SCRIPT = "script"
MANUAL = "manual"
SSH_COMMAND = "ssh_command" # 2026-04-05 Claude Code: Sprint 3 主機層修復
- Step 12.2: 在 auto_repair_service.py 的 _execute_step 加入 SSH_COMMAND 路由
找到 apps/api/src/services/auto_repair_service.py 的 _execute_step 方法(約 L471-500):
async def _execute_step(self, incident: Incident, step) -> str:
...
if step.action_type == ActionType.MANUAL:
return "SKIPPED (manual step)"
if step.action_type == ActionType.KUBECTL:
...
return "UNKNOWN_ACTION_TYPE"
在 return "UNKNOWN_ACTION_TYPE" 之前加入:
if step.action_type == ActionType.SSH_COMMAND:
# 2026-04-05 Claude Code: Sprint 3 主機層修復
# command 格式: "<component>" (由 Playbook 定義)
# layer 從 incident.labels 取得
from src.services.host_repair_agent import get_host_repair_agent
layer = incident.labels.get("layer", "docker-110")
component = step.command.strip()
agent = get_host_repair_agent()
try:
result = await agent.repair(layer=layer, component=component)
if result.success:
return f"SUCCESS (SSH repair:{component} on {layer})"
else:
return f"FAILED: {result.error}"
except Exception as e:
logger.error("ssh_repair_step_error", error=str(e))
return f"FAILED: {e}"
- Step 12.3: 確認測試仍通過
cd apps/api
python -m pytest tests/test_auto_repair_service.py tests/test_host_repair_agent.py -v
預期:全部 PASS。
- Step 12.4: Commit
git add apps/api/src/models/playbook.py apps/api/src/services/auto_repair_service.py
git commit -m "feat(api): auto_repair_service 整合 SSH_COMMAND ActionType
_execute_step 加入 SSH_COMMAND 路由:
- 從 incident.labels['layer'] 決定連線目標
- 呼叫 HostRepairAgent.repair(layer, component)
- 修復結果記錄到 executed_steps
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 13:SSH Key 掛載到 API Pod
Files:
-
Modify:
k8s/awoooi-prod/01-deployments.yaml -
Step 13.1: 確認 API Deployment 位置
grep -n "awoooi-api\|containers:\|volumeMounts:\|volumes:" k8s/awoooi-prod/01-deployments.yaml | head -20
- Step 13.2: 加入 volume 和 volumeMount
在 API deployment 的 containers[0] 下找到 volumeMounts 或在 containers 末尾加入:
# 2026-04-05 Claude Code: Sprint 3 Host Repair SSH key
volumeMounts:
- name: repair-ssh-key
mountPath: /etc/repair-ssh
readOnly: true
在同一 deployment 的 spec.template.spec 下加入 volumes:
volumes:
- name: repair-ssh-key
secret:
secretName: awoooi-repair-ssh-key
defaultMode: 0400
- Step 13.3: 套用到 K3s
ssh wooo@192.168.0.120 "kubectl apply -f -" < k8s/awoooi-prod/01-deployments.yaml
ssh wooo@192.168.0.120 "kubectl rollout status deployment/awoooi-api -n awoooi-prod --timeout=120s"
- Step 13.4: 驗證 SSH key 已掛載
ssh wooo@192.168.0.120 "kubectl exec -n awoooi-prod deploy/awoooi-api -- ls -la /etc/repair-ssh/"
預期:看到 id_ed25519 和 id_ed25519.pub,權限 400。
- Step 13.5: Commit
git add k8s/awoooi-prod/01-deployments.yaml
git commit -m "k8s(api): API pod 掛載 repair SSH key secret
掛載 awoooi-repair-ssh-key 到 /etc/repair-ssh
供 HostRepairAgent 在 Sprint 3 SSH 修復時使用
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 14:建立 Host Repair Playbooks
說明: 透過 AWOOOI API 建立修復 Playbook,讓 auto_repair_service 能匹配並執行。
Files:
-
Create:
scripts/ops/seed-repair-playbooks.py -
Step 14.1: 建立 seed 腳本
建立 scripts/ops/seed-repair-playbooks.py:
#!/usr/bin/env python3
"""
seed-repair-playbooks.py
建立 Sprint 3 Host Repair Playbooks
用法: python3 scripts/ops/seed-repair-playbooks.py
需要: AWOOOI API 可訪問 (http://192.168.0.121:32334)
"""
import json
import urllib.request
import urllib.error
API_BASE = "http://192.168.0.121:32334"
PLAYBOOKS = [
{
"name": "sentry-down-repair",
"description": "Sentry (110) 離線自動修復",
"trigger_alert": "SentryDown",
"symptom_pattern": {
"keywords": ["SentryDown", "sentry", "9000"],
"severity": "warning",
"labels": {"layer": "docker-110", "component": "sentry"},
},
"repair_steps": [
{
"step_number": 1,
"action_type": "ssh_command",
"command": "sentry",
"description": "SSH 到 110,docker compose up -d Sentry",
"risk_level": "LOW",
"timeout_seconds": 60,
}
],
"tags": ["sentry", "docker-110", "auto-repair"],
"risk_level": "LOW",
},
{
"name": "harbor-down-repair",
"description": "Harbor Registry (110) 離線自動修復",
"trigger_alert": "HarborDown",
"symptom_pattern": {
"keywords": ["HarborDown", "harbor", "5000", "ImagePullBackOff"],
"severity": "critical",
"labels": {"layer": "docker-110", "component": "harbor"},
},
"repair_steps": [
{
"step_number": 1,
"action_type": "ssh_command",
"command": "harbor",
"description": "SSH 到 110,docker compose up -d Harbor",
"risk_level": "LOW",
"timeout_seconds": 120,
}
],
"tags": ["harbor", "docker-110", "auto-repair", "registry"],
"risk_level": "LOW",
},
{
"name": "gitea-down-repair",
"description": "Gitea (110) 離線自動修復",
"trigger_alert": "GiteaDown",
"symptom_pattern": {
"keywords": ["GiteaDown", "gitea", "3001"],
"severity": "critical",
"labels": {"layer": "docker-110", "component": "gitea"},
},
"repair_steps": [
{
"step_number": 1,
"action_type": "ssh_command",
"command": "gitea",
"description": "SSH 到 110,docker compose up -d Gitea",
"risk_level": "LOW",
"timeout_seconds": 60,
}
],
"tags": ["gitea", "docker-110", "auto-repair"],
"risk_level": "LOW",
},
{
"name": "alertmanager-down-repair",
"description": "Alertmanager (110) 離線自動修復",
"trigger_alert": "AlertmanagerDown",
"symptom_pattern": {
"keywords": ["AlertmanagerDown", "alertmanager", "9093"],
"severity": "critical",
"labels": {"layer": "docker-110", "component": "alertmanager"},
},
"repair_steps": [
{
"step_number": 1,
"action_type": "ssh_command",
"command": "alertmanager",
"description": "SSH 到 110,docker compose up -d monitoring (含 Alertmanager)",
"risk_level": "LOW",
"timeout_seconds": 60,
}
],
"tags": ["alertmanager", "docker-110", "auto-repair", "critical-infra"],
"risk_level": "LOW",
},
{
"name": "openclaw-down-repair",
"description": "OpenClaw (188) 離線自動修復",
"trigger_alert": "OpenClawDown",
"symptom_pattern": {
"keywords": ["OpenClawDown", "openclaw", "8088", "clawbot"],
"severity": "critical",
"labels": {"layer": "docker-188", "component": "openclaw"},
},
"repair_steps": [
{
"step_number": 1,
"action_type": "ssh_command",
"command": "openclaw",
"description": "SSH 到 188,docker compose up -d OpenClaw",
"risk_level": "LOW",
"timeout_seconds": 90,
}
],
"tags": ["openclaw", "docker-188", "auto-repair"],
"risk_level": "LOW",
},
]
def create_playbook(playbook_data: dict) -> bool:
"""透過 API 建立 Playbook"""
data = json.dumps(playbook_data).encode()
req = urllib.request.Request(
f"{API_BASE}/api/v1/playbooks",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
print(f" ✅ Created: {playbook_data['name']} (id: {result.get('playbook_id', '?')})")
return True
except urllib.error.HTTPError as e:
body = e.read().decode()
if "already exists" in body or e.code == 409:
print(f" ⚠️ Already exists: {playbook_data['name']}")
return True
print(f" ❌ Failed: {playbook_data['name']} — HTTP {e.code}: {body[:100]}")
return False
except Exception as e:
print(f" ❌ Error: {playbook_data['name']} — {e}")
return False
if __name__ == "__main__":
print("=== 建立 Host Repair Playbooks ===")
success = 0
for pb in PLAYBOOKS:
if create_playbook(pb):
success += 1
print(f"\n結果: {success}/{len(PLAYBOOKS)} playbooks 建立成功")
- Step 14.2: 執行 seed 腳本
python3 scripts/ops/seed-repair-playbooks.py
預期輸出:所有 Playbook ✅ Created 或 ⚠️ Already exists。
- Step 14.3: 確認 Playbooks 已建立
curl -s http://192.168.0.121:32334/api/v1/playbooks | python3 -c "
import sys, json
pbs = json.load(sys.stdin)
for pb in pbs.get('playbooks', []):
print(pb['name'], pb.get('risk_level', '?'))
"
- Step 14.4: Commit
git add scripts/ops/seed-repair-playbooks.py
git commit -m "ops(scripts): seed-repair-playbooks.py — Sprint 3 Host Repair Playbooks
5 個 Playbooks: SentryDown/HarborDown/GiteaDown/AlertmanagerDown/OpenClawDown
全部 LOW 風險,layer: docker-110/docker-188
ActionType: ssh_command → HostRepairAgent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
Task 15:Sprint 3 E2E 閉環驗證
- Step 15.1: 驗證 SSH 連線 (從 API Pod)
ssh wooo@192.168.0.120 "kubectl exec -n awoooi-prod deploy/awoooi-api -- ssh -i /etc/repair-ssh/id_ed25519 -o StrictHostKeyChecking=accept-new -o BatchMode=yes wooo@192.168.0.110 health"
預期:REPAIR_BOT_HEALTHY:110
- Step 15.2: SentryDown E2E 測試
# 1. 手動停止 Sentry
ssh wooo@192.168.0.110 "cd /opt/sentry && docker compose stop"
# 2. 等待 Prometheus 偵測 (SentryDown for: 2m)
echo "等待 2 分鐘讓 Prometheus 觸發 SentryDown..."
sleep 130
# 3. 確認 Prometheus 告警狀態
ssh wooo@192.168.0.110 "curl -s 'http://localhost:9090/api/v1/alerts' | python3 -c \"
import sys, json
alerts = json.load(sys.stdin)
sentry_alerts = [a for a in alerts['data']['alerts'] if 'SentryDown' in a['labels'].get('alertname','')]
for a in sentry_alerts:
print(a['labels']['alertname'], a['state'])
\""
預期:看到 SentryDown firing
- Step 15.3: 確認 Alertmanager 已送出 webhook
ssh wooo@192.168.0.110 "curl -s http://localhost:9093/api/v2/alerts | python3 -c \"
import sys, json
alerts = json.load(sys.stdin)
for a in alerts:
if 'SentryDown' in str(a.get('labels',{})):
print('Found SentryDown alert in Alertmanager:', a.get('status',{}).get('state','?'))
\""
- Step 15.4: 確認 auto_repair_service 執行了 SSH 修復
# 查看 API pod logs
ssh wooo@192.168.0.120 "kubectl logs -n awoooi-prod deploy/awoooi-api --since=5m | grep -E 'host_repair|ssh_repair|SentryDown|auto_repair'"
預期:看到 host_repair_start、host_repair_complete、success=True
- Step 15.5: 確認 Sentry 已恢復
sleep 30 # 等待 docker compose up -d 完成
curl -s -o /dev/null -w "%{http_code}" http://192.168.0.110:9000/
預期:200、302 或 400(服務已恢復)
- Step 15.6: 確認 Telegram 收到告警和 resolved
人工確認 Telegram 收到:
SentryDown告警卡片- 自動修復成功通知(若 auto_repair 成功後有 resolved webhook)
- Step 15.7: 最終驗收 checklist
# 完整驗收腳本
cat << 'EOF' > /tmp/final-e2e.sh
#!/bin/bash
GREEN='\033[0;32m'; RED='\033[0;31m'; NC='\033[0m'
PASS=0; FAIL=0
check() {
local name="$1"; local cmd="$2"; local expect="$3"
result=$(eval "$cmd" 2>/dev/null)
if echo "$result" | grep -qE "$expect"; then
echo -e "${GREEN}✅ $name${NC}"
((PASS++))
else
echo -e "${RED}❌ $name${NC} (got: ${result:0:60})"
((FAIL++))
fi
}
echo "=== 全系統自愈閉環 E2E 驗收 ==="
# Sprint 1
check "Prometheus rules >30" "ssh wooo@192.168.0.110 'curl -s http://localhost:9090/api/v1/rules | python3 -c \"import sys,json; r=json.load(sys.stdin); print(sum(len(g[chr(39)rules chr(39)]) for g in r[chr(39)data chr(39)][chr(39)groups chr(39)]))\"'" "[3-9][0-9]"
check "SentryDown rule exists" "ssh wooo@192.168.0.110 'curl -s http://localhost:9090/api/v1/rules | python3 -c \"import sys,json; r=json.load(sys.stdin); names=[x[chr(39)name chr(39)] for g in r[chr(39)data chr(39)][chr(39)groups chr(39)] for x in g[chr(39)rules chr(39)]]; print(chr(39)OK chr(39) if chr(39)SentryDown chr(39) in names else chr(39)MISS chr(39))\"'" "OK"
check "110 Sentry running" "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://192.168.0.110:9000/" "200|302|400"
check "Alert chain E2E" "curl -s -X POST http://192.168.0.121:32334/api/v1/webhooks/alertmanager -H 'Content-Type: application/json' -d '{\"receiver\":\"test\",\"status\":\"firing\",\"alerts\":[{\"status\":\"firing\",\"labels\":{\"alertname\":\"FinalE2ETest\",\"severity\":\"info\"},\"annotations\":{\"summary\":\"Final E2E test\"},\"startsAt\":\"2026-04-05T00:00:00Z\",\"endsAt\":\"0001-01-01T00:00:00Z\",\"generatorURL\":\"\"}],\"groupLabels\":{},\"commonLabels\":{},\"commonAnnotations\":{},\"externalURL\":\"\",\"version\":\"4\",\"groupKey\":\"e2e\"}'" '"success":true'
# Sprint 3
check "SSH key mounted in API pod" "ssh wooo@192.168.0.120 'kubectl exec -n awoooi-prod deploy/awoooi-api -- ls /etc/repair-ssh/id_ed25519'" "id_ed25519"
check "SSH health 110" "ssh wooo@192.168.0.120 'kubectl exec -n awoooi-prod deploy/awoooi-api -- ssh -i /etc/repair-ssh/id_ed25519 -o StrictHostKeyChecking=accept-new -o BatchMode=yes wooo@192.168.0.110 health'" "REPAIR_BOT_HEALTHY"
echo ""
echo "=== 結果: ${PASS} 通過, ${FAIL} 失敗 ==="
[ $FAIL -eq 0 ] && echo -e "${GREEN}🎉 全系統自愈閉環驗收通過!${NC}" || echo -e "${RED}⚠️ ${FAIL} 項失敗${NC}"
EOF
bash /tmp/final-e2e.sh
Self-Review
Spec 覆蓋確認
| Spec 章節 | 對應 Task |
|---|---|
| S0 統一標籤規範 | Task 1 (alerts-unified.yml 內建標籤) |
| Sprint 1: Prometheus 規則部署 | Task 1 + Task 2 |
| Sprint 1: CD 自動同步 | Task 3 |
| Sprint 1: Sentry 啟動 | Task 4 |
| SOP v4.0 更新 | Task 5 |
| Sprint 1 驗收 | Task 6 |
| Sprint 2: SigNoz log alert | Task 7 |
| Sprint 2: Sentry SDK tags | Task 8 |
| Sprint 3: 修復腳本白名單 | Task 9 |
| Sprint 3: SSH key 基礎設施 | Task 10 |
| Sprint 3: HostRepairAgent | Task 11 |
| Sprint 3: auto_repair 整合 | Task 12 |
| Sprint 3: Pod 掛載 SSH key | Task 13 |
| Sprint 3: Playbooks | Task 14 |
| Sprint 3: E2E 閉環驗證 | Task 15 |
所有 spec 章節都有對應 Task。✅
型別一致性
HostRepairResult在 Task 11 定義,Task 12 使用result.success/result.error✅ActionType.SSH_COMMAND在 Task 12 定義,Task 14 的 Playbook seed 使用"ssh_command"✅get_host_repair_agent()在 Task 11 定義,Task 12 使用 ✅