Coverage for mcpgateway/version.py: 26%

126 statements  

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

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 

19from __future__ import annotations 

20 

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 

30 

31from fastapi import APIRouter, Depends, Request 

32from fastapi.responses import HTMLResponse, JSONResponse, Response 

33from sqlalchemy import text 

34 

35from mcpgateway.config import settings 

36from mcpgateway.db import engine 

37from mcpgateway.utils.verify_credentials import require_auth 

38 

39# Optional runtime dependencies 

40try: 

41 import psutil # optional for enhanced metrics 

42except ImportError: 

43 psutil = None # type: ignore 

44 

45try: 

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

47 

48 REDIS_AVAILABLE = True 

49except ImportError: 

50 aioredis = None # type: ignore 

51 REDIS_AVAILABLE = False 

52 

53# Globals 

54 

55START_TIME = time.time() 

56HOSTNAME = socket.gethostname() 

57LOGIN_PATH = "/login" 

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

59 

60 

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

62 """ 

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

64 

65 Parameters: 

66 key (str): The environment variable name. 

67 

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

72 

73 

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

75 """ 

76 Collect environment variables excluding those that look secret. 

77 

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

82 

83 

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

85 """ 

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

87 

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 

102 

103 

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

105 """ 

106 Redact credentials from a URL for safe display. 

107 

108 Parameters: 

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

110 

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) 

121 

122 

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

124 """ 

125 Query the database server version. 

126 

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 

143 

144 

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. 

149 

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

176 

177 # System memory and swap 

178 vm = psutil.virtual_memory() 

179 swap = psutil.swap_memory() 

180 

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) 

186 

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) 

191 

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 

203 

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) 

209 

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 } 

231 

232 

233def _build_payload( 

234 redis_version: Optional[str], 

235 redis_ok: bool, 

236) -> Dict[str, Any]: 

237 """ 

238 Build the complete diagnostics payload. 

239 

240 Parameters: 

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

242 redis_ok (bool): Whether Redis is reachable. 

243 

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 } 

283 

284 

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

286 """ 

287 Render a dict as an HTML table. 

288 

289 Parameters: 

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

291 

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

297 

298 

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

300 """ 

301 Render the full diagnostics payload as HTML. 

302 

303 Parameters: 

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

305 

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

330 

331 

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

333 """ 

334 Render the login form HTML for unauthenticated browsers. 

335 

336 Parameters: 

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

338 

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

360 

361 

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

372 

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. 

377 

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) 

393 

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 

398 

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)