Coverage for mcpgateway/version.py: 26%
126 statements
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-22 16:17 +0100
« prev ^ index » next coverage.py v7.9.1, created at 2025-06-22 16:17 +0100
1# -*- coding: utf-8 -*-
2"""version.py - diagnostics endpoint (HTML + JSON)
4Copyright 2025
5SPDX-License-Identifier: Apache-2.0
6Authors: Mihai Criveti
8A FastAPI router that mounts at /version and returns either:
9- JSON - machine-readable diagnostics payload
10- HTML - a lightweight dashboard when the client requests text/html or ?format=html
12Features:
13- Cross-platform system metrics (Windows/macOS/Linux), with fallbacks where APIs are unavailable
14- Optional dependencies: psutil (for richer metrics) and redis.asyncio (for Redis health); omitted gracefully if absent
15- Authentication enforcement via `require_auth`; unauthenticated browsers see login form, API clients get JSON 401
16- Redacted environment variables, sanitized DB/Redis URLs, Git commit detection
17"""
19from __future__ import annotations
21import json
22import os
23import platform
24import socket
25import subprocess
26import time
27from datetime import datetime
28from typing import Any, Dict, Optional
29from urllib.parse import urlsplit, urlunsplit
31from fastapi import APIRouter, Depends, Request
32from fastapi.responses import HTMLResponse, JSONResponse, Response
33from sqlalchemy import text
35from mcpgateway.config import settings
36from mcpgateway.db import engine
37from mcpgateway.utils.verify_credentials import require_auth
39# Optional runtime dependencies
40try:
41 import psutil # optional for enhanced metrics
42except ImportError:
43 psutil = None # type: ignore
45try:
46 import redis.asyncio as aioredis # optional Redis health check
48 REDIS_AVAILABLE = True
49except ImportError:
50 aioredis = None # type: ignore
51 REDIS_AVAILABLE = False
53# Globals
55START_TIME = time.time()
56HOSTNAME = socket.gethostname()
57LOGIN_PATH = "/login"
58router = APIRouter(tags=["meta"])
61def _is_secret(key: str) -> bool:
62 """
63 Identify if an environment variable key likely represents a secret.
65 Parameters:
66 key (str): The environment variable name.
68 Returns:
69 bool: True if the key contains secret-looking keywords, False otherwise.
70 """
71 return any(tok in key.upper() for tok in ("SECRET", "TOKEN", "PASS", "KEY"))
74def _public_env() -> Dict[str, str]:
75 """
76 Collect environment variables excluding those that look secret.
78 Returns:
79 Dict[str, str]: A map of environment variable names to values.
80 """
81 return {k: v for k, v in os.environ.items() if not _is_secret(k)}
84def _git_revision() -> Optional[str]:
85 """
86 Retrieve the current Git revision (short) if available.
88 Returns:
89 Optional[str]: The Git commit hash prefix or None if unavailable.
90 """
91 rev = os.getenv("GIT_COMMIT")
92 if rev:
93 return rev[:9]
94 try:
95 out = subprocess.check_output(
96 ["git", "rev-parse", "--short", "HEAD"],
97 stderr=subprocess.DEVNULL,
98 )
99 return out.decode().strip()
100 except Exception:
101 return None
104def _sanitize_url(url: Optional[str]) -> Optional[str]:
105 """
106 Redact credentials from a URL for safe display.
108 Parameters:
109 url (Optional[str]): The URL to sanitize.
111 Returns:
112 Optional[str]: The sanitized URL or None.
113 """
114 if not url:
115 return None
116 parts = urlsplit(url)
117 if parts.password:
118 netloc = f"{parts.username}@{parts.hostname}{':' + str(parts.port) if parts.port else ''}"
119 parts = parts._replace(netloc=netloc)
120 return urlunsplit(parts)
123def _database_version() -> tuple[str, bool]:
124 """
125 Query the database server version.
127 Returns:
128 tuple[str, bool]: (version string or error message, reachable flag).
129 """
130 dialect = engine.dialect.name
131 stmts = {
132 "sqlite": "SELECT sqlite_version();",
133 "postgresql": "SELECT current_setting('server_version');",
134 "mysql": "SELECT version();",
135 }
136 stmt = stmts.get(dialect, "SELECT version();")
137 try:
138 with engine.connect() as conn:
139 ver = conn.execute(text(stmt)).scalar()
140 return str(ver), True
141 except Exception as exc:
142 return str(exc), False
145def _system_metrics() -> Dict[str, Any]:
146 """
147 Gather system-wide and per-process metrics using psutil, falling back gracefully
148 if psutil is not installed or certain APIs are unavailable.
150 Returns:
151 Dict[str, Any]: A dictionary containing:
152 - boot_time (str): ISO-formatted system boot time.
153 - cpu_percent (float): Total CPU utilization percentage.
154 - cpu_count (int): Number of logical CPU cores.
155 - cpu_freq_mhz (int | None): Current CPU frequency in MHz, or None if unavailable.
156 - load_avg (tuple[float | None, float | None, float | None]):
157 System load average over 1, 5, and 15 minutes, or (None, None, None)
158 on platforms without getloadavg.
159 - mem_total_mb (int): Total physical memory in megabytes.
160 - mem_used_mb (int): Used physical memory in megabytes.
161 - swap_total_mb (int): Total swap memory in megabytes.
162 - swap_used_mb (int): Used swap memory in megabytes.
163 - disk_total_gb (float): Total size of the root disk partition in gigabytes.
164 - disk_used_gb (float): Used space of the root disk partition in gigabytes.
165 - process (Dict[str, Any]): A nested dict with per-process metrics:
166 * pid (int): Current process ID.
167 * threads (int): Number of active threads.
168 * rss_mb (float): Resident Set Size memory usage in megabytes.
169 * vms_mb (float): Virtual Memory Size usage in megabytes.
170 * open_fds (int | None): Number of open file descriptors, or None if unsupported.
171 * proc_cpu_percent (float): CPU utilization percentage for this process.
172 {}: Empty dict if psutil is not installed.
173 """
174 if not psutil:
175 return {}
177 # System memory and swap
178 vm = psutil.virtual_memory()
179 swap = psutil.swap_memory()
181 # Load average (Unix); on Windows returns (None, None, None)
182 try:
183 load = tuple(round(x, 2) for x in os.getloadavg())
184 except (AttributeError, OSError):
185 load = (None, None, None)
187 # CPU metrics
188 freq = psutil.cpu_freq()
189 cpu_pct = psutil.cpu_percent(interval=0.3)
190 cpu_count = psutil.cpu_count(logical=True)
192 # Process metrics
193 proc = psutil.Process()
194 try:
195 open_fds = proc.num_fds()
196 except Exception:
197 open_fds = None
198 proc_cpu_pct = proc.cpu_percent(interval=0.1)
199 rss_mb = round(proc.memory_info().rss / 1_048_576, 2)
200 vms_mb = round(proc.memory_info().vms / 1_048_576, 2)
201 threads = proc.num_threads()
202 pid = proc.pid
204 # Disk usage for root partition (ensure str on Windows)
205 root = os.getenv("SystemDrive", "C:\\") if os.name == "nt" else "/"
206 disk = psutil.disk_usage(str(root))
207 disk_total_gb = round(disk.total / 1_073_741_824, 2)
208 disk_used_gb = round(disk.used / 1_073_741_824, 2)
210 return {
211 "boot_time": datetime.fromtimestamp(psutil.boot_time()).isoformat(),
212 "cpu_percent": cpu_pct,
213 "cpu_count": cpu_count,
214 "cpu_freq_mhz": round(freq.current) if freq else None,
215 "load_avg": load,
216 "mem_total_mb": round(vm.total / 1_048_576),
217 "mem_used_mb": round(vm.used / 1_048_576),
218 "swap_total_mb": round(swap.total / 1_048_576),
219 "swap_used_mb": round(swap.used / 1_048_576),
220 "disk_total_gb": disk_total_gb,
221 "disk_used_gb": disk_used_gb,
222 "process": {
223 "pid": pid,
224 "threads": threads,
225 "rss_mb": rss_mb,
226 "vms_mb": vms_mb,
227 "open_fds": open_fds,
228 "proc_cpu_percent": proc_cpu_pct,
229 },
230 }
233def _build_payload(
234 redis_version: Optional[str],
235 redis_ok: bool,
236) -> Dict[str, Any]:
237 """
238 Build the complete diagnostics payload.
240 Parameters:
241 redis_version (Optional[str]): Version or error for Redis.
242 redis_ok (bool): Whether Redis is reachable.
244 Returns:
245 Dict[str, Any]: Structured diagnostics data.
246 """
247 db_ver, db_ok = _database_version()
248 return {
249 "timestamp": datetime.utcnow().isoformat() + "Z",
250 "host": HOSTNAME,
251 "uptime_seconds": int(time.time() - START_TIME),
252 "app": {
253 "name": settings.app_name,
254 "mcp_protocol_version": settings.protocol_version,
255 "git_revision": _git_revision(),
256 },
257 "platform": {
258 "python": platform.python_version(),
259 "fastapi": __import__("fastapi").__version__,
260 "sqlalchemy": __import__("sqlalchemy").__version__,
261 "os": f"{platform.system()} {platform.release()} ({platform.machine()})",
262 },
263 "database": {
264 "dialect": engine.dialect.name,
265 "url": _sanitize_url(settings.database_url),
266 "reachable": db_ok,
267 "server_version": db_ver,
268 },
269 "redis": {
270 "available": REDIS_AVAILABLE,
271 "url": _sanitize_url(settings.redis_url),
272 "reachable": redis_ok,
273 "server_version": redis_version,
274 },
275 "settings": {
276 "cache_type": settings.cache_type,
277 "mcpgateway_ui_enabled": getattr(settings, "mcpgateway_ui_enabled", None),
278 "mcpgateway_admin_api_enabled": getattr(settings, "mcpgateway_admin_api_enabled", None),
279 },
280 "env": _public_env(),
281 "system": _system_metrics(),
282 }
285def _html_table(obj: Dict[str, Any]) -> str:
286 """
287 Render a dict as an HTML table.
289 Parameters:
290 obj (Dict[str, Any]): The data to render.
292 Returns:
293 str: HTML table markup.
294 """
295 rows = "".join(f"<tr><th>{k}</th><td>{json.dumps(v, default=str) if not isinstance(v, str) else v}</td></tr>" for k, v in obj.items())
296 return f"<table>{rows}</table>"
299def _render_html(payload: Dict[str, Any]) -> str:
300 """
301 Render the full diagnostics payload as HTML.
303 Parameters:
304 payload (Dict[str, Any]): The diagnostics data.
306 Returns:
307 str: Complete HTML page.
308 """
309 style = (
310 "<style>"
311 "body{font-family:system-ui,sans-serif;margin:2rem;}"
312 "table{border-collapse:collapse;width:100%;margin-bottom:1rem;}"
313 "th,td{border:1px solid #ccc;padding:.5rem;text-align:left;}"
314 "th{background:#f7f7f7;width:25%;}"
315 "</style>"
316 )
317 header = f"<h1>MCP Gateway diagnostics</h1><p>Generated {payload['timestamp']} • Host {payload['host']} • Uptime {payload['uptime_seconds']}s</p>"
318 sections = ""
319 for title, key in (
320 ("App", "app"),
321 ("Platform", "platform"),
322 ("Database", "database"),
323 ("Redis", "redis"),
324 ("Settings", "settings"),
325 ("System", "system"),
326 ):
327 sections += f"<h2>{title}</h2>{_html_table(payload[key])}"
328 env_section = f"<h2>Environment</h2>{_html_table(payload['env'])}"
329 return f"<!doctype html><html><head><meta charset='utf-8'>{style}</head><body>{header}{sections}{env_section}</body></html>"
332def _login_html(next_url: str) -> str:
333 """
334 Render the login form HTML for unauthenticated browsers.
336 Parameters:
337 next_url (str): The URL to return to after login.
339 Returns:
340 str: HTML of the login page.
341 """
342 return f"""<!doctype html>
343<html><head><meta charset='utf-8'><title>Login - MCP Gateway</title>
344<style>
345body{{font-family:system-ui,sans-serif;margin:2rem;}}
346form{{max-width:320px;margin:auto;}}
347label{{display:block;margin:.5rem 0;}}
348input{{width:100%;padding:.5rem;}}
349button{{margin-top:1rem;padding:.5rem 1rem;}}
350</style></head>
351<body>
352 <h2>Please log in</h2>
353 <form action="{LOGIN_PATH}" method="post">
354 <input type="hidden" name="next" value="{next_url}">
355 <label>Username<input type="text" name="username" autocomplete="username"></label>
356 <label>Password<input type="password" name="password" autocomplete="current-password"></label>
357 <button type="submit">Login</button>
358 </form>
359</body></html>"""
362# Endpoint
363@router.get("/version", summary="Diagnostics (auth required)")
364async def version_endpoint(
365 request: Request,
366 fmt: Optional[str] = None,
367 partial: Optional[bool] = False,
368 _user=Depends(require_auth),
369) -> Response:
370 """
371 Serve diagnostics as JSON, full HTML, or partial HTML (if requested).
373 Parameters:
374 request (Request): The incoming HTTP request.
375 fmt (Optional[str]): Query param 'html' for full HTML output.
376 partial (Optional[bool]): Query param to request partial HTML fragment.
378 Returns:
379 Response: JSONResponse or HTMLResponse with diagnostics data.
380 """
381 # Redis health check
382 redis_ok = False
383 redis_version: Optional[str] = None
384 if REDIS_AVAILABLE and settings.cache_type.lower() == "redis" and settings.redis_url:
385 try:
386 client = aioredis.Redis.from_url(settings.redis_url)
387 await client.ping()
388 info = await client.info()
389 redis_version = info.get("redis_version")
390 redis_ok = True
391 except Exception as exc:
392 redis_version = str(exc)
394 payload = _build_payload(redis_version, redis_ok)
395 if partial:
396 # Return partial HTML fragment for HTMX embedding
397 from fastapi.templating import Jinja2Templates
399 templates = Jinja2Templates(directory=str(settings.templates_dir))
400 return templates.TemplateResponse("version_info_partial.html", {"request": request, "payload": payload})
401 wants_html = fmt == "html" or "text/html" in request.headers.get("accept", "")
402 if wants_html:
403 return HTMLResponse(_render_html(payload))
404 return JSONResponse(payload)