Coverage for mcpgateway/version.py: 80%

126 statements  

« 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) 

3 

4Copyright 2025 

5SPDX-License-Identifier: Apache-2.0 

6Authors: Mihai Criveti 

7 

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 

11 

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""" 

18 

19# Future 

20from __future__ import annotations 

21 

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 

32 

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 

38 

39# First-Party 

40from mcpgateway.config import settings 

41from mcpgateway.db import engine 

42from mcpgateway.utils.verify_credentials import require_auth 

43 

44# Optional runtime dependencies 

45try: 

46 # Third-Party 

47 import psutil # optional for enhanced metrics 

48except ImportError: 

49 psutil = None # type: ignore 

50 

51try: 

52 # Third-Party 

53 import redis.asyncio as aioredis # optional Redis health check 

54 

55 REDIS_AVAILABLE = True 

56except ImportError: 

57 aioredis = None # type: ignore 

58 REDIS_AVAILABLE = False 

59 

60# Globals 

61 

62START_TIME = time.time() 

63HOSTNAME = socket.gethostname() 

64LOGIN_PATH = "/login" 

65router = APIRouter(tags=["meta"]) 

66 

67 

68def _is_secret(key: str) -> bool: 

69 """ 

70 Identify if an environment variable key likely represents a secret. 

71 

72 Parameters: 

73 key (str): The environment variable name. 

74 

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")) 

79 

80 

81def _public_env() -> Dict[str, str]: 

82 """ 

83 Collect environment variables excluding those that look secret. 

84 

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)} 

89 

90 

91def _git_revision() -> Optional[str]: 

92 """ 

93 Retrieve the current Git revision (short) if available. 

94 

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 

109 

110 

111def _sanitize_url(url: Optional[str]) -> Optional[str]: 

112 """ 

113 Redact credentials from a URL for safe display. 

114 

115 Parameters: 

116 url (Optional[str]): The URL to sanitize. 

117 

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) 

128 

129 

130def _database_version() -> tuple[str, bool]: 

131 """ 

132 Query the database server version. 

133 

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 

150 

151 

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. 

156 

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 {} 

183 

184 # System memory and swap 

185 vm = psutil.virtual_memory() 

186 swap = psutil.swap_memory() 

187 

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) 

193 

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) 

198 

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 

210 

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) 

216 

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 } 

238 

239 

240def _build_payload( 

241 redis_version: Optional[str], 

242 redis_ok: bool, 

243) -> Dict[str, Any]: 

244 """ 

245 Build the complete diagnostics payload. 

246 

247 Parameters: 

248 redis_version (Optional[str]): Version or error for Redis. 

249 redis_ok (bool): Whether Redis is reachable. 

250 

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 } 

290 

291 

292def _html_table(obj: Dict[str, Any]) -> str: 

293 """ 

294 Render a dict as an HTML table. 

295 

296 Parameters: 

297 obj (Dict[str, Any]): The data to render. 

298 

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>" 

304 

305 

306def _render_html(payload: Dict[str, Any]) -> str: 

307 """ 

308 Render the full diagnostics payload as HTML. 

309 

310 Parameters: 

311 payload (Dict[str, Any]): The diagnostics data. 

312 

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>" 

337 

338 

339def _login_html(next_url: str) -> str: 

340 """ 

341 Render the login form HTML for unauthenticated browsers. 

342 

343 Parameters: 

344 next_url (str): The URL to return to after login. 

345 

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>""" 

367 

368 

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). 

379 

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. 

384 

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) 

400 

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)