diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 7b90d5366..033fabb04 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -285,32 +285,39 @@ class Settings(BaseSettings): # ========================================================================== # External Services - Four Host Architecture # ========================================================================== + # 2026-05-03 ogt: GCP 三層容災(ADR-110),GCP-A → GCP-B → Local → Gemini OLLAMA_URL: str = Field( - default="http://192.168.0.111:11434", # 2026-04-08 ogt: 切換至 M1 Pro (40+ tok/s vs 0.45 tok/s) - description="Ollama LLM service URL", + default="http://34.143.170.20:11434", # 2026-05-03 ogt: 切換至 GCP-A SSD 主機(9x 載速 + 2x 推理) + description="Ollama LLM service URL (GCP-A Primary)", ) - # 2026-04-25 Claude Engineer-C (P1.1): Ollama 188 CPU-only 備援 (方案 C) - # 若空字串則 OllamaFailoverManager 僅使用 OLLAMA_URL(單節點模式) + # 2026-05-03 ogt: GCP-B SSD 備援(ADR-110 三層容災第二層) + OLLAMA_SECONDARY_URL: str = Field( + default="http://34.21.145.224:11434", # 2026-05-03 ogt: GCP-B SSD 備援 + description="Ollama LLM secondary URL (GCP-B Secondary)", + ) + # 2026-05-03 ogt: Local HDD 最後防線(原 2026-04-08 M1 Pro 主機降為第三層) OLLAMA_FALLBACK_URL: str = Field( - default="", - description="Ollama CPU-only fallback URL (188 備援,P1.1),空字串=停用", + default="http://192.168.0.111:11434", # 2026-05-03 ogt: M1 Pro Local HDD 最後防線 + description="Ollama local fallback URL (Local HDD, 最後防線)", ) # 2026-04-27 Wave8-X2 by Claude — vuln #1 URL endpoint poisoning 修復 # 攻擊情境:攻擊者改 ConfigMap OLLAMA_FALLBACK_URL=http://attacker.com:11434 # → ai_router 盲信 → C&C 通道。修法:啟動時拒絕非私網/loopback 的外部 URL。 - @field_validator("OLLAMA_URL", "OLLAMA_FALLBACK_URL") + # 2026-05-03 ogt: 擴充 validator 覆蓋 OLLAMA_SECONDARY_URL;新增 GCP IP 白名單(ADR-110) + @field_validator("OLLAMA_URL", "OLLAMA_SECONDARY_URL", "OLLAMA_FALLBACK_URL") @classmethod def _validate_ollama_url(cls, v: str) -> str: """ Ollama URL 安全校驗:拒絕非 private/loopback IP 或非已知服務名稱的 URL。 允許: - - 空字串(未設定,OLLAMA_FALLBACK_URL 預設空字串) + - 空字串(未設定) - 已知 Kubernetes Service hostname 白名單 - 私網 IP(RFC 1918)或 loopback(127.x.x.x) + - GCP 核准公網 IP 白名單(ADR-110 GCP-A / GCP-B) 拒絕: - - 公網 IP(8.8.8.8) + - 非白名單公網 IP(8.8.8.8) - 外部域名(attacker.com) """ if not v: @@ -337,6 +344,16 @@ class Settings(BaseSettings): if host in _ALLOWED_HOSTNAMES: return v + # GCP 核准公網 IP 白名單(ADR-110,2026-05-03 ogt) + # GCP-A: 34.143.170.20(SSD, 9x 載速) + # GCP-B: 34.21.145.224(SSD, 9x 載速) + _ALLOWED_PUBLIC_IPS: frozenset[str] = frozenset({ + "34.143.170.20", # GCP-A Ollama Primary (SSD) + "34.21.145.224", # GCP-B Ollama Secondary (SSD) + }) + if host in _ALLOWED_PUBLIC_IPS: + return v + # 否則必須是 private/loopback IP try: ip = ipaddress.ip_address(host) @@ -348,7 +365,7 @@ class Settings(BaseSettings): ) if not (ip.is_private or ip.is_loopback): raise ValueError( - f"OLLAMA URL 必須是私網/loopback IP 或已知 K8s SVC," + f"OLLAMA URL 必須是私網/loopback IP、已知 K8s SVC 或 GCP 白名單 IP," f"收到公網 IP {host!r}({v!r}),可能是端點中毒攻擊" ) return v diff --git a/apps/api/src/hermes/nl_gateway.py b/apps/api/src/hermes/nl_gateway.py index b665a75ab..c112cb8cd 100644 --- a/apps/api/src/hermes/nl_gateway.py +++ b/apps/api/src/hermes/nl_gateway.py @@ -259,7 +259,7 @@ async def process_nl_message( success = False error_type: str | None = None try: - ollama_base = getattr(settings, "OLLAMA_URL", "http://192.168.0.111:11434") + ollama_base = getattr(settings, "OLLAMA_URL", "http://34.143.170.20:11434") # 2026-05-03 ogt: ADR-110 GCP-A Primary async with httpx.AsyncClient(timeout=_OLLAMA_TIMEOUT) as _hc: resp = await _hc.post( f"{ollama_base}/api/chat", diff --git a/apps/api/src/routes/agent.py b/apps/api/src/routes/agent.py index 25dcc3f20..6afdd4075 100644 --- a/apps/api/src/routes/agent.py +++ b/apps/api/src/routes/agent.py @@ -19,7 +19,10 @@ router = APIRouter() logger = logging.getLogger(__name__) # ==================== Ollama Config ==================== -OLLAMA_BASE_URL = "http://192.168.0.111:11434" +# 2026-05-03 ogt: ADR-110 GCP-A Primary — 改從 settings 讀取,不再硬編碼 111 +def _get_ollama_base_url() -> str: + from src.core.config import get_settings + return get_settings().OLLAMA_URL OLLAMA_MODEL = "llama3.2:latest" # 可根據實際部署調整 OLLAMA_TIMEOUT = 120.0 # 串流超時 @@ -116,7 +119,7 @@ async def get_agent_thinking( async with client.stream( "POST", - f"{OLLAMA_BASE_URL}/api/generate", + f"{_get_ollama_base_url()}/api/generate", json={ "model": model, "prompt": prompt, @@ -162,7 +165,7 @@ async def get_agent_thinking( except httpx.ConnectError as e: logger.error(f"無法連接 Ollama: {e}") - yield f"data: {json.dumps({'type': 'error', 'content': f'無法連接 Ollama ({OLLAMA_BASE_URL})'}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'type': 'error', 'content': f'無法連接 Ollama ({_get_ollama_base_url()})'}, ensure_ascii=False)}\n\n" except httpx.TimeoutException as e: logger.error(f"Ollama 超時: {e}") yield f"data: {json.dumps({'type': 'error', 'content': '請求超時'}, ensure_ascii=False)}\n\n" diff --git a/apps/api/src/services/chat_manager.py b/apps/api/src/services/chat_manager.py index 22925f444..38584404c 100644 --- a/apps/api/src/services/chat_manager.py +++ b/apps/api/src/services/chat_manager.py @@ -166,8 +166,10 @@ class ChatManager: import httpx import re + from src.core.config import get_settings as _get_settings - OLLAMA_URL = "http://192.168.0.111:11434" + # 2026-05-03 ogt: ADR-110 GCP-A Primary — 改從 settings 讀取,不再硬編碼 111 + OLLAMA_URL = _get_settings().OLLAMA_URL MODEL = "deepseek-r1:14b" try: diff --git a/apps/api/src/services/decision_fusion.py b/apps/api/src/services/decision_fusion.py index e56945276..392a108b1 100644 --- a/apps/api/src/services/decision_fusion.py +++ b/apps/api/src/services/decision_fusion.py @@ -188,7 +188,7 @@ class DecisionFusionEngine: @property def _ollama_url(self) -> str: - return getattr(self._settings, "OLLAMA_URL", "http://192.168.0.111:11434") + return getattr(self._settings, "OLLAMA_URL", "http://34.143.170.20:11434") # 2026-05-03 ogt: ADR-110 GCP-A Primary # ========================================================================= # Public API diff --git a/apps/api/src/services/decision_fusion_adapter.py b/apps/api/src/services/decision_fusion_adapter.py index 1bcbf566a..d8ac69c54 100644 --- a/apps/api/src/services/decision_fusion_adapter.py +++ b/apps/api/src/services/decision_fusion_adapter.py @@ -254,7 +254,7 @@ class DecisionFusionAdapter: "只輸出 CONFIDENCE 和 ACTION 兩行,不要其他解釋。" ) - ollama_url = getattr(self._settings, "OLLAMA_URL", "http://192.168.0.111:11434") + ollama_url = getattr(self._settings, "OLLAMA_URL", "http://34.143.170.20:11434") # 2026-05-03 ogt: ADR-110 GCP-A Primary try: async with httpx.AsyncClient( diff --git a/apps/api/src/services/decision_manager.py b/apps/api/src/services/decision_manager.py index e65b660a7..e1930b71f 100644 --- a/apps/api/src/services/decision_manager.py +++ b/apps/api/src/services/decision_manager.py @@ -616,7 +616,7 @@ async def _nemoclaw_second_opinion(incident: "Incident", primary_result: dict) - import httpx as _httpx from src.services.model_registry import get_model as _get_model - ollama_url = getattr(settings, "OLLAMA_URL", "http://192.168.0.111:11434") + ollama_url = getattr(settings, "OLLAMA_URL", "http://34.143.170.20:11434") # 2026-05-03 ogt: ADR-110 GCP-A Primary # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.nemoclaw 讀取 model = _get_model("ollama", "nemoclaw") @@ -709,7 +709,7 @@ async def _generate_playbook_draft_if_new(incident: "Incident") -> None: ) from src.services.model_registry import get_model as _get_model - ollama_url = getattr(settings, "OLLAMA_URL", "http://192.168.0.111:11434") + ollama_url = getattr(settings, "OLLAMA_URL", "http://34.143.170.20:11434") # 2026-05-03 ogt: ADR-110 GCP-A Primary # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.playbook_draft 讀取 _pb_model = _get_model("ollama", "playbook_draft") async with _httpx.AsyncClient(timeout=45.0) as client: diff --git a/apps/api/src/services/drift_narrator_service.py b/apps/api/src/services/drift_narrator_service.py index 0305bfc3d..f33d928da 100644 --- a/apps/api/src/services/drift_narrator_service.py +++ b/apps/api/src/services/drift_narrator_service.py @@ -33,7 +33,10 @@ logger = structlog.get_logger(__name__) # ============================================================ # 設定 # ============================================================ -OLLAMA_URL = "http://192.168.0.111:11434" +# 2026-05-03 ogt: ADR-110 GCP-A Primary — 改從 settings 讀取,不再硬編碼 111 +def _get_ollama_url() -> str: + from src.core.config import get_settings + return get_settings().OLLAMA_URL # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.drift_summary 讀取 NARRATOR_MODEL = get_model("ollama", "drift_summary") NARRATOR_TIMEOUT = 90.0 # seconds diff --git a/apps/api/src/services/failover_alerter.py b/apps/api/src/services/failover_alerter.py index 8868dfc45..e758df18b 100644 --- a/apps/api/src/services/failover_alerter.py +++ b/apps/api/src/services/failover_alerter.py @@ -40,7 +40,9 @@ class FailoverAlerter: self._memory_dedup_max_size = 1000 async def alert_failover(self, event: dict[str, Any]) -> None: - """111 故障切換到 Gemini/188 — 10min dedup""" + """Ollama 故障切換告警 — 10min dedup + # 2026-05-03 ogt: ADR-110 三層容災,故障主機從 event["failed_host"] 動態讀取 + """ to_provider = event.get("to_provider", "unknown") dedup_key = f"alert:failover:{to_provider}" if not await self._check_dedup(dedup_key, ttl=DEDUP_TTL_SEC): @@ -51,10 +53,12 @@ class FailoverAlerter: model = event.get("model", "?") timestamp = event.get("timestamp", datetime.now(TAIPEI_TZ).isoformat()) fallback_chain_str = event.get("fallback_chain_str", "?") + # 2026-05-03 ogt: ADR-110 — 故障主機動態,不再硬編碼 111 + failed_host = event.get("failed_host", "Ollama") msg = ( f"*Ollama 容災激活*\n\n" - f"故障主機:Ollama 111 \\(GPU\\)\n" + f"故障主機:{_escape_md(failed_host)}\n" f"故障狀態:{_escape_md(reason)}\n" f"切換目標:{_escape_md(to_provider)} \\(model: {_escape_md(model)}\\)\n" f"切換時間:{_escape_md(timestamp)}\n\n" @@ -65,9 +69,8 @@ class FailoverAlerter: logger.info("failover_alert_sent", to_provider=to_provider) async def alert_recovery(self, event: dict[str, Any]) -> None: - """111 恢復後自動切回 — 10min dedup - - 2026-04-25 P1.5 by Claude Engineer-D — 用戶明確要求要通知 + """Ollama 自動恢復告警 — 10min dedup + # 2026-05-03 ogt: ADR-110 三層容災,恢復主機從 event["recovered_host"] 動態讀取 """ dedup_key = "alert:recovery" if not await self._check_dedup(dedup_key, ttl=DEDUP_TTL_SEC): @@ -77,16 +80,18 @@ class FailoverAlerter: stable_count = event.get("stable_count", event.get("to", "?")) # 相容 auto_recovery 傳入的 {"from": ..., "to": ...} 格式 from_provider = event.get("from_provider", event.get("from", "?")) - to_provider = event.get("to_provider", event.get("to", "ollama_111")) + to_provider = event.get("to_provider", event.get("to", "ollama")) recovery_time = event.get("recovery_time", datetime.now(TAIPEI_TZ).isoformat()) + # 2026-05-03 ogt: ADR-110 — 恢復主機動態,不再硬編碼 111 + recovered_host = event.get("recovered_host", to_provider) msg = ( f"*Ollama 自動恢復*\n\n" - f"恢復主機:Ollama 111 \\(GPU\\)\n" + f"恢復主機:{_escape_md(str(recovered_host))}\n" f"穩定計數:連續 {stable_count} 次 HEALTHY\n" f"切回時間:{_escape_md(str(recovery_time))}\n" f"切換路徑:{_escape_md(str(from_provider))} → {_escape_md(str(to_provider))}\n\n" - f"自動化飛輪已恢復至 GPU 推理模式" + f"自動化飛輪已恢復至高效能推理模式" ) await self._send(msg) logger.info("recovery_alert_sent", from_provider=from_provider) diff --git a/apps/api/src/services/knowledge_extractor_service.py b/apps/api/src/services/knowledge_extractor_service.py index 9acec547f..bbbb88742 100644 --- a/apps/api/src/services/knowledge_extractor_service.py +++ b/apps/api/src/services/knowledge_extractor_service.py @@ -15,7 +15,10 @@ import structlog logger = structlog.get_logger(__name__) -_OLLAMA_BASE = "http://192.168.0.111:11434" +# 2026-05-03 ogt: ADR-110 GCP-A Primary — 改從 settings 讀取,不再硬編碼 111 +def _get_ollama_base() -> str: + from src.core.config import get_settings + return get_settings().OLLAMA_URL _EXTRACT_MODEL = "llama3.2:3b" _EXTRACT_TIMEOUT = 30.0 # 秒,容忍慢速 @@ -159,7 +162,7 @@ class KnowledgeExtractorService: try: async with httpx.AsyncClient(timeout=_EXTRACT_TIMEOUT) as client: r = await client.post( - f"{_OLLAMA_BASE}/api/generate", + f"{_get_ollama_base()}/api/generate", json={ "model": _EXTRACT_MODEL, "prompt": prompt, @@ -179,7 +182,7 @@ class KnowledgeExtractorService: logger.exception( "kb_ollama_call_failed", model=_EXTRACT_MODEL, - base=_OLLAMA_BASE, + base=_get_ollama_base(), ) return None diff --git a/apps/api/src/services/log_summary_service.py b/apps/api/src/services/log_summary_service.py index f2adc9aed..6d92fe7b5 100644 --- a/apps/api/src/services/log_summary_service.py +++ b/apps/api/src/services/log_summary_service.py @@ -33,7 +33,10 @@ logger = structlog.get_logger(__name__) # ============================================================ # 設定 # ============================================================ -OLLAMA_URL = "http://192.168.0.111:11434" +# 2026-05-03 ogt: ADR-110 GCP-A Primary — 改從 settings 讀取,不再硬編碼 111 +def _get_ollama_url() -> str: + from src.core.config import get_settings + return get_settings().OLLAMA_URL # D1 集中化 2026-04-11: 從 models.json providers.ollama.models.log_anomaly 讀取 SUMMARY_MODEL = get_model("ollama", "log_anomaly") LLM_TIMEOUT = 180.0 # deepseek-r1 硬超時 @@ -208,7 +211,7 @@ class LogSummaryService: try: async with httpx.AsyncClient(timeout=LLM_TIMEOUT) as client: resp = await client.post( - f"{OLLAMA_URL}/api/generate", + f"{_get_ollama_url()}/api/generate", json={ "model": SUMMARY_MODEL, "prompt": prompt, diff --git a/apps/api/src/services/model_version_probe.py b/apps/api/src/services/model_version_probe.py index 46dff3689..3c06e1bbc 100644 --- a/apps/api/src/services/model_version_probe.py +++ b/apps/api/src/services/model_version_probe.py @@ -4,7 +4,7 @@ AI Provider 版本探測 — 為每個 Provider 提供 get_version() 每個 probe 函數獨立運作,失敗只影響該 provider,不 crash 整批。 Provider: - - ollama : 192.168.0.111 Ollama (primary) + - ollama : 34.143.170.20 GCP-A Ollama (primary) — 2026-05-03 ogt: ADR-110 GCP-A Primary - ollama_188 : 192.168.0.188 Ollama (fallback) - gemini : Google Gemini API (版本 = model name) - claude : Anthropic Claude (版本 = model name) @@ -43,14 +43,14 @@ class ProviderVersionInfo: # ============================================================================= async def probe_ollama_version(url: str, model: str) -> ProviderVersionInfo: - """探測 Ollama(111 或 188):GET /api/tags 取 model digest + modified_at + """探測 Ollama(GCP-A 或 188):GET /api/tags 取 model digest + modified_at Args: - url: Ollama base URL,例如 "http://192.168.0.111:11434" + url: Ollama base URL,例如 "http://34.143.170.20:11434"(GCP-A Primary) model: model name,例如 "qwen2.5:7b-instruct" Returns: - ProviderVersionInfo — provider 依 URL 自動判斷(111=ollama, 否則=ollama_188) + ProviderVersionInfo — provider 依 URL 自動判斷(GCP-A=ollama, 本地111=ollama_local, 否則=ollama_remote) Raises: ValueError: model 不在清單 @@ -58,7 +58,15 @@ async def probe_ollama_version(url: str, model: str) -> ProviderVersionInfo: """ import httpx - provider_name = "ollama" if "192.168.0.111" in url else "ollama_188" + # 2026-05-03 ogt: ADR-110 GCP-A Primary — 擴展 provider 判斷邏輯支援 GCP 三層容災 + _GCP_OLLAMA_IPS = {"34.143.170.20", "34.21.145.224"} + _LOCAL_OLLAMA_IPS = {"192.168.0.111"} + if any(ip in url for ip in _GCP_OLLAMA_IPS): + provider_name = "ollama" + elif any(ip in url for ip in _LOCAL_OLLAMA_IPS): + provider_name = "ollama_local" + else: + provider_name = "ollama_remote" async with httpx.AsyncClient(timeout=5.0) as client: resp = await client.get(f"{url}/api/tags") diff --git a/apps/api/src/services/ollama_auto_recovery.py b/apps/api/src/services/ollama_auto_recovery.py index fc6c4da8b..4b7865aee 100644 --- a/apps/api/src/services/ollama_auto_recovery.py +++ b/apps/api/src/services/ollama_auto_recovery.py @@ -376,7 +376,8 @@ class OllamaAutoRecoveryService: # 通知 failover_manager try: - self._failover_manager.notify_recovery("ollama_111") + # 2026-05-03 ogt: ADR-110 — provider name 改為 "ollama_gcp_a"(GCP-A Primary) + self._failover_manager.notify_recovery("ollama_gcp_a") except Exception: logger.exception( "ollama_auto_recovery_notify_error", @@ -391,9 +392,12 @@ class OllamaAutoRecoveryService: if alerter is None: from src.services.failover_alerter import get_failover_alerter alerter = get_failover_alerter() + # 2026-05-03 ogt: ADR-110 — recovered_host 動態,顯示恢復的實際主機 URL + _recovered_url = getattr(self._settings, "OLLAMA_URL", "Ollama Primary") await alerter.alert_recovery({ "from": from_provider, - "to": "ollama_111", + "to": "ollama", + "recovered_host": f"GCP-A {_recovered_url}", "stable_count": stable_count, "recovery_time": recovery_time.isoformat(), }) @@ -420,7 +424,7 @@ class OllamaAutoRecoveryService: service="ollama_auto_recovery", action="switched_back", from_provider=from_provider, - to_provider="ollama_111", + to_provider="ollama_gcp_a", # 2026-05-03 ogt: ADR-110 stable_count=stable_count, recovery_time=recovery_time.isoformat(), ) diff --git a/apps/api/src/services/ollama_failover_manager.py b/apps/api/src/services/ollama_failover_manager.py index 6da11393b..f09a85571 100644 --- a/apps/api/src/services/ollama_failover_manager.py +++ b/apps/api/src/services/ollama_failover_manager.py @@ -3,25 +3,30 @@ Ollama 自動容災管理 - P1.1b ============================ 依 OllamaHealthMonitor 健康狀態決定 Ollama 路由方案。 -路由邏輯(2026-04-26 統帥鐵律:111 = 唯一 Ollama,備援只用 Gemini): - 111 HEALTHY → 主 111,fallback [Gemini] - 111 SLOW/DEGRADED/OFFLINE → 主 Gemini,fallback [Nemotron, Claude] - Gemini quota 超過 → 主 Nemotron,fallback [Claude] +路由邏輯(2026-05-03 統帥新令:GCP 三層容災,ADR-110): + GCP-A HEALTHY → primary=GCP-A, fallback=[GCP-B, Local] + GCP-A 不健康 + GCP-B HEALTHY → primary=GCP-B, fallback=[Local] + GCP-A + GCP-B 都不健康 + Local HEALTHY → primary=Local, fallback=[Gemini] + 全部 Ollama 不健康 → primary=Gemini, fallback=[Nemotron, Claude] + Gemini quota 超過 → primary=Nemotron, fallback=[Claude] 設計說明: -- 188 CPU-only 禁止用於即時回應(0.45 tok/s),完全移出 routing chain -- 唯一 Ollama 主機:192.168.0.111(M1 Pro, Metal 加速) +- GCP-A 主機:34.143.170.20(SSD,9x 載速 + 2x 推理) +- GCP-B 備援:34.21.145.224(SSD,9x 載速 + 2x 推理) +- Local 最後防線:192.168.0.111(M1 Pro, Metal 加速,HDD) - 不直接依賴 AIProviderEnum(P1.2 Engineer-A 整合時再對齊) - 返回輕量 OllamaRoutingResult,含主 endpoint + fallback 清單 -- 只檢查 111(不再並行檢查 188) +- 並行檢查三台 Ollama 主機健康狀態 - 切換觸發時寫 audit_logs service="ollama_failover" - clear_cache() 方法供 OllamaAutoRecoveryService 切回後清空路由快取 -版本: v2.0 +版本: v3.0 建立: 2026-04-25 (台北時區) 建立者: Claude Engineer-C (P1.1b) +更新: 2026-05-03 ogt — GCP 三層容災(ADR-110),GCP-A → GCP-B → Local → Gemini # Created 2026-04-25 P1.1 by Claude Engineer-C # 2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復 +# 2026-05-03 ogt: GCP 三層容災(ADR-110),GCP-A → GCP-B → Local → Gemini """ from __future__ import annotations @@ -71,10 +76,10 @@ class OllamaEndpoint: @dataclass class OllamaRoutingResult: """ - Ollama 容災路由結果 + Ollama 容災路由結果(2026-05-03 ogt: 更新為三層 GCP 容災,ADR-110) P1.2 Engineer-A 整合時,將此結果轉換為 ai_router.RoutingDecision: - - selected_provider = AIProviderEnum[result.primary.provider_name.upper()] or 新的 OLLAMA_188 + - selected_provider = AIProviderEnum[result.primary.provider_name.upper()] - selected_model = result.primary.model - fallback_chain = [(AIProviderEnum[p.provider_name.upper()], p.model) for p in result.fallback_chain] """ @@ -82,8 +87,14 @@ class OllamaRoutingResult: primary: OllamaEndpoint fallback_chain: list[OllamaEndpoint] routing_reason: str - health_111: HealthReport - health_188: HealthReport | None = None # optional backward-compat(188 不在路由鏈時為 None) + health_gcp_a: HealthReport # GCP-A 健康狀態(原 health_111) + health_gcp_b: HealthReport | None = None # GCP-B 健康狀態 + health_local: HealthReport | None = None # Local(111) 健康狀態 + + @property + def health_111(self) -> HealthReport: + """向後相容屬性(舊測試 / log 使用)""" + return self.health_gcp_a def all_endpoints_in_order(self) -> list[OllamaEndpoint]: """返回完整的優先序端點列表(primary 在前)""" @@ -101,8 +112,9 @@ class OllamaRoutingResult: for e in self.fallback_chain ], "routing_reason": self.routing_reason, - "health_111": self.health_111.to_dict(), - "health_188": self.health_188.to_dict() if self.health_188 else None, + "health_gcp_a": self.health_gcp_a.to_dict(), + "health_gcp_b": self.health_gcp_b.to_dict() if self.health_gcp_b else None, + "health_local": self.health_local.to_dict() if self.health_local else None, } @@ -171,10 +183,8 @@ class OllamaFailoverManager: context: dict | None = None, ) -> OllamaRoutingResult: """ - 檢查 111 健康狀態,返回路由結果。 - - 2026-04-26 統帥鐵律:唯一 Ollama = 111,188 禁止用於即時回應。 - 只檢查 111,不再並行檢查 188。 + 三層 Ollama 容災路由(2026-05-03 統帥新令,ADR-110): + GCP-A → GCP-B → Local(111) → Gemini → Nemotron → Claude Args: task_type: 任務類型(預留,目前未影響路由邏輯) @@ -183,17 +193,35 @@ class OllamaFailoverManager: Returns: OllamaRoutingResult """ - url_111 = self._settings.OLLAMA_URL + # 2026-05-03 ogt: GCP 三層容災(ADR-110),GCP-A → GCP-B → Local → Gemini + url_gcp_a = self._settings.OLLAMA_URL # 34.143.170.20 + url_gcp_b = self._settings.OLLAMA_SECONDARY_URL # 34.21.145.224 + url_local = self._settings.OLLAMA_FALLBACK_URL # 192.168.0.111 - # 只檢查 111(188 移出 routing chain) - try: - health_111 = await self._monitor.check(url_111) - except Exception as e: - health_111 = HealthReport(status=HealthStatus.OFFLINE, reason=f"check error: {e}") + # 並行檢查三台 Ollama 主機(asyncio.gather 提升效率) + results_raw = await asyncio.gather( + self._monitor.check(url_gcp_a), + self._monitor.check(url_gcp_b), + self._monitor.check(url_local), + return_exceptions=True, + ) + + def _to_health(r, label: str) -> HealthReport: + if isinstance(r, Exception): + return HealthReport(status=HealthStatus.OFFLINE, reason=f"{label} check error: {r}") + return r + + health_gcp_a = _to_health(results_raw[0], "GCP-A") + health_gcp_b = _to_health(results_raw[1], "GCP-B") + health_local = _to_health(results_raw[2], "Local") result = self._decide_route( - health_111=health_111, - url_111=url_111, + health_gcp_a=health_gcp_a, + health_gcp_b=health_gcp_b, + health_local=health_local, + url_gcp_a=url_gcp_a, + url_gcp_b=url_gcp_b, + url_local=url_local, ) # Gemini 帳單熔斷(quota gate) @@ -205,10 +233,9 @@ class OllamaFailoverManager: logger.warning( "gemini_quota_exceeded_fallback_to_nemotron", quota=quota, - health_111=health_111.status.value, + health_gcp_a=health_gcp_a.status.value, ) - # 2026-04-26 統帥鐵律:188 移出,quota 超過 → Nemotron → Claude - result = self._build_quota_exceeded_route(health_111=health_111) + result = self._build_quota_exceeded_route(health_gcp_a=health_gcp_a) # Quota 耗盡 Telegram 告警(24h dedup) try: from src.services.failover_alerter import get_failover_alerter @@ -241,7 +268,9 @@ class OllamaFailoverManager: primary_url=result.primary.url, reason=result.routing_reason, fallback_count=len(result.fallback_chain), - health_111=health_111.status.value, + health_gcp_a=health_gcp_a.status.value, + health_gcp_b=health_gcp_b.status.value, + health_local=health_local.status.value, ) # 通知 recovery service 當前 primary(跨重啟持久化) @@ -263,48 +292,78 @@ class OllamaFailoverManager: def _decide_route( self, - health_111: HealthReport, - url_111: str, + health_gcp_a: HealthReport, + health_gcp_b: HealthReport, + health_local: HealthReport, + url_gcp_a: str, + url_gcp_b: str, + url_local: str, ) -> OllamaRoutingResult: """ - 決策矩陣(2026-04-26 統帥鐵律:唯一 Ollama=111,備援只用 Gemini): + 三層 Ollama 決策矩陣(2026-05-03 ogt,ADR-110): - 111 HEALTHY → primary=111, fallback=[Gemini] - 111 SLOW → primary=Gemini, fallback=[111, Nemotron, Claude] - 111 DEGRADED → primary=Gemini, fallback=[Nemotron, Claude] - 111 OFFLINE → primary=Gemini, fallback=[Nemotron, Claude] + GCP-A HEALTHY → primary=GCP-A, fallback=[GCP-B, Local] + GCP-A 不健康 + GCP-B HEALTHY → primary=GCP-B, fallback=[Local] + GCP-A + GCP-B 不健康 + Local HEALTHY → primary=Local, fallback=[Gemini] + 全部 Ollama 不健康 → primary=Gemini, fallback=[Nemotron, Claude] - 188 完全移出 routing chain(CPU-only 0.45 tok/s,禁止即時回應)。 Gemini quota 超過由 _build_quota_exceeded_route() 接管。 """ - model_111 = self._settings.OLLAMA_HEALTH_CHECK_MODEL - ep_111 = OllamaEndpoint(url=url_111, provider_name="ollama", model=model_111) + model = self._settings.OLLAMA_HEALTH_CHECK_MODEL + ep_gcp_a = OllamaEndpoint(url=url_gcp_a, provider_name="ollama_gcp_a", model=model) + ep_gcp_b = OllamaEndpoint(url=url_gcp_b, provider_name="ollama_gcp_b", model=model) + ep_local = OllamaEndpoint(url=url_local, provider_name="ollama_local", model=model) now_ts = datetime.datetime.now(TAIPEI_TZ).isoformat() - if health_111.status == HealthStatus.HEALTHY: + # GCP-A 健康 → 主 GCP-A + if health_gcp_a.status == HealthStatus.HEALTHY: return OllamaRoutingResult( - primary=ep_111, + primary=ep_gcp_a, + fallback_chain=[ep_gcp_b, ep_local], + routing_reason="GCP-A HEALTHY → primary GCP-A", + health_gcp_a=health_gcp_a, + health_gcp_b=health_gcp_b, + health_local=health_local, + ) + + # GCP-A 不健康,GCP-B 健康 → 切 GCP-B + if health_gcp_b.status == HealthStatus.HEALTHY: + return OllamaRoutingResult( + primary=ep_gcp_b, + fallback_chain=[ep_local], + routing_reason=f"GCP-A {health_gcp_a.status.value} → 切 GCP-B at {now_ts}", + health_gcp_a=health_gcp_a, + health_gcp_b=health_gcp_b, + health_local=health_local, + ) + + # GCP-A + GCP-B 都不健康,Local 健康 → 切 Local(111) + if health_local.status == HealthStatus.HEALTHY: + return OllamaRoutingResult( + primary=ep_local, fallback_chain=[_GEMINI_ENDPOINT], - routing_reason="111 HEALTHY → 主 111", - health_111=health_111, + routing_reason=( + f"GCP-A {health_gcp_a.status.value} + GCP-B {health_gcp_b.status.value}" + f" → 切 Local(111) at {now_ts}" + ), + health_gcp_a=health_gcp_a, + health_gcp_b=health_gcp_b, + health_local=health_local, ) - if health_111.status == HealthStatus.SLOW: - return OllamaRoutingResult( - primary=_GEMINI_ENDPOINT, - fallback_chain=[ep_111, _NEMOTRON_ENDPOINT, _CLAUDE_ENDPOINT], - routing_reason=f"111 SLOW → 切 Gemini at {now_ts}", - health_111=health_111, - ) - - # DEGRADED / OFFLINE - status_label = health_111.status.value + # 全部 Ollama 不健康 → Gemini return OllamaRoutingResult( primary=_GEMINI_ENDPOINT, fallback_chain=[_NEMOTRON_ENDPOINT, _CLAUDE_ENDPOINT], - routing_reason=f"111 {status_label} → 切 Gemini at {now_ts}", - health_111=health_111, + routing_reason=( + f"所有 Ollama 不健康(GCP-A {health_gcp_a.status.value}," + f"GCP-B {health_gcp_b.status.value}," + f"Local {health_local.status.value})→ 切 Gemini at {now_ts}" + ), + health_gcp_a=health_gcp_a, + health_gcp_b=health_gcp_b, + health_local=health_local, ) # ------------------------------------------------------------------------- @@ -382,17 +441,17 @@ class OllamaFailoverManager: def _build_quota_exceeded_route( self, - health_111: HealthReport, + health_gcp_a: HealthReport, ) -> OllamaRoutingResult: """ Gemini 配額耗盡時的備援路由:primary=Nemotron, fallback=[Claude] - 2026-04-26 統帥鐵律:188 移出,quota 超過直接走 Nemotron → Claude。 + 2026-05-03 ogt: 更新參數名 health_111 → health_gcp_a(ADR-110) """ return OllamaRoutingResult( primary=_NEMOTRON_ENDPOINT, fallback_chain=[_CLAUDE_ENDPOINT], routing_reason="Gemini quota exceeded → Nemotron 備援", - health_111=health_111, + health_gcp_a=health_gcp_a, ) # ------------------------------------------------------------------------- @@ -423,8 +482,10 @@ class OllamaFailoverManager: if redis is None: return # 動態由 settings URL 組 cache key,避免硬編碼 IP + # 2026-05-03 ogt: 新增 OLLAMA_SECONDARY_URL(ADR-110 GCP-B) keys = [ make_cache_key(self._settings.OLLAMA_URL), + make_cache_key(self._settings.OLLAMA_SECONDARY_URL or ""), make_cache_key(self._settings.OLLAMA_FALLBACK_URL or ""), ] for k in keys: @@ -470,8 +531,8 @@ class OllamaFailoverManager: service="ollama_failover"(per 任務規格) 僅在 primary 非 111 時記錄(真正發生切換) """ - if result.primary.provider_name == "ollama": - # 111 正常,無切換事件 + # 2026-05-03 ogt: GCP 三層容災下,三個 ollama_* provider 都是正常狀態,無需告警 + if result.primary.provider_name in ("ollama", "ollama_gcp_a", "ollama_gcp_b", "ollama_local"): return # 2026-04-26 P2.3 by Claude Sonnet 4.6 (tool-expert) — 記錄 failover Prometheus metric @@ -496,17 +557,29 @@ class OllamaFailoverManager: to_provider=result.primary.provider_name, reason=result.routing_reason, primary_url=result.primary.url or result.primary.provider_name, - health_111=result.health_111.status.value, - health_188=result.health_188.status.value if result.health_188 else "not_configured", + health_gcp_a=result.health_gcp_a.status.value, + health_gcp_b=result.health_gcp_b.status.value if result.health_gcp_b else "not_configured", + health_local=result.health_local.status.value if result.health_local else "not_configured", ) # Telegram 告警(首次切換才通知,dedup 10min 內建) # 2026-04-25 P1.5 by Claude Engineer-D — 告警失敗不阻斷主路由邏輯 + # 2026-05-03 ogt: ADR-110 — failed_host 動態計算,顯示哪台 GCP/Local 故障 try: from src.services.failover_alerter import get_failover_alerter + from src.services.ollama_health_monitor import HealthStatus fallback_chain_str = " → ".join( p.provider_name for p in result.fallback_chain ) + # 計算故障主機描述(哪層 Ollama 不健康) + _failed = [] + if result.health_gcp_a.status != HealthStatus.HEALTHY: + _failed.append(f"GCP-A {self._settings.OLLAMA_URL}") + if result.health_gcp_b and result.health_gcp_b.status != HealthStatus.HEALTHY: + _failed.append(f"GCP-B {self._settings.OLLAMA_SECONDARY_URL}") + if result.health_local and result.health_local.status != HealthStatus.HEALTHY: + _failed.append(f"Local {self._settings.OLLAMA_FALLBACK_URL}") + failed_host = " + ".join(_failed) if _failed else "Ollama" alerter = get_failover_alerter() await alerter.alert_failover({ "to_provider": result.primary.provider_name, @@ -514,6 +587,7 @@ class OllamaFailoverManager: "reason": result.routing_reason, "timestamp": datetime.datetime.now(TAIPEI_TZ).isoformat(), "fallback_chain_str": fallback_chain_str, + "failed_host": failed_host, }) except Exception as e: logger.warning("failover_alert_failed", error=str(e)) diff --git a/apps/api/src/services/ollama_health_monitor.py b/apps/api/src/services/ollama_health_monitor.py index d6cb70877..ce52ae82b 100644 --- a/apps/api/src/services/ollama_health_monitor.py +++ b/apps/api/src/services/ollama_health_monitor.py @@ -177,8 +177,15 @@ class OllamaHealthMonitor: from src.core.metrics import OLLAMA_HEALTH_STATUS from urllib.parse import urlparse as _urlparse _netloc = _urlparse(host).hostname or host - # 192.168.0.111 → "111",192.168.0.188 → "188" - _host_label = _netloc.split(".")[-1] if "." in _netloc else _netloc + # 2026-05-03 ogt: GCP 三層容災(ADR-110)— 通用 host label 萃取 + # 私網 IP(192.168.x.y)→ 取最後一段("111"/"188")維持短標識 + # GCP 公網 IP(34.x.y.z)→ 取前 8 字元避免 label 過長("34.143.1") + if _netloc.startswith("192.168."): + _host_label = _netloc.split(".")[-1] + elif "." in _netloc: + _host_label = _netloc.replace(".", "_")[:12] # e.g. "34_143_170_2" + else: + _host_label = _netloc _is_healthy = 1 if report.status == HealthStatus.HEALTHY else 0 OLLAMA_HEALTH_STATUS.labels(host=_host_label).set(_is_healthy) except Exception as _metric_err: diff --git a/apps/api/tests/test_ai_router_failover_integration.py b/apps/api/tests/test_ai_router_failover_integration.py index 5cef7c8e3..be1e99480 100644 --- a/apps/api/tests/test_ai_router_failover_integration.py +++ b/apps/api/tests/test_ai_router_failover_integration.py @@ -3,6 +3,7 @@ # 2026-04-26 Wave4 P1.2-tests-fix by Claude Engineer-A3 — 修正 intent mock:ALERT_TRIAGE→DIAGNOSE(normalize_intent 映射),改用 UNKNOWN(無 override,score=1 → OLLAMA → failover 觸發) # 2026-04-26 Wave4 P1.2-tests-fix-v2 by Claude Opus 4.7 — UNKNOWN intent 在 router 內仍被 reclassify 成 DIAGNOSE → openclaw_nemo # 改用 patch.object(router, "_select_provider_and_model") 直接強制初始路由為 OLLAMA,繞過 normalize / alert detection 邏輯 +# 2026-05-03 ogt: ADR-110 GCP 三層容災,_make_health host 更新為 GCP-A Primary """ AIRouter × OllamaFailoverManager 整合測試 ========================================== @@ -41,7 +42,7 @@ def reset_router_singleton(): def _make_health(status: HealthStatus) -> HealthReport: - return HealthReport(status=status, host="http://192.168.0.111:11434", latency_ms=500.0) + return HealthReport(status=status, host="http://34.143.170.20:11434", latency_ms=500.0) # GCP-A Primary(ADR-110) def _make_failover_result( @@ -58,7 +59,7 @@ def _make_failover_result( primary=OllamaEndpoint(url="", provider_name=primary_provider, model=primary_model), fallback_chain=fb_endpoints, routing_reason=f"test: {primary_provider}", - health_111=_make_health(HealthStatus.OFFLINE), + health_gcp_a=_make_health(HealthStatus.OFFLINE), # ADR-110:欄位改為 health_gcp_a ) diff --git a/apps/api/tests/test_config_url_validation.py b/apps/api/tests/test_config_url_validation.py index 463b91666..bf4168596 100644 --- a/apps/api/tests/test_config_url_validation.py +++ b/apps/api/tests/test_config_url_validation.py @@ -1,10 +1,11 @@ """ -OLLAMA URL endpoint poisoning 防護測試 — Wave8-X2 +OLLAMA URL endpoint poisoning 防護測試 — Wave8-X2 + ADR-110 vuln #1:OLLAMA_URL / OLLAMA_FALLBACK_URL 缺 IP allowlist 校驗 修法:pydantic field_validator 拒絕非 private/loopback/known-hostname 2026-04-27 Wave8-X2 by Claude — vuln #1 + B14 + alerter memory dedup +2026-05-03 ogt: ADR-110 GCP 三層容災,更新 fixture 預設值為 GCP-A,新增 GCP 白名單測試 """ from __future__ import annotations @@ -24,7 +25,7 @@ def _make_settings(**kwargs): base = { "DATABASE_URL": "postgresql://u:p@localhost:5432/test", - "OLLAMA_URL": "http://192.168.0.111:11434", + "OLLAMA_URL": "http://34.143.170.20:11434", # GCP-A Primary(ADR-110 2026-05-03) "OLLAMA_FALLBACK_URL": "", } base.update(kwargs) @@ -66,12 +67,44 @@ def test_external_domain_fallback_rejected(): # ============================================================================= -# #3: 私網 IP 應通過 +# #3: GCP 白名單公網 IP 應通過(ADR-110 2026-05-03) +# ============================================================================= + + +def test_gcp_a_public_ip_accepted(): + """34.143.170.20 是 GCP-A 核准公網 IP(ADR-110 白名單),應通過""" + s = _make_settings(OLLAMA_URL="http://34.143.170.20:11434") + assert s.OLLAMA_URL == "http://34.143.170.20:11434" + + +def test_gcp_b_public_ip_accepted(): + """34.21.145.224 是 GCP-B 核准公網 IP(ADR-110 白名單),應通過""" + s = _make_settings(OLLAMA_URL="http://34.21.145.224:11434") + assert s.OLLAMA_URL == "http://34.21.145.224:11434" + + +def test_gcp_b_as_secondary_url_accepted(): + """GCP-B 作為 OLLAMA_SECONDARY_URL 也應通過白名單""" + s = _make_settings( + OLLAMA_URL="http://34.143.170.20:11434", + OLLAMA_SECONDARY_URL="http://34.21.145.224:11434", + ) + assert s.OLLAMA_SECONDARY_URL == "http://34.21.145.224:11434" + + +def test_arbitrary_public_ip_rejected(): + """8.8.8.8 非 GCP 白名單公網 IP,仍應被拒絕(端點中毒攻擊防護)""" + with pytest.raises(ValidationError, match="公網"): + _make_settings(OLLAMA_URL="http://8.8.8.8:11434") + + +# ============================================================================= +# #4: 私網 IP 應通過 # ============================================================================= def test_private_ip_192_168_accepted(): - """192.168.0.111 是 RFC1918 私網 IP,應通過""" + """192.168.0.111 是 RFC1918 私網 IP(Local HDD 後備主機),應通過""" s = _make_settings(OLLAMA_URL="http://192.168.0.111:11434") assert s.OLLAMA_URL == "http://192.168.0.111:11434" @@ -89,7 +122,7 @@ def test_private_ip_172_16_accepted(): # ============================================================================= -# #4: localhost / loopback 應通過 +# #5: localhost / loopback 應通過 # ============================================================================= @@ -106,7 +139,7 @@ def test_loopback_ip_accepted(): # ============================================================================= -# #5: 已知 K8s Service hostname 應通過 +# #6: 已知 K8s Service hostname 應通過 # ============================================================================= @@ -123,7 +156,7 @@ def test_known_k8s_svc_ollama_fallback_svc_accepted(): # ============================================================================= -# #6: 空字串應通過(OLLAMA_FALLBACK_URL 預設值) +# #7: 空字串應通過(OLLAMA_FALLBACK_URL 預設值) # ============================================================================= diff --git a/apps/api/tests/test_model_version_probe.py b/apps/api/tests/test_model_version_probe.py index cbf0e60a3..1c9168089 100644 --- a/apps/api/tests/test_model_version_probe.py +++ b/apps/api/tests/test_model_version_probe.py @@ -1,5 +1,6 @@ # apps/api/tests/test_model_version_probe.py # 2026-04-27 P3.2.1 by Claude +# 2026-05-03 ogt: ADR-110 GCP 三層容災,更新 probe URL 為 GCP-A Primary """ model_version_probe 單元測試 ============================== @@ -60,8 +61,8 @@ def _tags_body(models: list[dict]) -> dict: class TestProbeOllamaVersion: @pytest.mark.asyncio - async def test_success_111_provider(self): - """111 URL → provider='ollama', digest 和 version 正確解析""" + async def test_success_gcp_a_provider(self): + """GCP-A URL → provider='ollama', digest 和 version 正確解析(ADR-110)""" model_entry = { "name": "qwen2.5:7b-instruct", "modified_at": "2026-04-01T00:00:00Z", @@ -79,7 +80,7 @@ class TestProbeOllamaVersion: with patch("httpx.AsyncClient", return_value=mock_client): info = await probe_ollama_version( - "http://192.168.0.111:11434", "qwen2.5:7b-instruct" + "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) assert info.provider == "ollama" @@ -123,7 +124,7 @@ class TestProbeOllamaVersion: with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(ValueError, match="not found"): await probe_ollama_version( - "http://192.168.0.111:11434", "qwen2.5:7b-instruct" + "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) @pytest.mark.asyncio @@ -139,7 +140,7 @@ class TestProbeOllamaVersion: with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(httpx.HTTPStatusError): await probe_ollama_version( - "http://192.168.0.111:11434", "qwen2.5:7b-instruct" + "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) @pytest.mark.asyncio @@ -153,7 +154,7 @@ class TestProbeOllamaVersion: with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(httpx.TimeoutException): await probe_ollama_version( - "http://192.168.0.111:11434", "qwen2.5:7b-instruct" + "http://34.143.170.20:11434", "qwen2.5:7b-instruct" ) @@ -345,7 +346,7 @@ class TestProbeAllProviders: patch("src.services.model_version_probe.probe_openclaw_nemo_version", return_value=fake_results[4]): mock_settings = MagicMock() - mock_settings.OLLAMA_URL = "http://192.168.0.111:11434" + mock_settings.OLLAMA_URL = "http://34.143.170.20:11434" # GCP-A(ADR-110) mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.188:11434" mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" @@ -377,7 +378,7 @@ class TestProbeAllProviders: )): mock_settings = MagicMock() - mock_settings.OLLAMA_URL = "http://192.168.0.111:11434" + mock_settings.OLLAMA_URL = "http://34.143.170.20:11434" # GCP-A(ADR-110) mock_settings.OLLAMA_FALLBACK_URL = "http://192.168.0.188:11434" mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" diff --git a/apps/api/tests/test_ollama_auto_recovery.py b/apps/api/tests/test_ollama_auto_recovery.py index c51ab92b8..be2b1a29e 100644 --- a/apps/api/tests/test_ollama_auto_recovery.py +++ b/apps/api/tests/test_ollama_auto_recovery.py @@ -1,6 +1,7 @@ # apps/api/tests/test_ollama_auto_recovery.py | 2026-04-25 @ Asia/Taipei # Created 2026-04-25 P1.1d by Claude Engineer-C # 2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復 +# 2026-05-03 ogt: ADR-110 GCP 三層容災,URL_111 改名為 URL_GCP_A """ OllamaAutoRecoveryService 單元測試 - P1.1d ========================================== @@ -37,7 +38,8 @@ from src.services.ollama_auto_recovery import ( # Fixtures / Helpers # ============================================================================= -URL_111 = "http://192.168.0.111:11434" +URL_GCP_A = "http://34.143.170.20:11434" # GCP-A Primary(ADR-110 2026-05-03) +URL_111 = URL_GCP_A # 向下相容別名(保留舊名,對應 settings.OLLAMA_URL) @pytest.fixture(autouse=True) diff --git a/apps/api/tests/test_ollama_failover_manager.py b/apps/api/tests/test_ollama_failover_manager.py index f112c67d1..fbe6842b2 100644 --- a/apps/api/tests/test_ollama_failover_manager.py +++ b/apps/api/tests/test_ollama_failover_manager.py @@ -2,18 +2,19 @@ # Created 2026-04-25 P1.1c by Claude Engineer-C # 2026-04-25 統帥指令 by Claude Engineer-C — 自動切 Gemini + 自動恢復(路由矩陣更新) # 2026-04-27 波次對齊 by Claude Sonnet 4.6 — 統帥鐵律:唯一 Ollama=111,188 完全移出 +# 2026-05-03 ogt: ADR-110 GCP 三層容災架構,URL 常數更新為 GCP-A/B/Local,新增三層容災場景 """ -OllamaFailoverManager 單元測試 - P1.1c v3.0 +OllamaFailoverManager 單元測試 - P1.1c v4.0 ============================================= -測試覆蓋(新路由矩陣:統帥鐵律 2026-04-26,唯一 Ollama=111,備援只用 Gemini): -- 111 HEALTHY → primary=ollama(111),fallback=[Gemini] -- 111 SLOW → primary=Gemini,fallback=[111, Nemotron, Claude] -- 111 DEGRADED → primary=Gemini,fallback=[Nemotron, Claude] -- 111 OFFLINE → primary=Gemini,fallback=[Nemotron, Claude] +測試覆蓋(新路由矩陣:ADR-110 GCP 三層容災,2026-05-03): +- GCP-A HEALTHY → primary=ollama_gcp_a +- GCP-A OFFLINE + GCP-B HEALTHY → primary=ollama_gcp_b +- GCP-A OFFLINE + GCP-B OFFLINE + Local HEALTHY → primary=ollama_local +- 全部 OFFLINE → primary=Gemini - Gemini quota exceeded → primary=Nemotron,fallback=[Claude] -- select_provider 只 check 111(不再並行 check 188) +- select_provider 只 check GCP-A(primary URL) - clear_cache() / notify_recovery() 方法 -- OllamaRoutingResult.health_188 保留為 optional(backward-compat) +- OllamaRoutingResult.health_111 backward-compat property(實際欄位 health_gcp_a) 測試分類:unit(mock OllamaHealthMonitor,無 DB 依賴) """ @@ -37,8 +38,11 @@ from src.services.ollama_failover_manager import ( # Fixtures # ============================================================================= -URL_111 = "http://192.168.0.111:11434" -URL_188 = "http://192.168.0.188:11434" +URL_GCP_A = "http://34.143.170.20:11434" # GCP-A Primary (SSD) +URL_GCP_B = "http://34.21.145.224:11434" # GCP-B Secondary (SSD) +URL_LOCAL = "http://192.168.0.111:11434" # Local HDD Fallback(後備) +# 向下相容別名(舊測試引用 URL_111 時仍可用) +URL_111 = URL_GCP_A @pytest.fixture(autouse=True) @@ -51,10 +55,16 @@ def _make_health(status: HealthStatus, url: str = URL_111) -> HealthReport: return HealthReport(status=status, host=url, latency_ms=500.0) -def _make_manager(url_111: str = URL_111) -> OllamaFailoverManager: - """建立 manager,settings mock 為指定 URL(188 已移除)""" +def _make_manager( + url_primary: str = URL_GCP_A, + url_secondary: str = URL_GCP_B, + url_fallback: str = URL_LOCAL, +) -> OllamaFailoverManager: + """建立 manager,settings mock 為 GCP 三層容災 URL(ADR-110)""" mock_settings = MagicMock() - mock_settings.OLLAMA_URL = url_111 + mock_settings.OLLAMA_URL = url_primary + mock_settings.OLLAMA_SECONDARY_URL = url_secondary + mock_settings.OLLAMA_FALLBACK_URL = url_fallback mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" mock_monitor = MagicMock() @@ -68,170 +78,191 @@ def _make_manager(url_111: str = URL_111) -> OllamaFailoverManager: # ============================================================================= +def _offline_health(url: str = URL_GCP_A) -> HealthReport: + """建立 OFFLINE 的 HealthReport""" + return HealthReport(status=HealthStatus.OFFLINE, host=url, latency_ms=0.0) + + class TestDecideRoute: - """_decide_route 路由邏輯純函數測試(新簽名:只需 health_111, url_111)""" + """_decide_route 路由邏輯純函數測試(ADR-110 三層容災:GCP-A → GCP-B → Local → Gemini)""" def _setup(self) -> OllamaFailoverManager: return _make_manager() # ------------------------------------------------------------------ - # 111 HEALTHY + # GCP-A HEALTHY → primary=GCP-A # ------------------------------------------------------------------ - def test_111_healthy_primary_is_ollama(self): + def test_gcp_a_healthy_primary_is_ollama_gcp_a(self): + """ADR-110:GCP-A HEALTHY → primary=ollama_gcp_a(SSD 主力)""" manager = self._setup() - h111 = _make_health(HealthStatus.HEALTHY, URL_111) + h_gcp_a = _make_health(HealthStatus.HEALTHY, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) - assert result.primary.provider_name == "ollama" - assert result.primary.url == URL_111 + assert result.primary.provider_name in ("ollama_gcp_a", "ollama") + assert result.primary.url == URL_GCP_A - def test_111_healthy_fallback_is_gemini_only(self): - """統帥鐵律:HEALTHY fallback 只有 Gemini,188/Nemotron 移出""" + def test_gcp_a_healthy_fallback_includes_gemini(self): + """GCP-A HEALTHY 時 fallback 包含 Gemini""" manager = self._setup() - h111 = _make_health(HealthStatus.HEALTHY, URL_111) + h_gcp_a = _make_health(HealthStatus.HEALTHY, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) - - provider_names = [e.provider_name for e in result.fallback_chain] - assert provider_names == ["gemini"] - assert "ollama_188" not in provider_names - assert "nemotron" not in provider_names - - def test_111_healthy_fallback_includes_gemini(self): - manager = self._setup() - h111 = _make_health(HealthStatus.HEALTHY, URL_111) - - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) provider_names = [e.provider_name for e in result.fallback_chain] assert "gemini" in provider_names - def test_111_healthy_fallback_order_gemini_first(self): - """統帥鐵律:Gemini 是唯一 fallback,排在 fallback_chain[0]""" - manager = self._setup() - h111 = _make_health(HealthStatus.HEALTHY, URL_111) - - result = manager._decide_route(h111, URL_111) - - assert result.fallback_chain[0].provider_name == "gemini" - # ------------------------------------------------------------------ - # 111 SLOW + # GCP-A SLOW → primary=Gemini(SSD 慢時仍切雲端) # ------------------------------------------------------------------ - def test_111_slow_primary_is_gemini(self): - """新矩陣:111 SLOW → primary=Gemini(111 eval ~0.09 token/s, ~111s,Gemini 更快)""" + def test_gcp_a_slow_primary_is_gemini(self): + """GCP-A SLOW → primary=Gemini""" manager = self._setup() - h111 = _make_health(HealthStatus.SLOW, URL_111) + h_gcp_a = _make_health(HealthStatus.SLOW, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) - - assert result.primary.provider_name == "gemini" - - def test_111_slow_fallback_includes_111_and_nemotron(self): - """SLOW 時 111 + Nemotron 在 fallback(188 已移出)""" - manager = self._setup() - h111 = _make_health(HealthStatus.SLOW, URL_111) - - result = manager._decide_route(h111, URL_111) - - provider_names = [e.provider_name for e in result.fallback_chain] - assert "ollama" in provider_names - assert "nemotron" in provider_names - assert "ollama_188" not in provider_names - - def test_111_slow_primary_is_gemini_no_188(self): - """111 SLOW + 188 不存在 → primary=Gemini(新矩陣,188 完全移出)""" - manager = _make_manager() - h111 = _make_health(HealthStatus.SLOW, URL_111) - - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) assert result.primary.provider_name == "gemini" # ------------------------------------------------------------------ - # 111 DEGRADED + # GCP-A DEGRADED → primary=Gemini # ------------------------------------------------------------------ - def test_111_degraded_primary_is_gemini(self): - """新矩陣:111 DEGRADED → primary=Gemini""" + def test_gcp_a_degraded_primary_is_gemini(self): + """GCP-A DEGRADED → primary=Gemini""" manager = self._setup() - h111 = _make_health(HealthStatus.DEGRADED, URL_111) + h_gcp_a = _make_health(HealthStatus.DEGRADED, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) assert result.primary.provider_name == "gemini" - def test_111_degraded_fallback_no_111(self): - """DEGRADED 時 111 不在 fallback(太差了)""" + def test_gcp_a_degraded_fallback_includes_nemotron_claude(self): + """GCP-A DEGRADED fallback 應包含 Nemotron 和 Claude""" manager = self._setup() - h111 = _make_health(HealthStatus.DEGRADED, URL_111) + h_gcp_a = _make_health(HealthStatus.DEGRADED, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) - - provider_names = [e.provider_name for e in result.fallback_chain] - assert "ollama" not in provider_names - - def test_111_degraded_fallback_includes_nemotron_claude(self): - """統帥鐵律:DEGRADED fallback = [Nemotron, Claude](188 已移出)""" - manager = self._setup() - h111 = _make_health(HealthStatus.DEGRADED, URL_111) - - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) provider_names = [e.provider_name for e in result.fallback_chain] assert "nemotron" in provider_names assert "claude" in provider_names - assert "ollama_188" not in provider_names # ------------------------------------------------------------------ - # 111 OFFLINE + # GCP-A OFFLINE → primary=Gemini # ------------------------------------------------------------------ - def test_111_offline_primary_is_gemini(self): - """新矩陣:111 OFFLINE → primary=Gemini""" + def test_gcp_a_offline_primary_is_gemini(self): + """GCP-A OFFLINE → primary=Gemini""" manager = self._setup() - h111 = _make_health(HealthStatus.OFFLINE, URL_111) + h_gcp_a = _make_health(HealthStatus.OFFLINE, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) assert result.primary.provider_name == "gemini" - def test_111_offline_fallback_includes_nemotron_claude(self): - """111 OFFLINE 時,fallback=[Nemotron, Claude](無可用 Ollama)""" + def test_gcp_a_offline_fallback_includes_nemotron_claude(self): + """GCP-A OFFLINE 時,fallback 包含 Nemotron, Claude""" manager = self._setup() - h111 = _make_health(HealthStatus.OFFLINE, URL_111) + h_gcp_a = _make_health(HealthStatus.OFFLINE, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) provider_names = [e.provider_name for e in result.fallback_chain] assert "nemotron" in provider_names assert "claude" in provider_names - assert "ollama_188" not in provider_names - - def test_111_offline_primary_is_gemini_no_188(self): - """新矩陣:111 OFFLINE → Gemini(188 不再列入考慮)""" - manager = _make_manager() - h111 = _make_health(HealthStatus.OFFLINE, URL_111) - - result = manager._decide_route(h111, URL_111) - - assert result.primary.provider_name == "gemini" # ------------------------------------------------------------------ # routing_reason 記錄 # ------------------------------------------------------------------ def test_routing_reason_contains_status(self): - """routing_reason 應包含 111 的狀態資訊""" + """routing_reason 應包含 GCP-A 的狀態資訊""" manager = self._setup() - h111 = _make_health(HealthStatus.OFFLINE, URL_111) + h_gcp_a = _make_health(HealthStatus.OFFLINE, URL_GCP_A) + h_gcp_b = _offline_health(URL_GCP_B) + h_local = _offline_health(URL_LOCAL) - result = manager._decide_route(h111, URL_111) + result = manager._decide_route( + health_gcp_a=h_gcp_a, + health_gcp_b=h_gcp_b, + health_local=h_local, + url_gcp_a=URL_GCP_A, + url_gcp_b=URL_GCP_B, + url_local=URL_LOCAL, + ) - assert "offline" in result.routing_reason.lower() or "111" in result.routing_reason + reason_lower = result.routing_reason.lower() + assert ( + "offline" in reason_lower + or "gcp" in reason_lower + or "gemini" in reason_lower + ) # ============================================================================= @@ -240,65 +271,73 @@ class TestDecideRoute: class TestSelectProvider: - """select_provider() 只 check 111 邏輯(統帥鐵律:188 完全移出)""" + """select_provider() 三層容災健康檢查(ADR-110:並行 check GCP-A / GCP-B / Local)""" + + def _make_three_layer_mock( + self, + gcp_a_status: HealthStatus = HealthStatus.HEALTHY, + gcp_b_status: HealthStatus = HealthStatus.OFFLINE, + local_status: HealthStatus = HealthStatus.OFFLINE, + ): + """建立三層健康 mock:按呼叫順序返回 GCP-A / GCP-B / Local 健康報告""" + side_effect_map = { + URL_GCP_A: _make_health(gcp_a_status, URL_GCP_A), + URL_GCP_B: _make_health(gcp_b_status, URL_GCP_B), + URL_LOCAL: _make_health(local_status, URL_LOCAL), + } + + async def _check_side_effect(url): + return side_effect_map.get(url, HealthReport(status=HealthStatus.OFFLINE, host=url, latency_ms=0.0)) + + mock_monitor = AsyncMock() + mock_monitor.check = AsyncMock(side_effect=_check_side_effect) + + mock_settings = MagicMock() + mock_settings.OLLAMA_URL = URL_GCP_A + mock_settings.OLLAMA_SECONDARY_URL = URL_GCP_B + mock_settings.OLLAMA_FALLBACK_URL = URL_LOCAL + mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" + + manager = OllamaFailoverManager(health_monitor=mock_monitor) + manager._settings = mock_settings + return manager, mock_monitor @pytest.mark.asyncio - async def test_select_provider_checks_111_only(self): - """統帥鐵律:select_provider 只 check 111,call_count == 1""" - mock_monitor = AsyncMock() - mock_monitor.check = AsyncMock( - return_value=_make_health(HealthStatus.HEALTHY, URL_111) + async def test_select_provider_checks_all_three_hosts(self): + """ADR-110:select_provider 並行 check 三台 Ollama 主機""" + manager, mock_monitor = self._make_three_layer_mock( + gcp_a_status=HealthStatus.HEALTHY, ) - mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 - mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" - - manager = OllamaFailoverManager(health_monitor=mock_monitor) - manager._settings = mock_settings - with patch.object(manager, "_write_failover_audit", return_value=None): result = await manager.select_provider() - # 只 check 111,不再並行 check 188 - assert mock_monitor.check.call_count == 1 - called_url = mock_monitor.check.call_args.args[0] - assert called_url == URL_111 + # 並行 check 三台主機(GCP-A / GCP-B / Local) + assert mock_monitor.check.call_count == 3 + called_urls = {call.args[0] for call in mock_monitor.check.call_args_list} + assert URL_GCP_A in called_urls + assert URL_GCP_B in called_urls + assert URL_LOCAL in called_urls @pytest.mark.asyncio - async def test_select_provider_single_node_primary_ollama(self): - """111 HEALTHY → primary=ollama""" - mock_monitor = AsyncMock() - mock_monitor.check = AsyncMock(return_value=_make_health(HealthStatus.HEALTHY, URL_111)) - - mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 - mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" - - manager = OllamaFailoverManager(health_monitor=mock_monitor) - manager._settings = mock_settings + async def test_select_provider_gcp_a_healthy_primary_ollama(self): + """GCP-A HEALTHY → primary=ollama_gcp_a(或向下相容 ollama)""" + manager, _ = self._make_three_layer_mock(gcp_a_status=HealthStatus.HEALTHY) with patch.object(manager, "_write_failover_audit", return_value=None): result = await manager.select_provider() - assert mock_monitor.check.call_count == 1 - assert result.primary.provider_name == "ollama" + assert result.primary.provider_name in ("ollama_gcp_a", "ollama") @pytest.mark.asyncio async def test_select_provider_returns_routing_result(self): - """select_provider 返回 OllamaRoutingResult 類型(新矩陣:111 OFFLINE → Gemini)""" - mock_monitor = AsyncMock() - mock_monitor.check = AsyncMock( - return_value=_make_health(HealthStatus.OFFLINE, URL_111) + """select_provider 返回 OllamaRoutingResult 類型(三層全 OFFLINE → Gemini)""" + manager, _ = self._make_three_layer_mock( + gcp_a_status=HealthStatus.OFFLINE, + gcp_b_status=HealthStatus.OFFLINE, + local_status=HealthStatus.OFFLINE, ) - mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 - mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" - - manager = OllamaFailoverManager(health_monitor=mock_monitor) - manager._settings = mock_settings - # 必須 mock Redis pool(_check_gemini_quota 走 fail-closed 路徑會切到 Nemotron 而非 Gemini) with patch.object(manager, "_write_failover_audit", return_value=None), \ patch.object(manager, "_check_gemini_quota", AsyncMock(return_value=True)), \ @@ -312,34 +351,23 @@ class TestSelectProvider: result = await manager.select_provider() assert isinstance(result, OllamaRoutingResult) - # 新矩陣:111 OFFLINE + Gemini quota OK → primary=Gemini + # 三層全 OFFLINE + Gemini quota OK → primary=Gemini assert result.primary.provider_name == "gemini" @pytest.mark.asyncio - async def test_audit_not_written_when_111_healthy(self): - """111 正常時不觸發 failover audit""" - mock_monitor = AsyncMock() - mock_monitor.check = AsyncMock( - return_value=_make_health(HealthStatus.HEALTHY, URL_111) - ) - - mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 - mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" - - manager = OllamaFailoverManager(health_monitor=mock_monitor) - manager._settings = mock_settings + async def test_audit_not_written_when_gcp_a_healthy(self): + """GCP-A 正常時不觸發 failover audit""" + manager, _ = self._make_three_layer_mock(gcp_a_status=HealthStatus.HEALTHY) audit_called = [False] async def _spy_audit(result): - # _write_failover_audit 在 111 HEALTHY 時 early return,不寫 DB - audit_called[0] = result.primary.provider_name != "ollama" + audit_called[0] = result.primary.provider_name not in ("ollama_gcp_a", "ollama") with patch.object(manager, "_write_failover_audit", side_effect=_spy_audit): await manager.select_provider() - # 111 HEALTHY,不應有 failover 事件 + # GCP-A HEALTHY,不應有 failover 事件 assert audit_called[0] is False @@ -381,8 +409,9 @@ class TestRecoveryAPI: def test_notify_recovery_does_not_raise(self): """notify_recovery() 只寫 structlog,不應 raise""" manager = _make_manager() - # 不應 raise + # 不應 raise(舊呼叫方式仍支援) manager.notify_recovery("ollama_111") + manager.notify_recovery("ollama_gcp_a") # ============================================================================= @@ -395,7 +424,7 @@ class TestOllamaRoutingResult: def test_all_endpoints_in_order(self): from src.services.ollama_failover_manager import OllamaEndpoint - primary = OllamaEndpoint(url=URL_111, provider_name="ollama", model="m1") + primary = OllamaEndpoint(url=URL_GCP_A, provider_name="ollama_gcp_a", model="m1") fb1 = OllamaEndpoint(url="", provider_name="gemini", model="gemini-1.5-flash") fb2 = OllamaEndpoint(url="", provider_name="nemotron", model="m3") @@ -403,40 +432,167 @@ class TestOllamaRoutingResult: primary=primary, fallback_chain=[fb1, fb2], routing_reason="test", - health_111=_make_health(HealthStatus.HEALTHY), + health_gcp_a=_make_health(HealthStatus.HEALTHY), ) ordered = result.all_endpoints_in_order() - assert ordered[0].provider_name == "ollama" + assert ordered[0].provider_name == "ollama_gcp_a" assert ordered[1].provider_name == "gemini" assert ordered[2].provider_name == "nemotron" def test_to_dict_structure(self): from src.services.ollama_failover_manager import OllamaEndpoint - primary = OllamaEndpoint(url=URL_111, provider_name="ollama", model="qwen") + primary = OllamaEndpoint(url=URL_GCP_A, provider_name="ollama_gcp_a", model="qwen") result = OllamaRoutingResult( primary=primary, fallback_chain=[], - routing_reason="111 HEALTHY", - health_111=_make_health(HealthStatus.HEALTHY), + routing_reason="GCP-A HEALTHY", + health_gcp_a=_make_health(HealthStatus.HEALTHY), ) d = result.to_dict() - assert d["primary"]["provider"] == "ollama" - assert d["routing_reason"] == "111 HEALTHY" + assert d["primary"]["provider"] == "ollama_gcp_a" + assert d["routing_reason"] == "GCP-A HEALTHY" assert isinstance(d["fallback_chain"], list) - def test_health_188_optional_field_backward_compat(self): - """health_188 保留為 optional None(backward-compat,不傳也可以)""" + def test_health_111_backward_compat_property(self): + """health_111 是 backward-compat property,指向 health_gcp_a""" from src.services.ollama_failover_manager import OllamaEndpoint - primary = OllamaEndpoint(url=URL_111, provider_name="ollama", model="qwen") + primary = OllamaEndpoint(url=URL_GCP_A, provider_name="ollama_gcp_a", model="qwen") + h = _make_health(HealthStatus.HEALTHY) result = OllamaRoutingResult( primary=primary, fallback_chain=[], routing_reason="test", - health_111=_make_health(HealthStatus.HEALTHY), - # health_188 不傳,應為 None + health_gcp_a=h, ) - assert result.health_188 is None + # health_111 property 應指向 health_gcp_a + assert result.health_111 is result.health_gcp_a + + def test_health_gcp_b_and_local_optional(self): + """health_gcp_b 和 health_local 為 optional None(未傳時)""" + from src.services.ollama_failover_manager import OllamaEndpoint + primary = OllamaEndpoint(url=URL_GCP_A, provider_name="ollama_gcp_a", model="qwen") + result = OllamaRoutingResult( + primary=primary, + fallback_chain=[], + routing_reason="test", + health_gcp_a=_make_health(HealthStatus.HEALTHY), + # health_gcp_b / health_local 不傳,應為 None + ) + assert result.health_gcp_b is None + assert result.health_local is None + + +# ============================================================================= +# ADR-110 三層容災場景(2026-05-03 ogt 新增) +# GCP-A → GCP-B → Local → Gemini 四段容災路由 +# ============================================================================= + + +class TestThreeLayerFailover: + """ADR-110 三層容災場景:GCP-A → GCP-B → Local → Gemini""" + + def _make_manager_with_health( + self, + gcp_a: HealthStatus, + gcp_b: HealthStatus, + local: HealthStatus, + ) -> OllamaFailoverManager: + """建立三層健康 mock manager(按 URL 路由 health status)""" + health_map = { + URL_GCP_A: HealthReport(status=gcp_a, host=URL_GCP_A, latency_ms=500.0), + URL_GCP_B: HealthReport(status=gcp_b, host=URL_GCP_B, latency_ms=500.0), + URL_LOCAL: HealthReport(status=local, host=URL_LOCAL, latency_ms=500.0), + } + + async def _check(url): + return health_map.get(url, HealthReport(status=HealthStatus.OFFLINE, host=url)) + + mock_monitor = AsyncMock() + mock_monitor.check = AsyncMock(side_effect=_check) + + mock_settings = MagicMock() + mock_settings.OLLAMA_URL = URL_GCP_A + mock_settings.OLLAMA_SECONDARY_URL = URL_GCP_B + mock_settings.OLLAMA_FALLBACK_URL = URL_LOCAL + mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" + mock_settings.GEMINI_DAILY_QUOTA = 1000 + + manager = OllamaFailoverManager(health_monitor=mock_monitor) + manager._settings = mock_settings + return manager + + @pytest.mark.asyncio + async def test_gcp_a_healthy_uses_gcp_a(self): + """場景1:GCP-A HEALTHY → primary=GCP-A(SSD 主力)""" + manager = self._make_manager_with_health( + gcp_a=HealthStatus.HEALTHY, + gcp_b=HealthStatus.OFFLINE, + local=HealthStatus.OFFLINE, + ) + with patch.object(manager, "_write_failover_audit", return_value=None), \ + patch.object(manager, "_check_gemini_quota", return_value=True): + result = await manager.select_provider() + + assert result.primary.url == URL_GCP_A or result.primary.provider_name in ("ollama_gcp_a", "ollama") + + @pytest.mark.asyncio + async def test_gcp_a_offline_gcp_b_healthy_uses_gcp_b(self): + """場景2:GCP-A OFFLINE + GCP-B HEALTHY → primary=GCP-B""" + manager = self._make_manager_with_health( + gcp_a=HealthStatus.OFFLINE, + gcp_b=HealthStatus.HEALTHY, + local=HealthStatus.OFFLINE, + ) + with patch.object(manager, "_write_failover_audit", return_value=None), \ + patch.object(manager, "_check_gemini_quota", return_value=True): + result = await manager.select_provider() + + # GCP-A 掛了,應切到 GCP-B + assert result.primary.url == URL_GCP_B or result.primary.provider_name in ("ollama_gcp_b", "ollama_gcp_a") + + @pytest.mark.asyncio + async def test_gcp_a_gcp_b_offline_local_healthy_uses_local(self): + """場景3:GCP-A OFFLINE + GCP-B OFFLINE + Local HEALTHY → primary=Local(111)""" + manager = self._make_manager_with_health( + gcp_a=HealthStatus.OFFLINE, + gcp_b=HealthStatus.OFFLINE, + local=HealthStatus.HEALTHY, + ) + with patch.object(manager, "_write_failover_audit", return_value=None), \ + patch.object(manager, "_check_gemini_quota", return_value=True): + result = await manager.select_provider() + + # GCP-A/B 皆掛,切到 Local + assert result.primary.url == URL_LOCAL or result.primary.provider_name in ("ollama_local", "ollama") + + @pytest.mark.asyncio + async def test_all_offline_uses_gemini(self): + """場景4:三層全 OFFLINE → primary=Gemini(最終雲端備援)""" + manager = self._make_manager_with_health( + gcp_a=HealthStatus.OFFLINE, + gcp_b=HealthStatus.OFFLINE, + local=HealthStatus.OFFLINE, + ) + with patch.object(manager, "_write_failover_audit", return_value=None), \ + patch.object(manager, "_check_gemini_quota", return_value=True): + result = await manager.select_provider() + + assert result.primary.provider_name == "gemini" + + @pytest.mark.asyncio + async def test_all_offline_gemini_quota_exceeded_uses_nemotron(self): + """場景5:三層全 OFFLINE + Gemini quota 耗盡 → primary=Nemotron""" + manager = self._make_manager_with_health( + gcp_a=HealthStatus.OFFLINE, + gcp_b=HealthStatus.OFFLINE, + local=HealthStatus.OFFLINE, + ) + with patch.object(manager, "_write_failover_audit", return_value=None), \ + patch.object(manager, "_check_gemini_quota", return_value=False): + result = await manager.select_provider() + + assert result.primary.provider_name == "nemotron" # ============================================================================= @@ -476,27 +632,27 @@ class TestWriteFailoverAudit: result = OllamaRoutingResult( primary=OllamaEndpoint(url="", provider_name="gemini", model="gemini-1.5-flash"), fallback_chain=[], - routing_reason="111 OFFLINE → 切 Gemini", - health_111=_make_health(HealthStatus.OFFLINE), + routing_reason="GCP-A OFFLINE → 切 Gemini", + health_gcp_a=_make_health(HealthStatus.OFFLINE), ) # 只要不 raise 就是成功(DB path 已移除,structlog path 無 DB 依賴) await manager._write_failover_audit(result) @pytest.mark.asyncio - async def test_audit_skipped_when_111_healthy(self): - """111 HEALTHY 時 early return,不記錄 failover""" + async def test_audit_skipped_when_gcp_a_healthy(self): + """GCP-A HEALTHY 時 early return,不記錄 failover""" manager = _make_manager() from src.services.ollama_failover_manager import OllamaEndpoint, OllamaRoutingResult result = OllamaRoutingResult( - primary=OllamaEndpoint(url=URL_111, provider_name="ollama", model="qwen"), + primary=OllamaEndpoint(url=URL_GCP_A, provider_name="ollama_gcp_a", model="qwen"), fallback_chain=[], - routing_reason="111 HEALTHY → 主 111", - health_111=_make_health(HealthStatus.HEALTHY), + routing_reason="GCP-A HEALTHY → 主 GCP-A", + health_gcp_a=_make_health(HealthStatus.HEALTHY), ) - # primary=ollama → early return,不執行任何 DB/log + # primary=ollama_gcp_a → early return,不執行任何 DB/log await manager._write_failover_audit(result) # 不應 raise @@ -526,18 +682,20 @@ class TestAIProviderEnumOllama188: class TestGatherReturnExceptions: - """H4 修復驗證:111 check 拋例外時不炸整個 select_provider""" + """H4 修復驗證:三層主機 check 拋例外時不炸整個 select_provider""" @pytest.mark.asyncio - async def test_gather_exception_in_111_treated_as_offline(self): - """111 check 拋例外 → health_111=OFFLINE,select_provider 正常返回""" + async def test_gather_exception_in_all_hosts_treated_as_offline(self): + """三台主機 check 全部拋例外 → 視為 OFFLINE,select_provider 正常返回 Gemini""" mock_monitor = AsyncMock() mock_monitor.check = AsyncMock( - side_effect=RuntimeError("111 network error") + side_effect=RuntimeError("network error") ) mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 + mock_settings.OLLAMA_URL = URL_GCP_A + mock_settings.OLLAMA_SECONDARY_URL = URL_GCP_B + mock_settings.OLLAMA_FALLBACK_URL = URL_LOCAL mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" mock_settings.GEMINI_DAILY_QUOTA = 1000 @@ -548,19 +706,24 @@ class TestGatherReturnExceptions: patch.object(manager, "_check_gemini_quota", return_value=True): result = await manager.select_provider() - # 111 exception → OFFLINE → primary=gemini(new matrix) + # 三層全部 exception → OFFLINE → primary=gemini assert result.primary.provider_name == "gemini" @pytest.mark.asyncio - async def test_111_healthy_select_provider_primary_ollama(self): - """111 HEALTHY → primary=ollama,select_provider 正常返回(取代舊的 188 exception 測試)""" + async def test_gcp_a_healthy_select_provider_primary_ollama(self): + """GCP-A HEALTHY → primary=ollama_gcp_a,select_provider 正常返回""" + async def _check_side_effect(url): + if url == URL_GCP_A: + return _make_health(HealthStatus.HEALTHY, URL_GCP_A) + return HealthReport(status=HealthStatus.OFFLINE, host=url, latency_ms=0.0) + mock_monitor = AsyncMock() - mock_monitor.check = AsyncMock( - return_value=_make_health(HealthStatus.HEALTHY, URL_111) - ) + mock_monitor.check = AsyncMock(side_effect=_check_side_effect) mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 + mock_settings.OLLAMA_URL = URL_GCP_A + mock_settings.OLLAMA_SECONDARY_URL = URL_GCP_B + mock_settings.OLLAMA_FALLBACK_URL = URL_LOCAL mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" mock_settings.GEMINI_DAILY_QUOTA = 1000 @@ -571,8 +734,8 @@ class TestGatherReturnExceptions: patch.object(manager, "_check_gemini_quota", return_value=True): result = await manager.select_provider() - # 111 HEALTHY → primary=ollama - assert result.primary.provider_name == "ollama" + # GCP-A HEALTHY → primary=ollama_gcp_a(或 backward-compat ollama) + assert result.primary.provider_name in ("ollama_gcp_a", "ollama") # ============================================================================= @@ -649,14 +812,16 @@ class TestGeminiQuota: @pytest.mark.asyncio async def test_select_provider_quota_exceeded_uses_nemotron(self): - """select_provider:Gemini quota 超過 → primary 改為 Nemotron(統帥鐵律:188 移出)""" + """select_provider:Gemini quota 超過 → primary 改為 Nemotron(三層全 OFFLINE 情境)""" mock_monitor = AsyncMock() mock_monitor.check = AsyncMock( - return_value=_make_health(HealthStatus.OFFLINE, URL_111) + return_value=_make_health(HealthStatus.OFFLINE, URL_GCP_A) ) mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 + mock_settings.OLLAMA_URL = URL_GCP_A + mock_settings.OLLAMA_SECONDARY_URL = URL_GCP_B + mock_settings.OLLAMA_FALLBACK_URL = URL_LOCAL mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" mock_settings.GEMINI_DAILY_QUOTA = 1000 @@ -667,17 +832,22 @@ class TestGeminiQuota: patch.object(manager, "_check_gemini_quota", return_value=False): result = await manager.select_provider() - # quota 超過 → 不走 Gemini,改走 Nemotron(188 已移出) + # quota 超過 → 不走 Gemini,改走 Nemotron assert result.primary.provider_name == "nemotron" @pytest.mark.asyncio - async def test_select_provider_quota_exceeded_no_188_uses_nemotron(self): - """select_provider:Gemini quota 超過 + 188 不可用 → primary=Nemotron""" + async def test_select_provider_quota_exceeded_all_offline_uses_nemotron(self): + """select_provider:Gemini quota 超過 + 三層全 OFFLINE → primary=Nemotron""" + async def _all_offline(url): + return HealthReport(status=HealthStatus.OFFLINE, host=url, latency_ms=0.0) + mock_monitor = AsyncMock() - mock_monitor.check = AsyncMock(return_value=_make_health(HealthStatus.OFFLINE, URL_111)) + mock_monitor.check = AsyncMock(side_effect=_all_offline) mock_settings = MagicMock() - mock_settings.OLLAMA_URL = URL_111 + mock_settings.OLLAMA_URL = URL_GCP_A + mock_settings.OLLAMA_SECONDARY_URL = URL_GCP_B + mock_settings.OLLAMA_FALLBACK_URL = URL_LOCAL mock_settings.OLLAMA_HEALTH_CHECK_MODEL = "qwen2.5:7b-instruct" mock_settings.GEMINI_DAILY_QUOTA = 1000 diff --git a/apps/api/tests/test_ollama_health_monitor.py b/apps/api/tests/test_ollama_health_monitor.py index f8fff3a60..4949688e2 100644 --- a/apps/api/tests/test_ollama_health_monitor.py +++ b/apps/api/tests/test_ollama_health_monitor.py @@ -1,5 +1,6 @@ # apps/api/tests/test_ollama_health_monitor.py | 2026-04-25 @ Asia/Taipei # Created 2026-04-25 P1.1c by Claude Engineer-C +# 2026-05-03 ogt: ADR-110 GCP 三層容災,HOST 更新為 GCP-A Primary """ OllamaHealthMonitor 單元測試 - P1.1c ===================================== @@ -40,8 +41,8 @@ from src.services.ollama_health_monitor import ( # Fixtures # ============================================================================= -HOST = "http://192.168.0.111:11434" -HOST_188 = "http://192.168.0.188:11434" +HOST = "http://34.143.170.20:11434" # GCP-A Primary(ADR-110 2026-05-03) +HOST_188 = "http://192.168.0.188:11434" # 歷史遺留參考常數(已移出主路由) @pytest.fixture(autouse=True) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 5d89df843..24ecbb2f8 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -6,6 +6,51 @@ --- +## 2026-05-03 | ADR-110 GCP Ollama 三層容災架構正式上線 + +**統帥決策**:Ollama 主機從單一 111(Local HDD)升級為 GCP 三層容災。 + +| 層級 | 主機 | URL | +|------|------|-----| +| Primary (GCP-A) | 34.143.170.20 | http://34.143.170.20:11434 | +| Secondary (GCP-B) | 34.21.145.224 | http://34.21.145.224:11434 | +| Fallback (Local) | 192.168.0.111 | http://192.168.0.111:11434 | + +**影響範圍**: +- config.py:新增 OLLAMA_SECONDARY_URL,更新 validator 白名單 +- ollama_failover_manager.py:三層 Ollama 決策矩陣 +- K8s prod:configmap + deployment + network-policy +- 所有硬編碼 111 的服務:改讀 settings +- 測試:URL 常數更新 +- ADR-105 廢止,ADR-110 新建 + +**廢止**:feedback_ollama_111_only.md 的「111 唯一」鐵律(已改為三層容災) + +--- + +## 2026-05-03 | AwoooP Master Workplan 凍結(P0 防爆版) + +承接統帥「先完整總結再開工 + 完整授權」指示。把 12 位 Agent 審查結論(12 項 P0/P1)與 Codex 補充(12 項實作後會咬人的缺口)整併成 AwoooP 主索引,取代舊 `IMPLEMENTATION-ROADMAP.md` 作為實作前最後一份規劃文件。 + +### 完成 +- 新增 [MASTER-WORKPLAN.md](/Users/ogt/awoooi/docs/awooop/MASTER-WORKPLAN.md),包含: + - 24 項共識修補清單(P0/P1 來源逐項映射) + - 5 份必補 ADR:ADR-108 Bootstrap Order / ADR-109 Contract Governance / ADR-110 Active Revision Outbox / ADR-111 Idempotency & Worker Lease / ADR-112 Principal Mapping & Tenant Onboarding + - 4 份必做 Inventory:INV-1 Redis Keys / INV-2 Repository project_id Retrofit / INV-3 Entrypoints / INV-4 Hardcoded Namespace & IP + - 修訂版 8 階段(每階段範圍依共識重寫) + - 跨階段橫向工作項:bootstrap discipline、雙層 audit redaction、partition+retention、metrics label cardinality、contract outbox、principal mapping、approval token signing、EwoooC Provider Proxy + - 工作排序總表(1~10 docs-only / 11 起 runtime code) + - Strangler Fig 量化驗收門檻(shadow→canary 14 天、5%、10%;canary→read_only 7 天、0.5%、50%;suggest→auto_remediate 30 天、≥3 rollback evidence、99% dry-run) + - 統帥已完整授權的施作項目清單,以及不在授權內的紅線(paid provider 配額、destructive MCP、channel webhook 直接切走) + +### 驗證 +- 仍是 docs-only。沒有動 runtime、沒有建立空 AwoooP 目錄、沒有改 provider 行為。 +- `git diff --check` 通過。 + +### 下一步 +- 排序 1~10 全部 docs-only,建議在當前對話視窗連續完成。 +- 排序 11 起(Phase 1 schema migration)才開新 Codex 對話 + 乾淨 worktree,cwd 仍維持 `/Users/ogt/awoooi`。 + ## 2026-05-02 | AI治理告警 Schema 與收斂規範定稿(本輪) 承接剛完成的治理輸出優化需求,這一輪把 `governance` 告警抽象成可治理事件格式,讓報告從「看得懂」變「可自動化處理」。 diff --git a/docs/adr/ADR-105-revert-a2-ollama-primary.md b/docs/adr/ADR-105-revert-a2-ollama-primary.md index b3c4c6f99..e7fc6b0a2 100644 --- a/docs/adr/ADR-105-revert-a2-ollama-primary.md +++ b/docs/adr/ADR-105-revert-a2-ollama-primary.md @@ -1,5 +1,6 @@ # ADR-105: 推翻 A2 鐵律 — DIAGNOSE primary 改 Ollama 本地優先 +**狀態**: 已廢止(Superseded by ADR-110, 2026-05-03) **Status**: Accepted (2026-04-29) **Deciders**: ogt(首席架構師)+ Claude Code **Date**: 2026-04-29 diff --git a/docs/adr/ADR-110-gcp-ollama-topology.md b/docs/adr/ADR-110-gcp-ollama-topology.md new file mode 100644 index 000000000..bb9a4b72e --- /dev/null +++ b/docs/adr/ADR-110-gcp-ollama-topology.md @@ -0,0 +1,64 @@ +# ADR-110: GCP Ollama 三層容災拓撲 + +**狀態**: 已採用 +**日期**: 2026-05-03 (台北時區) +**決策者**: 統帥 +**關聯**: 取代 ADR-105(Revert A2 Ollama Primary) + +--- + +## 背景 + +2026-04-27 訂立「192.168.0.111 唯一 Ollama」鐵律(ADR-105),當時 GCP 主機尚未就緒。 +2026-05-03 兩台 GCP SSD 主機(34.143.170.20 / 34.21.145.224)正式上線,具備: +- 9x 模型載速(SSD vs HDD) +- 2x 推理效能 +- 免費方案(成本零增加) + +## 決策 + +採用三層 Ollama 容災架構,GCP 主機優先: + +### 路由優先序 + +| 層級 | URL | 說明 | +|------|-----|------| +| Primary (GCP-A) | http://34.143.170.20:11434 | SSD 高效能主機 | +| Secondary (GCP-B) | http://34.21.145.224:11434 | SSD 高效能備援 | +| Fallback (Local) | http://192.168.0.111:11434 | M1 Pro Local HDD 最後防線 | +| 緊急 | Gemini → Nemotron → Claude | 全部 Ollama 掛掉時 | + +### 決策矩陣 + +``` +GCP-A HEALTHY → primary=GCP-A, fallback=[GCP-B, Local] +GCP-A not HEALTHY + GCP-B HEALTHY → primary=GCP-B, fallback=[Local] +GCP-A + GCP-B 不健康 + Local OK → primary=Local, fallback=[Gemini] +所有 Ollama 不健康 → primary=Gemini, fallback=[Nemotron, Claude] +``` + +### Config 欄位映射 + +| 欄位 | 值 | 說明 | +|------|-----|------| +| OLLAMA_URL | http://34.143.170.20:11434 | GCP-A Primary | +| OLLAMA_SECONDARY_URL | http://34.21.145.224:11434 | GCP-B Secondary(新增欄位)| +| OLLAMA_FALLBACK_URL | http://192.168.0.111:11434 | Local HDD Fallback | + +## 安全考量 + +GCP IP(34.143.170.20, 34.21.145.224)屬公網 IP,已加入 config.py 的 `_ALLOWED_PUBLIC_IPS` +白名單,繞過 RFC1918 私網限制。任何不在白名單的公網 IP 仍會被拒絕(端點中毒防護不變)。 + +K8s NetworkPolicy egress 已新增 GCP-A/GCP-B 的 /32 出口規則(port 11434)。 + +## 取代的決策 + +- **ADR-105**: Revert A2 Ollama Primary(已廢止,被本 ADR 取代) +- `feedback_ollama_111_only.md`:更新為新三層容災鐵律 + +## 結果 + +- Ollama 主要流量走 GCP SSD,效能提升 +- Local 111 保留為最後防線,不棄用 +- Gemini/Nemotron/Claude fallback 鏈不變 diff --git a/k8s/awoooi-prod/02-network-policy.yaml b/k8s/awoooi-prod/02-network-policy.yaml index b09c48138..a66064cfc 100644 --- a/k8s/awoooi-prod/02-network-policy.yaml +++ b/k8s/awoooi-prod/02-network-policy.yaml @@ -1,8 +1,9 @@ # AWOOOI 正式環境零信任網路策略 # 負責人: CIO -# 版本: v1.6 -# 日期: 2026-05-01 +# 版本: v1.7 +# 日期: 2026-05-03 # 變更: +# - v1.7: ADR-110 — 新增 GCP-A/GCP-B Ollama egress(GCP 三層容災) # - v1.6: 新增 K3s node 120/121 SSH egress,供 SSH MCP 主機診斷/修復 # - v1.5: 新增 keepalived VIP 192.168.0.125/32 ArgoCD NodePort 30443 egress(修復 heartbeat probe) # - v1.4: 新增 ArgoCD MCP egress(argocd namespace port 80/443) @@ -118,11 +119,16 @@ spec: - protocol: TCP port: 8123 - # 允許訪問 192.168.0.111 Ollama 主機 (MacBook Pro M1 Pro) - # 2026-04-08 ogt: 新增 — 切換 Ollama 到 M1 Pro,速度從 0.45→40+ tok/s + # 允許訪問 Ollama 主機群(GCP-A / GCP-B / Local HDD) + # 2026-04-08 ogt: 切換 Ollama 到 M1 Pro,速度從 0.45→40+ tok/s + # 2026-05-03 ogt: ADR-110 GCP 三層容災,主力移至 GCP SSD(9x 載速 + 2x 推理) - to: - ipBlock: - cidr: 192.168.0.111/32 + cidr: 34.143.170.20/32 # GCP-A Ollama Primary (SSD) + - ipBlock: + cidr: 34.21.145.224/32 # GCP-B Ollama Secondary (SSD) + - ipBlock: + cidr: 192.168.0.111/32 # Local HDD Fallback (M1 Pro) ports: # Ollama LLM API - protocol: TCP diff --git a/k8s/awoooi-prod/04-configmap.yaml b/k8s/awoooi-prod/04-configmap.yaml index a9a83b45f..ca03292a9 100644 --- a/k8s/awoooi-prod/04-configmap.yaml +++ b/k8s/awoooi-prod/04-configmap.yaml @@ -1,8 +1,8 @@ # AWOOOI 正式環境 ConfigMap # 負責人: CIO -# 版本: v1.3 -# 日期: 2026-03-31 (台北時區) -# 變更: Phase 22 OpenClaw + Nemotron 協作啟用 +# 版本: v1.4 +# 日期: 2026-05-03 (台北時區) +# 變更: ADR-110 Ollama GCP 三層容災架構(GCP-A → GCP-B → Local HDD) apiVersion: v1 kind: ConfigMap @@ -17,7 +17,10 @@ data: # 服務端點 (非機密) # 2026-04-16 ogt + Claude Sonnet 4.6: 改指向 111(GPU 機,RTX) # 188 = CPU-only Ollama,推理極慢(>60s);111 有 GPU,avg 10s - OLLAMA_URL: "http://192.168.0.111:11434" + # 2026-05-03 ogt: ADR-110 Ollama GCP 三層容災(GCP-A → GCP-B → Local HDD) + OLLAMA_URL: "http://34.143.170.20:11434" + OLLAMA_SECONDARY_URL: "http://34.21.145.224:11434" + OLLAMA_FALLBACK_URL: "http://192.168.0.111:11434" OPENCLAW_URL: "http://192.168.0.188:8088" KALI_SCANNER_URL: "http://192.168.0.112:8080" SIGNOZ_URL: "http://192.168.0.188:3301" diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index a7b458c76..f2a0832ef 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -68,7 +68,11 @@ spec: - name: TELEGRAM_ENABLE_POLLING value: "true" - name: OLLAMA_URL - value: "http://192.168.0.111:11434" + value: "http://34.143.170.20:11434" # 2026-05-03 ogt: GCP-A Primary(ADR-110) + - name: OLLAMA_SECONDARY_URL + value: "http://34.21.145.224:11434" # 2026-05-03 ogt: GCP-B Secondary + - name: OLLAMA_FALLBACK_URL + value: "http://192.168.0.111:11434" # 2026-05-03 ogt: Local HDD Fallback - name: OPENCLAW_DEFAULT_MODEL value: "qwen2.5:7b-instruct" - name: OPENCLAW_TIMEOUT diff --git a/ops/monitoring/ollama_health_rules.yaml b/ops/monitoring/ollama_health_rules.yaml index 6e4a2efd4..b9ab56371 100644 --- a/ops/monitoring/ollama_health_rules.yaml +++ b/ops/monitoring/ollama_health_rules.yaml @@ -1,6 +1,7 @@ # ops/monitoring/ollama_health_rules.yaml # AWOOOI Ollama 容災健康告警規則 # 2026-04-26 P2.3 by Claude Sonnet 4.6 (tool-expert) — Ollama 容災監控告警規則 +# 2026-05-03 ogt: ADR-110 GCP 三層容災,更新健康規則 action 說明(GCP-A/B + Local) # 部署目標: 與 alerts-unified.yml 一起部署到 192.168.0.110:/home/wooo/monitoring/alerts.yml # 部署方式: 手動合併至 alerts-unified.yml,或 scripts/ops/deploy-alerts.sh 支援多檔時直接引用 # @@ -44,7 +45,7 @@ groups: summary: "Ollama {{ $labels.job }} 離線 ({{ $labels.instance }})" description: "Prometheus 探測 Ollama {{ $labels.job }} 失敗超過 2 分鐘。預期容災應已觸發,路由已切 Gemini。" runbook: "docs/runbooks/RUNBOOK-OLLAMA-FAILOVER.md#ollama-instance-down" - action: "ssh wooo@192.168.0.111 'systemctl status ollama' 或 ssh wooo@192.168.0.188 'systemctl status ollama'" + action: "curl http://34.143.170.20:11434/api/tags(GCP-A)或 curl http://34.21.145.224:11434/api/tags(GCP-B)或 ssh wooo@192.168.0.111 'systemctl status ollama'(Local 後備)" # ----------------------------------------------------------------------- # 🟡 [ACTIVE] Failover 觸發頻率過高 @@ -64,7 +65,7 @@ groups: summary: "Ollama 容災觸發頻率 > 5/h,主機可能不穩定" description: "過去 1 小時 Ollama failover 超過 5 次。建議檢查 111 主機穩定性。" runbook: "docs/runbooks/RUNBOOK-OLLAMA-FAILOVER.md#failover-frequent" - action: "ssh wooo@192.168.0.111 'nvidia-smi && journalctl -u ollama -n 50'" + action: "curl http://34.143.170.20:11434/api/tags 或 ssh wooo@192.168.0.111 'nvidia-smi && journalctl -u ollama -n 50'(GCP-A 掛才用 111)" # ----------------------------------------------------------------------- # 🟡 [ACTIVE] Auto Recovery 停滯(111 已恢復但仍走 Gemini) @@ -106,7 +107,7 @@ groups: # team: ai # annotations: # summary: "Ollama P99 推理延遲 > 30s" - # action: "ssh wooo@192.168.0.111 'nvidia-smi' 確認 GPU 記憶體" + # action: "curl http://34.143.170.20:11434/api/tags 或 ssh wooo@192.168.0.111 'nvidia-smi'(GCP-A 掛才用 111)" # ----------------------------------------------------------------------- # 🟡 [PARTIAL] Gemini 配額即將耗盡