diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index ae3dacd8d..1a8f18af3 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -136,6 +136,27 @@ class Settings(BaseSettings): description="ClickHouse HTTP API URL (SignOz backend, direct query)", ) + # ========================================================================== + # Sentry Self-Hosted (Phase 10: Error Tracking + AI Analysis) + # 端點: http://192.168.0.110:9000 (DevOps 金庫) + # ========================================================================== + SENTRY_SELF_HOSTED_URL: str = Field( + default="http://192.168.0.110:9000", + description="Sentry Self-Hosted API URL", + ) + SENTRY_ORG: str = Field( + default="sentry", + description="Sentry organization slug", + ) + SENTRY_PROJECT: str = Field( + default="awoooi-api", + description="Sentry project slug", + ) + SENTRY_AUTH_TOKEN: str = Field( + default="", + description="Sentry Auth Token for API access (from K8s Secret)", + ) + # ========================================================================== # OpenTelemetry (可觀測性鐵律) # 四主機架構強制校驗: OTEL 必須指向 192.168.0.188 @@ -370,6 +391,38 @@ class Settings(BaseSettings): HttpUrl(v) return v + # ========================================================================== + # Phase 13.1: GitHub Webhook → OpenClaw 整合 + # GitHub PR/Push 事件自動觸發 AI 代碼審查 + # ========================================================================== + GITHUB_WEBHOOK_SECRET: str = Field( + default="", + description="GitHub Webhook secret for signature verification (X-Hub-Signature-256)", + ) + GITHUB_ALLOWED_REPOS: str = Field( + default="", + description="Comma-separated list of allowed repositories (e.g., 'owner/repo1,owner/repo2')", + ) + + def get_github_allowed_repos(self) -> list[str]: + """Parse comma-separated allowed repos to list""" + raw = self.GITHUB_ALLOWED_REPOS + if not raw or not raw.strip(): + return [] + return [repo.strip() for repo in raw.split(",") if repo.strip()] + + # ========================================================================== + # Phase 13.2: Grafana MCP Tool (#83) + # ========================================================================== + GRAFANA_URL: str = Field( + default="http://192.168.0.188:3000", + description="Grafana server URL", + ) + GRAFANA_API_KEY: str = Field( + default="", + description="Grafana API key for authentication (Bearer token)", + ) + # ========================================================================== # Computed Properties # ========================================================================== diff --git a/apps/api/src/plugins/mcp/providers/__init__.py b/apps/api/src/plugins/mcp/providers/__init__.py index 0c7554ca3..795d9d6d5 100644 --- a/apps/api/src/plugins/mcp/providers/__init__.py +++ b/apps/api/src/plugins/mcp/providers/__init__.py @@ -6,11 +6,19 @@ MCP Tool Providers - ADR-015 模組化架構 - K8sProvider: Kubernetes 操作 (kubectl) - SignOzProvider: 監控指標查詢 - DatabaseProvider: 資料庫查詢 (Approval/Incident) +- FilesystemProvider: 安全受限的文件讀取 (#82) +- GrafanaProvider: Grafana Dashboard 查詢 (#83) @see docs/adr/ADR-015-mcp-modular-architecture.md + +變更紀錄: +- 2026-03-26 v1.2: 新增 GrafanaProvider (#83) - Claude Code +- 2026-03-26 v1.1: 新增 FilesystemProvider (#82) - Claude Code """ from src.plugins.mcp.providers.database_provider import DatabaseProvider +from src.plugins.mcp.providers.filesystem_provider import FilesystemProvider +from src.plugins.mcp.providers.grafana_provider import GrafanaProvider from src.plugins.mcp.providers.k8s_provider import K8sProvider from src.plugins.mcp.providers.signoz_provider import SignOzProvider @@ -18,6 +26,8 @@ __all__ = [ "K8sProvider", "SignOzProvider", "DatabaseProvider", + "FilesystemProvider", + "GrafanaProvider", ] @@ -32,3 +42,5 @@ def register_all_providers() -> None: register_provider(K8sProvider()) register_provider(SignOzProvider()) register_provider(DatabaseProvider()) + register_provider(FilesystemProvider()) + register_provider(GrafanaProvider()) diff --git a/apps/api/src/plugins/mcp/providers/grafana_provider.py b/apps/api/src/plugins/mcp/providers/grafana_provider.py new file mode 100644 index 000000000..08a813dd1 --- /dev/null +++ b/apps/api/src/plugins/mcp/providers/grafana_provider.py @@ -0,0 +1,665 @@ +""" +Grafana MCP Tool Provider - Phase 13.2 #83 +========================================== + +提供 Grafana Dashboard 查詢工具: +- list_dashboards: 列出可用 Dashboard +- get_dashboard: 取得 Dashboard 詳細資訊 +- get_panel_data: 取得 Panel 數據 +- generate_dashboard_url: 產生 Dashboard URL + +安全機制: +1. API Key 認證 (Bearer Token) +2. 唯讀操作 (Read-Only) +3. Dashboard UID 驗證 + +透過 DI 注入配置,不直接 import services。 + +@see docs/adr/ADR-015-mcp-modular-architecture.md +@author Claude Code +@version 1.0 +@created 2026-03-26 (台北時區) +@issue #83 +""" + +import re +import uuid +from typing import Any +from urllib.parse import urljoin + +import httpx +import structlog + +from src.plugins.mcp.interfaces import MCPTool, MCPToolProvider, MCPToolResult + +logger = structlog.get_logger(__name__) + +# ============================================================================= +# Configuration Constants +# ============================================================================= + +# Default Grafana URL (can be overridden via settings) +DEFAULT_GRAFANA_URL = "http://192.168.0.188:3000" + +# Dashboard UID validation pattern (alphanumeric, dash, underscore) +DASHBOARD_UID_PATTERN = re.compile(r"^[a-zA-Z0-9_-]+$") + +# Request timeout in seconds +REQUEST_TIMEOUT = 30.0 + + +class GrafanaError(Exception): + """Grafana API 錯誤""" + pass + + +class GrafanaProvider(MCPToolProvider): + """ + Grafana MCP Tool Provider + + 提供 Grafana Dashboard 查詢功能: + 1. 列出所有 Dashboard + 2. 取得 Dashboard 詳細資訊 + 3. 取得 Panel 數據 + 4. 產生 Dashboard URL + + Security Notes: + - 所有操作都是唯讀 + - Dashboard UID 會被驗證格式 + - API Key 透過 Bearer Token 認證 + """ + + def __init__( + self, + grafana_url: str | None = None, + api_key: str | None = None, + ) -> None: + """ + 初始化 Provider + + Args: + grafana_url: Grafana URL (default: from settings) + api_key: Grafana API Key (default: from settings) + """ + self._grafana_url = grafana_url + self._api_key = api_key + self._client: httpx.AsyncClient | None = None + + logger.info( + "grafana_provider_initialized", + grafana_url=self._get_grafana_url(), + ) + + def _get_grafana_url(self) -> str: + """取得 Grafana URL (lazy load from settings)""" + if self._grafana_url: + return self._grafana_url + + try: + from src.core.config import get_settings + settings = get_settings() + return getattr(settings, "GRAFANA_URL", DEFAULT_GRAFANA_URL) + except Exception: + return DEFAULT_GRAFANA_URL + + def _get_api_key(self) -> str | None: + """取得 API Key (lazy load from settings)""" + if self._api_key: + return self._api_key + + try: + from src.core.config import get_settings + settings = get_settings() + return getattr(settings, "GRAFANA_API_KEY", None) + except Exception: + return None + + def _get_headers(self) -> dict[str, str]: + """取得 HTTP Headers (含認證)""" + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + api_key = self._get_api_key() + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + return headers + + async def _get_client(self) -> httpx.AsyncClient: + """取得 HTTP Client (lazy initialization)""" + if self._client is None: + self._client = httpx.AsyncClient( + base_url=self._get_grafana_url(), + headers=self._get_headers(), + timeout=REQUEST_TIMEOUT, + ) + return self._client + + @property + def name(self) -> str: + return "grafana" + + async def list_tools(self) -> list[MCPTool]: + return [ + MCPTool( + name="list_dashboards", + description="List available Grafana dashboards. Returns dashboard titles, UIDs, and folder info.", + input_schema={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query to filter dashboards by title", + }, + "folder_id": { + "type": "integer", + "description": "Filter by folder ID (0 = General folder)", + }, + "tag": { + "type": "string", + "description": "Filter by tag (e.g., 'kubernetes', 'prometheus')", + }, + "limit": { + "type": "integer", + "description": "Max results (default: 100)", + "default": 100, + }, + }, + }, + server_name=self.name, + ), + MCPTool( + name="get_dashboard", + description="Get dashboard details by UID. Returns panels, variables, and annotations.", + input_schema={ + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Dashboard UID (e.g., 'abc123')", + }, + }, + "required": ["uid"], + }, + server_name=self.name, + ), + MCPTool( + name="get_panel_data", + description="Get data from a specific panel in a dashboard. Useful for fetching metrics.", + input_schema={ + "type": "object", + "properties": { + "dashboard_uid": { + "type": "string", + "description": "Dashboard UID", + }, + "panel_id": { + "type": "integer", + "description": "Panel ID within the dashboard", + }, + "from_time": { + "type": "string", + "description": "Start time (e.g., 'now-1h', '2026-03-26T00:00:00Z')", + "default": "now-1h", + }, + "to_time": { + "type": "string", + "description": "End time (e.g., 'now', '2026-03-26T01:00:00Z')", + "default": "now", + }, + }, + "required": ["dashboard_uid", "panel_id"], + }, + server_name=self.name, + ), + MCPTool( + name="generate_dashboard_url", + description="Generate a URL to view a dashboard with optional time range and variables.", + input_schema={ + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "Dashboard UID", + }, + "from_time": { + "type": "string", + "description": "Start time (e.g., 'now-1h', 'now-24h')", + "default": "now-1h", + }, + "to_time": { + "type": "string", + "description": "End time (e.g., 'now')", + "default": "now", + }, + "variables": { + "type": "object", + "description": "Template variables as key-value pairs (e.g., {'namespace': 'awoooi-prod'})", + }, + "panel_id": { + "type": "integer", + "description": "Focus on specific panel (opens panel view)", + }, + }, + "required": ["uid"], + }, + server_name=self.name, + ), + ] + + async def execute( + self, + tool_name: str, + parameters: dict[str, Any], + ) -> MCPToolResult: + execution_id = str(uuid.uuid4())[:8] + + try: + if tool_name == "list_dashboards": + output = await self._list_dashboards(parameters) + elif tool_name == "get_dashboard": + output = await self._get_dashboard(parameters) + elif tool_name == "get_panel_data": + output = await self._get_panel_data(parameters) + elif tool_name == "generate_dashboard_url": + output = await self._generate_dashboard_url(parameters) + else: + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"Unknown tool: {tool_name}", + ) + + logger.info( + "grafana_tool_executed", + tool=tool_name, + execution_id=execution_id, + ) + + return MCPToolResult( + success=True, + execution_id=execution_id, + output=output, + ) + + except GrafanaError as e: + logger.warning( + "grafana_api_error", + tool=tool_name, + execution_id=execution_id, + error=str(e), + ) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"Grafana API error: {e}", + ) + except httpx.HTTPStatusError as e: + logger.warning( + "grafana_http_error", + tool=tool_name, + execution_id=execution_id, + status_code=e.response.status_code, + ) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"HTTP {e.response.status_code}: {e.response.text[:200]}", + ) + except httpx.RequestError as e: + logger.warning( + "grafana_request_error", + tool=tool_name, + execution_id=execution_id, + error=str(e), + ) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=f"Request failed: {e}", + ) + except Exception as e: + logger.exception( + "grafana_provider_error", + tool=tool_name, + execution_id=execution_id, + error=str(e), + ) + return MCPToolResult( + success=False, + execution_id=execution_id, + error=str(e), + ) + + # ========================================================================= + # Validation Methods + # ========================================================================= + + def _validate_dashboard_uid(self, uid: str) -> str: + """ + 驗證 Dashboard UID 格式 + + Args: + uid: Dashboard UID + + Returns: + str: 驗證後的 UID + + Raises: + GrafanaError: UID 格式不正確 + """ + if not uid: + raise GrafanaError("Dashboard UID is required") + + uid = uid.strip() + + if len(uid) > 40: + raise GrafanaError(f"Dashboard UID too long: {len(uid)} chars (max: 40)") + + if not DASHBOARD_UID_PATTERN.match(uid): + raise GrafanaError( + f"Invalid dashboard UID format: {uid}. " + "Only alphanumeric, dash, and underscore allowed." + ) + + return uid + + # ========================================================================= + # Tool Implementations + # ========================================================================= + + async def _list_dashboards(self, parameters: dict) -> dict: + """ + 列出 Dashboard + + Grafana API: GET /api/search + """ + query = parameters.get("query") + folder_id = parameters.get("folder_id") + tag = parameters.get("tag") + limit = parameters.get("limit", 100) + + # 構建查詢參數 + params: dict[str, Any] = { + "type": "dash-db", # 只搜尋 dashboard + "limit": min(limit, 500), # 最多 500 + } + + if query: + params["query"] = query + if folder_id is not None: + params["folderIds"] = folder_id + if tag: + params["tag"] = tag + + # 呼叫 Grafana API + client = await self._get_client() + response = await client.get("/api/search", params=params) + response.raise_for_status() + + dashboards = response.json() + + # 轉換回應格式 + results = [] + for db in dashboards: + results.append({ + "uid": db.get("uid"), + "title": db.get("title"), + "folder_title": db.get("folderTitle", "General"), + "folder_id": db.get("folderId", 0), + "url": db.get("url"), + "tags": db.get("tags", []), + "type": db.get("type"), + }) + + return { + "dashboards": results, + "count": len(results), + "filters": { + "query": query, + "folder_id": folder_id, + "tag": tag, + }, + } + + async def _get_dashboard(self, parameters: dict) -> dict: + """ + 取得 Dashboard 詳細資訊 + + Grafana API: GET /api/dashboards/uid/:uid + """ + uid = parameters.get("uid", "") + uid = self._validate_dashboard_uid(uid) + + # 呼叫 Grafana API + client = await self._get_client() + response = await client.get(f"/api/dashboards/uid/{uid}") + response.raise_for_status() + + data = response.json() + dashboard = data.get("dashboard", {}) + meta = data.get("meta", {}) + + # 提取 Panel 資訊 + panels = [] + for panel in dashboard.get("panels", []): + panels.append({ + "id": panel.get("id"), + "title": panel.get("title"), + "type": panel.get("type"), + "description": panel.get("description"), + "datasource": panel.get("datasource"), + }) + + # 提取變數資訊 + variables = [] + for var in dashboard.get("templating", {}).get("list", []): + variables.append({ + "name": var.get("name"), + "label": var.get("label"), + "type": var.get("type"), + "current_value": var.get("current", {}).get("value"), + }) + + return { + "uid": uid, + "title": dashboard.get("title"), + "description": dashboard.get("description"), + "tags": dashboard.get("tags", []), + "folder": meta.get("folderTitle", "General"), + "created_by": meta.get("createdBy"), + "updated_by": meta.get("updatedBy"), + "version": dashboard.get("version"), + "panels": panels, + "panel_count": len(panels), + "variables": variables, + "url": meta.get("url"), + } + + async def _get_panel_data(self, parameters: dict) -> dict: + """ + 取得 Panel 數據 + + 使用 Grafana Query API 取得 Panel 數據 + Grafana API: POST /api/ds/query + """ + dashboard_uid = parameters.get("dashboard_uid", "") + dashboard_uid = self._validate_dashboard_uid(dashboard_uid) + + panel_id = parameters.get("panel_id") + if panel_id is None: + raise GrafanaError("Panel ID is required") + + from_time = parameters.get("from_time", "now-1h") + to_time = parameters.get("to_time", "now") + + # 先取得 Dashboard 取得 Panel 定義 + client = await self._get_client() + dash_response = await client.get(f"/api/dashboards/uid/{dashboard_uid}") + dash_response.raise_for_status() + + dashboard = dash_response.json().get("dashboard", {}) + panel = None + + # 尋找 Panel + for p in dashboard.get("panels", []): + if p.get("id") == panel_id: + panel = p + break + + if not panel: + raise GrafanaError(f"Panel {panel_id} not found in dashboard {dashboard_uid}") + + # 取得 Panel 基本資訊 + panel_info = { + "dashboard_uid": dashboard_uid, + "panel_id": panel_id, + "panel_title": panel.get("title"), + "panel_type": panel.get("type"), + "from_time": from_time, + "to_time": to_time, + } + + # 取得 Panel 的查詢定義 + targets = panel.get("targets", []) + if not targets: + return { + **panel_info, + "data": None, + "message": "Panel has no query targets", + } + + # 使用 Grafana 的 Query API + # 注意: 這需要 datasource 支援且有權限 + datasource = panel.get("datasource") + if not datasource: + return { + **panel_info, + "data": None, + "message": "Panel has no datasource configured", + "targets": [t.get("expr", t.get("query", "N/A")) for t in targets], + } + + # 嘗試執行查詢 + try: + query_payload = { + "queries": [ + { + **target, + "datasource": datasource, + "refId": target.get("refId", "A"), + } + for target in targets + ], + "from": from_time, + "to": to_time, + } + + query_response = await client.post("/api/ds/query", json=query_payload) + query_response.raise_for_status() + query_data = query_response.json() + + # 解析回應 + results = query_data.get("results", {}) + frames = [] + + for ref_id, result in results.items(): + for frame in result.get("frames", []): + schema = frame.get("schema", {}) + data = frame.get("data", {}) + frames.append({ + "ref_id": ref_id, + "name": schema.get("name"), + "fields": [ + { + "name": f.get("name"), + "type": f.get("type"), + } + for f in schema.get("fields", []) + ], + "row_count": len(data.get("values", [[]])[0]) if data.get("values") else 0, + }) + + return { + **panel_info, + "datasource": datasource, + "frames": frames, + "frame_count": len(frames), + } + + except httpx.HTTPStatusError as e: + # 查詢失敗,回傳 Panel 資訊但無數據 + return { + **panel_info, + "data": None, + "message": f"Query failed: HTTP {e.response.status_code}", + "targets": [t.get("expr", t.get("query", "N/A")) for t in targets], + } + + async def _generate_dashboard_url(self, parameters: dict) -> dict: + """ + 產生 Dashboard URL + + 支援時間範圍、變數、Panel 聚焦 + """ + uid = parameters.get("uid", "") + uid = self._validate_dashboard_uid(uid) + + from_time = parameters.get("from_time", "now-1h") + to_time = parameters.get("to_time", "now") + variables = parameters.get("variables", {}) + panel_id = parameters.get("panel_id") + + # 構建 URL + base_url = self._get_grafana_url() + dashboard_path = f"/d/{uid}" + + # 構建查詢參數 + query_params = [ + f"from={from_time}", + f"to={to_time}", + ] + + # 添加變數 + for key, value in variables.items(): + # Grafana 變數格式: var-= + query_params.append(f"var-{key}={value}") + + # 添加 Panel 聚焦 + if panel_id is not None: + query_params.append(f"viewPanel={panel_id}") + + # 組合 URL + query_string = "&".join(query_params) + full_url = urljoin(base_url, f"{dashboard_path}?{query_string}") + + return { + "uid": uid, + "url": full_url, + "from_time": from_time, + "to_time": to_time, + "variables": variables, + "panel_id": panel_id, + } + + async def health_check(self) -> bool: + """ + 健康檢查 + + 驗證 Grafana API 可連線 + """ + try: + client = await self._get_client() + response = await client.get("/api/health") + return response.status_code == 200 + except Exception as e: + logger.warning("grafana_health_check_failed", error=str(e)) + return False + + async def close(self) -> None: + """關閉 HTTP Client""" + if self._client: + await self._client.aclose() + self._client = None