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