server-management-lib 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,636 @@
1
+ """
2
+ HTTP Clients for InfluxDB and Prometheus
3
+
4
+ Provides read-only query clients for both time-series databases
5
+ with built-in security validation.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ import re
12
+ from time import monotonic
13
+
14
+ import aiohttp
15
+ import sqlparse
16
+ from sqlparse import tokens as sql_tokens
17
+
18
+ from .audit import AuditLogger
19
+ from .output import DEFAULT_MAX_OUTPUT_BYTES, sanitize_output
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Defense-in-depth: whitelist the InfluxDB endpoints so even if
24
+ # the path construction changes, requests to admin/write endpoints are blocked.
25
+ INFLUXDB_ALLOWED_ENDPOINTS: frozenset[str] = frozenset(
26
+ {
27
+ "/api/v3/query_sql",
28
+ "/api/v3/query_influxql",
29
+ "/health",
30
+ "/metrics",
31
+ "/ping",
32
+ }
33
+ )
34
+
35
+
36
+ class InfluxDBClient:
37
+ """Client for InfluxDB v3 HTTP API (read-only)."""
38
+
39
+ def __init__(
40
+ self,
41
+ host: str = "localhost",
42
+ port: int = 8181,
43
+ use_https: bool = False,
44
+ database: str | None = None,
45
+ token: str | None = None,
46
+ query_limit: int = 1000,
47
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
48
+ timeout: float = 30,
49
+ audit: AuditLogger | None = None,
50
+ ):
51
+ self.host = host
52
+ self.port = port
53
+ self.use_https = use_https
54
+ self.database = database
55
+ self.token = token
56
+ self.query_limit = min(query_limit, 10000)
57
+ self.max_output_bytes = max_output_bytes
58
+ self.timeout = timeout
59
+ self.audit = audit
60
+ self.scheme = "https" if use_https else "http"
61
+ self._session: aiohttp.ClientSession | None = None
62
+
63
+ def _get_session(self) -> aiohttp.ClientSession:
64
+ """Return the client's shared HTTP session, creating it on demand."""
65
+ if self._session is None:
66
+ self._session = aiohttp.ClientSession()
67
+ return self._session
68
+
69
+ async def close(self) -> None:
70
+ """Close the shared HTTP session (idempotent)."""
71
+ session = self._session
72
+ self._session = None
73
+ if session is not None:
74
+ await session.close()
75
+
76
+ def _log_audit(
77
+ self,
78
+ query: str,
79
+ database: str | None,
80
+ status: str | int,
81
+ started: float,
82
+ *,
83
+ error: str | None = None,
84
+ ) -> None:
85
+ """Record query metadata without storing the query text.
86
+
87
+ SQL and PromQL may contain literal values that the model should not
88
+ persist in a local log. Length plus SHA-256 keeps queries correlatable
89
+ without copying their contents.
90
+ """
91
+ if self.audit is None:
92
+ return
93
+ fields: dict[str, object] = {
94
+ "database": database,
95
+ "status": status,
96
+ "duration_ms": round((monotonic() - started) * 1000, 1),
97
+ "query_length": len(query),
98
+ "query_sha256": hashlib.sha256(query.encode("utf-8")).hexdigest(),
99
+ }
100
+ if error is not None:
101
+ fields["error"] = error
102
+ self.audit.log("influxdb_query", **fields)
103
+
104
+ async def query(self, sql_query: str, database: str | None = None) -> str:
105
+ """
106
+ Execute a SELECT query against the InfluxDB v3 HTTP API.
107
+
108
+ Args:
109
+ sql_query: SQL query (must start with SELECT)
110
+ database: Database name (overrides constructor default)
111
+
112
+ Returns:
113
+ Query results in JSON format
114
+ """
115
+ started = monotonic()
116
+ db = database or self.database
117
+ if not db:
118
+ self._log_audit(sql_query, db, "no_database", started)
119
+ return (
120
+ "❌ No database specified. Provide 'database' parameter or set "
121
+ "'influxdb.database' in config.yaml."
122
+ )
123
+
124
+ path = "/api/v3/query_sql"
125
+ url = f"{self.scheme}://{self.host}:{self.port}{path}"
126
+
127
+ # InfluxDB v3 has no server-side row limit parameter; enforce it in SQL.
128
+ sql_query = _ensure_limit(sql_query, self.query_limit)
129
+
130
+ if path not in INFLUXDB_ALLOWED_ENDPOINTS:
131
+ return f"❌ Blocked: endpoint '{path}' is not in the allowed whitelist"
132
+
133
+ params = {
134
+ "db": db,
135
+ "q": sql_query,
136
+ }
137
+
138
+ headers: dict[str, str] = {"Content-Type": "application/json"}
139
+ if self.token:
140
+ headers["Authorization"] = f"Bearer {self.token}"
141
+
142
+ try:
143
+ session = self._get_session()
144
+ async with session.post(
145
+ url,
146
+ json=params,
147
+ headers=headers,
148
+ timeout=self.timeout,
149
+ ) as response:
150
+ if response.status == 200:
151
+ try:
152
+ data = await response.json()
153
+ except (aiohttp.ContentTypeError, ValueError):
154
+ body = await response.text()
155
+ if _looks_like_html(response, body):
156
+ self._log_audit(
157
+ sql_query,
158
+ db,
159
+ response.status,
160
+ started,
161
+ error="html_response",
162
+ )
163
+ return _html_response_message(
164
+ "InfluxDB", url, response.status, body
165
+ )
166
+ self._log_audit(
167
+ sql_query,
168
+ db,
169
+ response.status,
170
+ started,
171
+ error="json_decode_error",
172
+ )
173
+ return _json_decode_message(
174
+ "InfluxDB", url, response.status, body
175
+ )
176
+ formatted = json.dumps(data, indent=2)
177
+ formatted = sanitize_output(
178
+ formatted, self.max_output_bytes, label="InfluxDB result"
179
+ )
180
+ self._log_audit(sql_query, db, response.status, started)
181
+ return f"✅ Query successful\n\n{formatted}"
182
+ else:
183
+ body = await response.text()
184
+ if _looks_like_html(response, body):
185
+ self._log_audit(
186
+ sql_query,
187
+ db,
188
+ response.status,
189
+ started,
190
+ error="html_response",
191
+ )
192
+ return _html_response_message(
193
+ "InfluxDB", url, response.status, body
194
+ )
195
+
196
+ error_body = sanitize_output(
197
+ body,
198
+ self.max_output_bytes,
199
+ label="InfluxDB error",
200
+ )
201
+
202
+ hint = ""
203
+ if _should_show_scan_hint(sql_query, error_body):
204
+ hint = (
205
+ "💡 Tip: InfluxDB 3 Core has a file-scan limit. "
206
+ "Queries without a `WHERE time` clause may fail "
207
+ "with HTTP 500. Add a time range like:\n"
208
+ " WHERE time > now() - INTERVAL '1 hour'\n\n"
209
+ )
210
+
211
+ self._log_audit(sql_query, db, response.status, started)
212
+ return (
213
+ f"{hint}"
214
+ f"❌ InfluxDB query failed (HTTP {response.status})\n"
215
+ f"{error_body}"
216
+ )
217
+ except aiohttp.ClientError as e:
218
+ self._log_audit(
219
+ sql_query, db, "connection_error", started, error=type(e).__name__
220
+ )
221
+ return f"❌ Connection error: {e}"
222
+ except TimeoutError:
223
+ self._log_audit(sql_query, db, "timeout", started)
224
+ return f"❌ Query timed out after {self.timeout} seconds"
225
+
226
+
227
+ class PrometheusClient:
228
+ """Client for Prometheus HTTP API (read-only)."""
229
+
230
+ def __init__(
231
+ self,
232
+ host: str = "localhost",
233
+ port: int = 9090,
234
+ use_https: bool = False,
235
+ token: str | None = None,
236
+ max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES,
237
+ timeout: float = 30,
238
+ audit: AuditLogger | None = None,
239
+ ):
240
+ self.host = host
241
+ self.port = port
242
+ self.use_https = use_https
243
+ self.token = token
244
+ self.max_output_bytes = max_output_bytes
245
+ self.timeout = timeout
246
+ self.audit = audit
247
+ self.scheme = "https" if use_https else "http"
248
+ self._session: aiohttp.ClientSession | None = None
249
+
250
+ def _get_session(self) -> aiohttp.ClientSession:
251
+ """Return the client's shared HTTP session, creating it on demand."""
252
+ if self._session is None:
253
+ self._session = aiohttp.ClientSession()
254
+ return self._session
255
+
256
+ async def close(self) -> None:
257
+ """Close the shared HTTP session (idempotent)."""
258
+ session = self._session
259
+ self._session = None
260
+ if session is not None:
261
+ await session.close()
262
+
263
+ def _log_audit(
264
+ self,
265
+ query: str,
266
+ status: str | int,
267
+ started: float,
268
+ *,
269
+ error: str | None = None,
270
+ ) -> None:
271
+ """Record query metadata without storing the PromQL text."""
272
+ if self.audit is None:
273
+ return
274
+ fields: dict[str, object] = {
275
+ "status": status,
276
+ "duration_ms": round((monotonic() - started) * 1000, 1),
277
+ "query_length": len(query),
278
+ "query_sha256": hashlib.sha256(query.encode("utf-8")).hexdigest(),
279
+ }
280
+ if error is not None:
281
+ fields["error"] = error
282
+ self.audit.log("prometheus_query", **fields)
283
+
284
+ async def query(self, promql: str, time: str | None = None) -> str:
285
+ """
286
+ Execute an instant query against the Prometheus HTTP API.
287
+
288
+ Args:
289
+ promql: PromQL expression
290
+ time: Optional RFC3339 timestamp or Unix timestamp (defaults to now)
291
+
292
+ Returns:
293
+ Query results in JSON format
294
+ """
295
+ started = monotonic()
296
+ url = f"{self.scheme}://{self.host}:{self.port}/api/v1/query"
297
+
298
+ params: dict[str, str] = {"query": promql}
299
+ if time:
300
+ params["time"] = time
301
+
302
+ headers: dict[str, str] = {}
303
+ if self.token:
304
+ headers["Authorization"] = f"Bearer {self.token}"
305
+
306
+ try:
307
+ session = self._get_session()
308
+ async with session.get(
309
+ url,
310
+ params=params,
311
+ headers=headers,
312
+ timeout=self.timeout,
313
+ ) as response:
314
+ if response.status == 200:
315
+ try:
316
+ data = await response.json()
317
+ except (aiohttp.ContentTypeError, ValueError):
318
+ body = await response.text()
319
+ if _looks_like_html(response, body):
320
+ self._log_audit(
321
+ promql, response.status, started, error="html_response"
322
+ )
323
+ return _html_response_message(
324
+ "Prometheus", url, response.status, body
325
+ )
326
+ self._log_audit(
327
+ promql,
328
+ response.status,
329
+ started,
330
+ error="json_decode_error",
331
+ )
332
+ return _json_decode_message(
333
+ "Prometheus", url, response.status, body
334
+ )
335
+ if data.get("status") == "success":
336
+ formatted = json.dumps(data["data"], indent=2)
337
+ formatted = sanitize_output(
338
+ formatted,
339
+ self.max_output_bytes,
340
+ label="Prometheus result",
341
+ )
342
+ self._log_audit(promql, response.status, started)
343
+ return f"✅ Query successful\n\n{formatted}"
344
+ else:
345
+ error_type = data.get("errorType", "unknown")
346
+ error_msg = data.get("error", "Unknown error")
347
+ self._log_audit(promql, f"promql_error:{error_type}", started)
348
+ return (
349
+ f"❌ PromQL query error\n"
350
+ f"Type: {error_type}\n"
351
+ f"Error: {error_msg}"
352
+ )
353
+ else:
354
+ body = await response.text()
355
+ if _looks_like_html(response, body):
356
+ self._log_audit(
357
+ promql, response.status, started, error="html_response"
358
+ )
359
+ return _html_response_message(
360
+ "Prometheus", url, response.status, body
361
+ )
362
+ error_body = sanitize_output(
363
+ body,
364
+ self.max_output_bytes,
365
+ label="Prometheus error",
366
+ )
367
+ self._log_audit(promql, response.status, started)
368
+ return (
369
+ f"❌ Prometheus query failed (HTTP {response.status})\n"
370
+ f"{error_body}"
371
+ )
372
+ except aiohttp.ClientError as e:
373
+ self._log_audit(promql, "connection_error", started, error=type(e).__name__)
374
+ return f"❌ Connection error: {e}"
375
+ except TimeoutError:
376
+ self._log_audit(promql, "timeout", started)
377
+ return f"❌ Query timed out after {self.timeout} seconds"
378
+
379
+ def _log_targets(
380
+ self,
381
+ status: str | int,
382
+ started: float,
383
+ *,
384
+ error: str | None = None,
385
+ ) -> None:
386
+ """Record a targets request without storing response bodies."""
387
+ if self.audit is None:
388
+ return
389
+ fields: dict[str, object] = {
390
+ "status": status,
391
+ "duration_ms": round((monotonic() - started) * 1000, 1),
392
+ }
393
+ if error is not None:
394
+ fields["error"] = error
395
+ self.audit.log("prometheus_targets", **fields)
396
+
397
+ async def get_targets(self) -> str:
398
+ """Get scrape targets from Prometheus."""
399
+ started = monotonic()
400
+ url = f"{self.scheme}://{self.host}:{self.port}/api/v1/targets"
401
+
402
+ headers: dict[str, str] = {}
403
+ if self.token:
404
+ headers["Authorization"] = f"Bearer {self.token}"
405
+
406
+ try:
407
+ session = self._get_session()
408
+ async with session.get(
409
+ url, headers=headers, timeout=self.timeout
410
+ ) as response:
411
+ if response.status == 200:
412
+ try:
413
+ data = await response.json()
414
+ except (aiohttp.ContentTypeError, ValueError):
415
+ body = await response.text()
416
+ if _looks_like_html(response, body):
417
+ self._log_targets(
418
+ response.status, started, error="html_response"
419
+ )
420
+ return _html_response_message(
421
+ "Prometheus", url, response.status, body
422
+ )
423
+ self._log_targets(
424
+ response.status, started, error="json_decode_error"
425
+ )
426
+ return _json_decode_message(
427
+ "Prometheus", url, response.status, body
428
+ )
429
+ if data.get("status") == "success":
430
+ targets = data.get("data", {}).get("activeTargets", [])
431
+ if not targets:
432
+ self._log_targets(response.status, started)
433
+ return "No active targets configured in Prometheus."
434
+
435
+ lines = [f"✅ Found {len(targets)} active targets:\n"]
436
+ for target in targets:
437
+ labels = target.get("labels", {})
438
+ job = labels.get("job", "unknown")
439
+ instance = labels.get("instance", "unknown")
440
+ health = target.get("health", "unknown")
441
+ last_error = target.get("lastError", "")
442
+ lines.append(
443
+ f" • {job}/{instance}: {health}"
444
+ + (f" ({last_error})" if last_error else "")
445
+ )
446
+ self._log_targets(response.status, started)
447
+ return "\n".join(lines)
448
+
449
+ self._log_targets("unexpected_response", started)
450
+ return f"❌ Unexpected response: {json.dumps(data, indent=2)}"
451
+
452
+ body = await response.text()
453
+ if _looks_like_html(response, body):
454
+ self._log_targets(response.status, started, error="html_response")
455
+ return _html_response_message(
456
+ "Prometheus", url, response.status, body
457
+ )
458
+ error_body = sanitize_output(
459
+ body,
460
+ self.max_output_bytes,
461
+ label="Prometheus error",
462
+ )
463
+ self._log_targets(response.status, started)
464
+ return (
465
+ f"❌ Failed to get targets (HTTP {response.status})\n{error_body}"
466
+ )
467
+ except aiohttp.ClientError as e:
468
+ self._log_targets("connection_error", started, error=type(e).__name__)
469
+ return f"❌ Connection error: {e}"
470
+ except TimeoutError:
471
+ self._log_targets("timeout", started)
472
+ return f"❌ Request timed out after {self.timeout} seconds"
473
+
474
+
475
+ # An error page is diagnostic context, not a query result: keep the excerpt
476
+ # short so a themed proxy page cannot flood the model context.
477
+ _HTML_EXCERPT_BYTES = 400
478
+
479
+ _HTML_START_PATTERN = re.compile(
480
+ r"^\s*<(?:!doctype\s+html|html|head|body)\b", re.IGNORECASE
481
+ )
482
+
483
+ _SCAN_LIMIT_PATTERN = re.compile(r"(?:file[ -]scan|scan[ -]limit)", re.IGNORECASE)
484
+
485
+ _TIME_PREDICATE_PATTERN = re.compile(r"\btime\b", re.IGNORECASE)
486
+
487
+ _WHERE_END_KEYWORDS = frozenset(
488
+ {"GROUP BY", "ORDER BY", "LIMIT", "HAVING", "UNION", "WINDOW", "OFFSET"}
489
+ )
490
+
491
+
492
+ def _content_type(response: object) -> str:
493
+ """Return the declared content type when the response exposes one."""
494
+ value = getattr(response, "content_type", None)
495
+ return value if isinstance(value, str) else ""
496
+
497
+
498
+ def _looks_like_html(response: object, body: str) -> bool:
499
+ """Detect a proxy/gateway HTML page rather than an API response."""
500
+ if "html" in _content_type(response).lower():
501
+ return True
502
+ return bool(_HTML_START_PATTERN.match(body[:512]))
503
+
504
+
505
+ def _excerpt(body: str, label: str) -> str:
506
+ """Collapse whitespace and cap a body to a short, readable excerpt."""
507
+ collapsed = " ".join(body.split())
508
+ return sanitize_output(collapsed, _HTML_EXCERPT_BYTES, label=label)
509
+
510
+
511
+ def _html_response_message(service: str, url: str, status: int, body: str) -> str:
512
+ """Build the diagnostic for an HTML page served instead of the API."""
513
+ excerpt = _excerpt(body, "HTML page excerpt")
514
+ return (
515
+ f"❌ Received an HTML page from {url} (HTTP {status}), "
516
+ f"not a {service} response.\n"
517
+ "This is usually a reverse proxy / gateway error, not a query error.\n"
518
+ "Check that the upstream is reachable and that the vhost disables\n"
519
+ "`proxy_intercept_errors` for /api paths.\n"
520
+ f"\nHTML excerpt: {excerpt}"
521
+ )
522
+
523
+
524
+ def _json_decode_message(service: str, url: str, status: int, body: str) -> str:
525
+ """Build the diagnostic for a body that is not decodable JSON."""
526
+ excerpt = _excerpt(body, f"{service} response excerpt")
527
+ return (
528
+ f"❌ {service} returned a {status} response from {url} that is not "
529
+ "valid JSON.\n"
530
+ "This is usually a reverse proxy, captcha or login page instead of the "
531
+ f"{service} API.\n"
532
+ f"\nResponse excerpt: {excerpt}"
533
+ )
534
+
535
+
536
+ def _has_time_predicate(sql_query: str) -> bool:
537
+ """Return True when the WHERE clause filters on a ``time`` column.
538
+
539
+ ``SELECT time ...`` is not a time filter, so the hint must not be
540
+ suppressed for it. The check walks the flattened token stream, ignores
541
+ string literals and stops at the end of the WHERE clause.
542
+ """
543
+ try:
544
+ statements = sqlparse.parse(sql_query)
545
+ except Exception: # pragma: no cover - sqlparse is defensive
546
+ return False
547
+
548
+ for statement in statements:
549
+ in_where = False
550
+ for token in statement.flatten():
551
+ if token.ttype in sql_tokens.Keyword:
552
+ keyword = " ".join(token.value.upper().split())
553
+ if keyword == "WHERE":
554
+ in_where = True
555
+ continue
556
+ if keyword in _WHERE_END_KEYWORDS:
557
+ in_where = False
558
+ continue
559
+ if not in_where or token.is_whitespace:
560
+ continue
561
+ if token.ttype in sql_tokens.String:
562
+ continue
563
+ if _TIME_PREDICATE_PATTERN.search(token.value):
564
+ return True
565
+ return False
566
+
567
+
568
+ def _should_show_scan_hint(sql_query: str, error_body: str) -> bool:
569
+ """Only hint at the file-scan limit when it can be the actual cause."""
570
+ if _SCAN_LIMIT_PATTERN.search(error_body):
571
+ return True
572
+ return not _has_time_predicate(sql_query)
573
+
574
+
575
+ def _ensure_limit(sql_query: str, limit: int) -> str:
576
+ """Bound the result set of a SELECT query.
577
+
578
+ A top-level ``LIMIT <n>`` is clamped to ``limit``; a ``LIMIT`` inside a
579
+ string literal, an identifier or a nested subquery is ignored. When the
580
+ outermost query has no numeric LIMIT clause, one is appended. Comments are
581
+ rejected by :meth:`SecurityValidator.validate_influxdb_query` before the
582
+ query reaches this helper.
583
+ """
584
+ query = sql_query.strip().rstrip(";").rstrip()
585
+ maximum = int(limit)
586
+
587
+ try:
588
+ statements = sqlparse.parse(query)
589
+ except Exception: # pragma: no cover - sqlparse is defensive
590
+ return f"{query}\nLIMIT {maximum}"
591
+
592
+ if len(statements) != 1:
593
+ return f"{query}\nLIMIT {maximum}"
594
+
595
+ statement = statements[0]
596
+ tokens = statement.tokens
597
+ for index, token in enumerate(tokens):
598
+ if token.ttype not in sql_tokens.Keyword or token.value.upper() != "LIMIT":
599
+ continue
600
+
601
+ next_index = index + 1
602
+ while next_index < len(tokens) and (
603
+ tokens[next_index].is_whitespace
604
+ or tokens[next_index].ttype in sql_tokens.Comment
605
+ ):
606
+ next_index += 1
607
+
608
+ if next_index >= len(tokens):
609
+ continue
610
+ value_token = tokens[next_index]
611
+ if value_token.ttype not in sql_tokens.Number.Integer:
612
+ continue
613
+
614
+ if int(value_token.value) > maximum:
615
+ value_token.value = str(maximum)
616
+ return str(statement).strip()
617
+
618
+ return f"{query}\nLIMIT {maximum}"
619
+
620
+
621
+ def parse_duration(value: str | int | float, default: float = 30) -> float:
622
+ """Parse ``30``, ``"30s"``, ``"1m"`` or ``"500ms"`` into seconds."""
623
+ if isinstance(value, int | float):
624
+ return float(value)
625
+ text = str(value).strip().lower()
626
+ units = {"ms": 0.001, "s": 1.0, "m": 60.0, "h": 3600.0}
627
+ for suffix, factor in units.items():
628
+ if text.endswith(suffix):
629
+ try:
630
+ return float(text[: -len(suffix)]) * factor
631
+ except ValueError:
632
+ return default
633
+ try:
634
+ return float(text)
635
+ except ValueError:
636
+ return default