sre-agent-cli 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.
sre_agent/tools.py ADDED
@@ -0,0 +1,712 @@
1
+ """Tool layer v1: read-only ways for the agent to gather evidence.
2
+
3
+ Three tools, all look-only. None of them can change anything:
4
+
5
+ - ``query_metrics`` — ask a metrics backend a PromQL question.
6
+ - ``search_logs`` — search log lines (in memory, or from a file in
7
+ the demo).
8
+ - ``get_service_status`` — ask whether a service is up, and how
9
+ many copies of it are ready.
10
+
11
+ How safety is kept, in plain words:
12
+
13
+ - **Read-only by construction.** The tools only fetch. There is no
14
+ method here that writes, restarts, scales, or deletes.
15
+ - **Inputs are checked.** Every request is a typed model. An empty
16
+ or over-long query, an unbalanced PromQL string, or a limit
17
+ outside the allowed range is rejected before anything runs.
18
+ - **Outputs are bounded.** A query can return only so many series,
19
+ so many points per series, and so many log lines. Anything cut
20
+ off is flagged with ``truncated`` so the reader knows.
21
+ - **Outputs are cleaned.** Log lines and messages pass a filter
22
+ that replaces common secret shapes (passwords, tokens, keys)
23
+ with ``[redacted]`` before they are stored or shown.
24
+ - **Output is data, never orders.** A tool result becomes an
25
+ ``Evidence`` record. Nothing in it is ever run or obeyed. See
26
+ docs/safety-model.md.
27
+ - **Backend addresses are checked.** A live backend's address
28
+ must pass the rules in ``backend_url.py`` (finding F-05):
29
+ https, or plain http to this machine only, no credentials
30
+ inside the address, and an optional host allow-list.
31
+
32
+ Backends sit behind small classes so the tool shapes stay the same
33
+ when the demo backends are swapped for real ones. This pass ships
34
+ the demo backends (memory, file, and a Prometheus reply parser);
35
+ wiring a live Prometheus or Loki server plugs into the same
36
+ interface later, exactly as the incident store worked in Phase 1.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ import re
43
+ import urllib.parse
44
+ import urllib.request
45
+ from datetime import UTC, datetime
46
+ from pathlib import Path
47
+
48
+ from pydantic import BaseModel, Field, field_validator
49
+
50
+ from .backend_url import validate_backend_url
51
+ from .logging_config import get_logger
52
+ from .models import Evidence, Target
53
+
54
+ log = get_logger(__name__)
55
+
56
+ # Hard bounds. These are the tool layer's own limits; a caller may
57
+ # ask for less, never for more.
58
+ MAX_PROMQL_LENGTH = 500
59
+ MAX_LOG_TEXT_LENGTH = 200
60
+ MAX_SERIES = 20
61
+ MAX_POINTS_PER_SERIES = 200
62
+ MAX_LOG_LIMIT = 100
63
+ MAX_LOG_MESSAGE_CHARS = 500
64
+ MAX_EVIDENCE_SUMMARY_CHARS = 1000
65
+
66
+ # Words in a PromQL query that are functions or keywords, not metric
67
+ # names. The demo backend uses this to find the metric being asked
68
+ # about; a real Prometheus server does the full parsing itself.
69
+ _PROMQL_NON_METRICS = {
70
+ "rate",
71
+ "irate",
72
+ "increase",
73
+ "sum",
74
+ "avg",
75
+ "max",
76
+ "min",
77
+ "count",
78
+ "by",
79
+ "without",
80
+ "histogram_quantile",
81
+ "clamp_max",
82
+ "clamp_min",
83
+ "abs",
84
+ "ceil",
85
+ "floor",
86
+ "round",
87
+ "on",
88
+ "group_left",
89
+ "group_right",
90
+ "and",
91
+ "or",
92
+ "unless",
93
+ }
94
+
95
+ # Each entry is (pattern, replacement). A replacement may refer
96
+ # back to capture groups (``\1``) when part of the match is safe
97
+ # to keep — a key name, a URL's scheme and user — so the cleaned
98
+ # line still makes sense. Everything else is replaced whole.
99
+ _SECRET_PATTERNS: list[tuple[re.Pattern[str], str]] = [
100
+ # Labelled values: password=…, token: …, client_secret=…, …
101
+ # The value stops at whitespace, a semicolon, a comma or a
102
+ # quote, so one password inside a connection string
103
+ # (``Server=db;Password=…;Database=app``) is cleaned without
104
+ # eating the rest of the string. An opening quote is kept.
105
+ (
106
+ re.compile(
107
+ r"(?i)(password|passwd|pwd|client[_-]?secret|access[_-]?token"
108
+ r"|refresh[_-]?token|id[_-]?token|auth[_-]?token"
109
+ r"|session[_-]?token|secret|token|api[_-]?key|apikey"
110
+ r"|private[_-]?key)"
111
+ r"(\s*[:=]\s*[\"']?)[^\s;,\"']+"
112
+ ),
113
+ r"\1\2[redacted]",
114
+ ),
115
+ # A whole private-key block, header, body and footer together.
116
+ (
117
+ re.compile(
118
+ r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?"
119
+ r"-----END [A-Z ]*PRIVATE KEY-----",
120
+ re.DOTALL,
121
+ ),
122
+ "[redacted]",
123
+ ),
124
+ # JSON web tokens (JWTs): three base64url parts joined by
125
+ # dots, the first part starting ``eyJ`` — the encoded form of
126
+ # a JSON header starting ``{"``. Requiring that start keeps
127
+ # ordinary dotted words and version numbers out of this rule.
128
+ (
129
+ re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"
130
+ r"\.[A-Za-z0-9_-]{8,}\b"),
131
+ "[redacted]",
132
+ ),
133
+ # Credentials inside a URL: scheme://user:password@host —
134
+ # the scheme, user and host are kept, the password is not.
135
+ (
136
+ re.compile(r"([a-zA-Z][a-zA-Z0-9+.-]*://[^/\s:@]+:)[^/\s@]+(@)"),
137
+ r"\1[redacted]\2",
138
+ ),
139
+ (re.compile(r"(?i)bearer\s+\S+"), "[redacted]"),
140
+ (re.compile(r"(?i)basic\s+[A-Za-z0-9+/=]{8,}"), "[redacted]"),
141
+ (re.compile(r"(?:AKIA|ASIA)[0-9A-Z]{16}"), "[redacted]"),
142
+ (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"), "[redacted]"),
143
+ (re.compile(r"gh[pousr]_[A-Za-z0-9_]{20,}"), "[redacted]"),
144
+ (re.compile(r"glpat-[A-Za-z0-9_-]{20,}"), "[redacted]"),
145
+ (re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), "[redacted]"),
146
+ (re.compile(r"\b(?:sk|rk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b"), "[redacted]"),
147
+ (re.compile(r"https://hooks\.slack\.com/services/\S+"), "[redacted]"),
148
+ ]
149
+
150
+
151
+ def redact_secrets(text: str) -> str:
152
+ """Replace common secret shapes in *text* with ``[redacted]``.
153
+
154
+ This is a safety net, not a promise: it catches the shapes
155
+ secrets most often take in log lines — labelled values
156
+ (``password=``, ``token:``, ``client_secret=``), passwords
157
+ inside connection strings and inside URLs, JSON web tokens,
158
+ bearer and basic credentials, private-key blocks and headers,
159
+ and the prefixed tokens of common providers (AWS, GitHub,
160
+ GitLab, Slack, Stripe). The labelled pattern keeps the key
161
+ name (``password=``) so the line still makes sense.
162
+
163
+ What it still does not catch, stated plainly (finding F-03's
164
+ remainder): an unlabelled secret with no known prefix and no
165
+ label — a random string that only its owner knows is a
166
+ secret — and the body lines of a private key shown without
167
+ their header. No pattern list can promise those; redaction
168
+ is defence in depth, and evidence must still be shown only
169
+ to the operator.
170
+ """
171
+ out = text
172
+ for pattern, replacement in _SECRET_PATTERNS:
173
+ out = pattern.sub(replacement, out)
174
+ return out
175
+
176
+
177
+ def _bound(text: str, limit: int) -> str:
178
+ if len(text) <= limit:
179
+ return text
180
+ return text[:limit] + "…[truncated]"
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Metrics: PromQL query
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ class MetricSample(BaseModel):
189
+ """One measured value at one moment."""
190
+
191
+ timestamp: datetime
192
+ value: float
193
+
194
+
195
+ class MetricSeries(BaseModel):
196
+ """One line of measurements, with the labels that name it."""
197
+
198
+ metric: str
199
+ labels: dict[str, str] = Field(default_factory=dict)
200
+ samples: list[MetricSample] = Field(default_factory=list)
201
+
202
+
203
+ class QueryMetricsRequest(BaseModel):
204
+ """A PromQL question. Checked before it is allowed to run."""
205
+
206
+ query: str
207
+ limit_series: int = Field(default=10, ge=1, le=MAX_SERIES)
208
+ limit_points_per_series: int = Field(default=50, ge=1, le=MAX_POINTS_PER_SERIES)
209
+
210
+ @field_validator("query")
211
+ @classmethod
212
+ def _query_must_be_safe(cls, v: str) -> str:
213
+ q = v.strip()
214
+ if not q:
215
+ raise ValueError("PromQL query must not be empty")
216
+ if len(q) > MAX_PROMQL_LENGTH:
217
+ raise ValueError(f"PromQL query is too long (limit {MAX_PROMQL_LENGTH})")
218
+ if ";" in q or "\n" in q or "\r" in q:
219
+ raise ValueError("PromQL query must be a single query with no semicolons")
220
+ for open_ch, close_ch in (("{", "}"), ("[", "]"), ("(", ")")):
221
+ if q.count(open_ch) != q.count(close_ch):
222
+ raise ValueError(f"PromQL query has unbalanced '{open_ch}{close_ch}'")
223
+ return q
224
+
225
+
226
+ class QueryMetricsResult(BaseModel):
227
+ query: str
228
+ series: list[MetricSeries] = Field(default_factory=list)
229
+ total_series_found: int = 0
230
+ truncated: bool = False
231
+
232
+ def to_evidence(self) -> Evidence:
233
+ """Turn this result into an evidence record for an incident."""
234
+ if not self.series:
235
+ summary = f"No series matched PromQL query: {self.query}"
236
+ else:
237
+ parts: list[str] = []
238
+ for s in self.series[:3]:
239
+ if s.samples:
240
+ last = s.samples[-1]
241
+ parts.append(
242
+ f"{s.metric}{s.labels} last value {last.value:g} "
243
+ f"at {last.timestamp.isoformat()}"
244
+ )
245
+ else:
246
+ parts.append(f"{s.metric}{s.labels} (no samples)")
247
+ summary = "; ".join(parts)
248
+ if self.total_series_found > len(self.series):
249
+ summary += f"; +{self.total_series_found - len(self.series)} more series"
250
+ if self.truncated:
251
+ summary += " [truncated]"
252
+ return Evidence(
253
+ kind="metric",
254
+ query=f"promql: {self.query}",
255
+ summary=_bound(redact_secrets(summary), MAX_EVIDENCE_SUMMARY_CHARS),
256
+ )
257
+
258
+
259
+ def metric_name_from_query(query: str) -> str | None:
260
+ """Find the metric a PromQL query is mainly about, if there is one.
261
+
262
+ The demo backend matches on this name. It is a simple, honest
263
+ helper: it skips known function names and label names inside
264
+ ``{...}``, and returns the first other identifier it finds.
265
+ """
266
+ without_labels = re.sub(r"\{[^}]*\}", " ", query)
267
+ without_labels = re.sub(r"\b(?:by|without)\s*\([^)]*\)", " ", without_labels)
268
+ for token in re.findall(r"[a-zA-Z_:][a-zA-Z0-9_:]*", without_labels):
269
+ if token.lower() not in _PROMQL_NON_METRICS:
270
+ return token
271
+ return None
272
+
273
+
274
+ class InMemoryMetricsBackend:
275
+ """Demo metrics backend: series held in memory.
276
+
277
+ ``add_series`` exists only so a demo or a test can seed data.
278
+ The tool itself never calls it; querying is read-only.
279
+ """
280
+
281
+ def __init__(self) -> None:
282
+ self._series: list[MetricSeries] = []
283
+
284
+ def add_series(
285
+ self, metric: str, labels: dict[str, str], samples: list[MetricSample]
286
+ ) -> None:
287
+ self._series.append(
288
+ MetricSeries(metric=metric, labels=dict(labels), samples=list(samples))
289
+ )
290
+
291
+ def fetch(self, query: str) -> list[MetricSeries]:
292
+ name = metric_name_from_query(query)
293
+ if name is None:
294
+ return []
295
+ return [s for s in self._series if s.metric == name]
296
+
297
+
298
+ def parse_prometheus_response(payload: dict[str, object]) -> list[MetricSeries]:
299
+ """Turn a Prometheus ``/api/v1/query`` reply into series.
300
+
301
+ Pure function, so it can be tested without a server. Handles
302
+ both instant replies (one value per series) and range replies
303
+ (a list of values per series). A reply whose status is not
304
+ ``success`` gives back no series.
305
+ """
306
+ if payload.get("status") != "success":
307
+ return []
308
+ data = payload.get("data")
309
+ if not isinstance(data, dict):
310
+ return []
311
+ result = data.get("result")
312
+ if not isinstance(result, list):
313
+ return []
314
+ series: list[MetricSeries] = []
315
+ for item in result:
316
+ if not isinstance(item, dict):
317
+ continue
318
+ metric_labels = item.get("metric")
319
+ labels: dict[str, str] = {}
320
+ metric_name = "unknown"
321
+ if isinstance(metric_labels, dict):
322
+ for k, v in metric_labels.items():
323
+ if k == "__name__":
324
+ metric_name = str(v)
325
+ else:
326
+ labels[str(k)] = str(v)
327
+ samples: list[MetricSample] = []
328
+ raw_values = item.get("values")
329
+ raw_value = item.get("value")
330
+ points: list[object] = []
331
+ if isinstance(raw_values, list):
332
+ points = raw_values
333
+ elif isinstance(raw_value, list):
334
+ points = [raw_value]
335
+ for point in points:
336
+ if not (isinstance(point, list) and len(point) == 2):
337
+ continue
338
+ try:
339
+ ts = datetime.fromtimestamp(float(point[0]), tz=UTC)
340
+ samples.append(MetricSample(timestamp=ts, value=float(point[1])))
341
+ except (TypeError, ValueError):
342
+ continue
343
+ series.append(MetricSeries(metric=metric_name, labels=labels, samples=samples))
344
+ return series
345
+
346
+
347
+ class PrometheusMetricsBackend:
348
+ """Metrics backend that asks a real Prometheus server.
349
+
350
+ Uses only the standard library, so no new dependency is needed.
351
+ The reply parsing lives in :func:`parse_prometheus_response`.
352
+ The address is checked by :func:`validate_backend_url` when
353
+ the backend is built (finding F-05): https, or plain http to
354
+ this machine only, and — when ``allowed_hosts`` is given —
355
+ only a host on that list.
356
+ """
357
+
358
+ def __init__(
359
+ self,
360
+ base_url: str,
361
+ timeout_seconds: float = 5.0,
362
+ allowed_hosts: list[str] | None = None,
363
+ ) -> None:
364
+ self._base_url = validate_backend_url(
365
+ base_url, allowed_hosts=allowed_hosts, what="Prometheus address"
366
+ )
367
+ self._timeout = timeout_seconds
368
+
369
+ def fetch(self, query: str) -> list[MetricSeries]:
370
+ qs = urllib.parse.urlencode({"query": query})
371
+ url = f"{self._base_url}/api/v1/query?{qs}"
372
+ with urllib.request.urlopen(url, timeout=self._timeout) as resp:
373
+ payload = json.loads(resp.read().decode("utf-8"))
374
+ if not isinstance(payload, dict):
375
+ return []
376
+ return parse_prometheus_response(payload)
377
+
378
+
379
+ # ---------------------------------------------------------------------------
380
+ # Logs: search
381
+ # ---------------------------------------------------------------------------
382
+
383
+
384
+ class LogEntry(BaseModel):
385
+ timestamp: datetime
386
+ service: str
387
+ namespace: str = "default"
388
+ level: str = "INFO"
389
+ message: str
390
+
391
+
392
+ class SearchLogsRequest(BaseModel):
393
+ """A log search. An empty ``text`` means "all lines that match
394
+ the other filters"."""
395
+
396
+ text: str = ""
397
+ service: str | None = None
398
+ namespace: str | None = None
399
+ level: str | None = None
400
+ limit: int = Field(default=20, ge=1, le=MAX_LOG_LIMIT)
401
+
402
+ @field_validator("text")
403
+ @classmethod
404
+ def _text_must_be_bounded(cls, v: str) -> str:
405
+ if len(v) > MAX_LOG_TEXT_LENGTH:
406
+ raise ValueError(f"log search text is too long (limit {MAX_LOG_TEXT_LENGTH})")
407
+ return v
408
+
409
+ @field_validator("level")
410
+ @classmethod
411
+ def _level_must_be_known(cls, v: str | None) -> str | None:
412
+ if v is None:
413
+ return v
414
+ upper = v.upper()
415
+ if upper not in {"DEBUG", "INFO", "WARN", "WARNING", "ERROR", "CRITICAL"}:
416
+ raise ValueError(f"unknown log level: {v!r}")
417
+ return "WARN" if upper == "WARNING" else upper
418
+
419
+
420
+ class SearchLogsResult(BaseModel):
421
+ text: str
422
+ entries: list[LogEntry] = Field(default_factory=list)
423
+ total_found: int = 0
424
+ truncated: bool = False
425
+
426
+ def to_evidence(self) -> Evidence:
427
+ if not self.entries:
428
+ summary = f"No log lines matched search: {self.text!r}"
429
+ else:
430
+ first = self.entries[0]
431
+ summary = (
432
+ f"{self.total_found} log line(s) matched {self.text!r}; "
433
+ f"first: [{first.level}] {first.service}: {first.message}"
434
+ )
435
+ if self.truncated:
436
+ summary += " [truncated]"
437
+ return Evidence(
438
+ kind="log",
439
+ query=f"log search: {self.text!r}",
440
+ summary=_bound(redact_secrets(summary), MAX_EVIDENCE_SUMMARY_CHARS),
441
+ )
442
+
443
+
444
+ def _entry_matches(entry: LogEntry, request: SearchLogsRequest) -> bool:
445
+ if request.text and request.text.lower() not in entry.message.lower():
446
+ return False
447
+ if request.service is not None and entry.service != request.service:
448
+ return False
449
+ if request.namespace is not None and entry.namespace != request.namespace:
450
+ return False
451
+ return not (request.level is not None and entry.level.upper() != request.level)
452
+
453
+
454
+ class InMemoryLogsBackend:
455
+ """Demo log backend: lines held in memory. Seeding is test/demo
456
+ setup only; searching is read-only."""
457
+
458
+ def __init__(self, entries: list[LogEntry] | None = None) -> None:
459
+ self._entries: list[LogEntry] = list(entries or [])
460
+
461
+ def add_entry(self, entry: LogEntry) -> None:
462
+ self._entries.append(entry)
463
+
464
+ def search(self, request: SearchLogsRequest) -> list[LogEntry]:
465
+ return [e for e in self._entries if _entry_matches(e, request)]
466
+
467
+
468
+ class FileLogsBackend:
469
+ """Demo log backend that searches a plain file, one line at a time.
470
+
471
+ Each line may be plain text, or a JSON object with ``message``,
472
+ ``service``, ``namespace``, ``level`` and ``timestamp`` fields.
473
+ A missing file simply gives no lines; it is not an error, so a
474
+ demo with no log file yet still works.
475
+ """
476
+
477
+ def __init__(self, path: str | Path, default_service: str = "unknown") -> None:
478
+ self._path = Path(path)
479
+ self._default_service = default_service
480
+
481
+ def _load(self) -> list[LogEntry]:
482
+ if not self._path.exists():
483
+ return []
484
+ entries: list[LogEntry] = []
485
+ for line in self._path.read_text(encoding="utf-8").splitlines():
486
+ line = line.strip()
487
+ if not line:
488
+ continue
489
+ entries.append(self._parse_line(line))
490
+ return entries
491
+
492
+ def _parse_line(self, line: str) -> LogEntry:
493
+ try:
494
+ obj = json.loads(line)
495
+ except json.JSONDecodeError:
496
+ return LogEntry(
497
+ timestamp=datetime.now(UTC),
498
+ service=self._default_service,
499
+ message=line,
500
+ )
501
+ if not isinstance(obj, dict):
502
+ return LogEntry(
503
+ timestamp=datetime.now(UTC),
504
+ service=self._default_service,
505
+ message=line,
506
+ )
507
+ ts = obj.get("timestamp")
508
+ when = datetime.now(UTC)
509
+ if isinstance(ts, str):
510
+ try:
511
+ when = datetime.fromisoformat(ts)
512
+ except ValueError:
513
+ when = datetime.now(UTC)
514
+ return LogEntry(
515
+ timestamp=when,
516
+ service=str(obj.get("service", self._default_service)),
517
+ namespace=str(obj.get("namespace", "default")),
518
+ level=str(obj.get("level", "INFO")),
519
+ message=str(obj.get("message", "")),
520
+ )
521
+
522
+ def search(self, request: SearchLogsRequest) -> list[LogEntry]:
523
+ return [e for e in self._load() if _entry_matches(e, request)]
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # Service status
528
+ # ---------------------------------------------------------------------------
529
+
530
+
531
+ class ServiceStatus(BaseModel):
532
+ service: str
533
+ namespace: str
534
+ environment: str
535
+ # One of: healthy, degraded, down, unknown.
536
+ status: str
537
+ ready_replicas: int = Field(default=0, ge=0)
538
+ desired_replicas: int = Field(default=0, ge=0)
539
+ version: str = ""
540
+ message: str = ""
541
+
542
+
543
+ class ServiceStatusResult(BaseModel):
544
+ target: Target
545
+ found: bool
546
+ status: ServiceStatus | None = None
547
+
548
+ def to_evidence(self) -> Evidence:
549
+ t = self.target
550
+ query = f"service status: {t.environment}/{t.namespace}/{t.service}"
551
+ if not self.found or self.status is None:
552
+ summary = f"No status known for service {t.service} in {t.namespace}"
553
+ else:
554
+ s = self.status
555
+ summary = (
556
+ f"Service {s.service} is {s.status}: "
557
+ f"{s.ready_replicas}/{s.desired_replicas} replicas ready"
558
+ )
559
+ if s.version:
560
+ summary += f", version {s.version}"
561
+ if s.message:
562
+ summary += f". {s.message}"
563
+ return Evidence(
564
+ kind="service",
565
+ query=query,
566
+ summary=_bound(redact_secrets(summary), MAX_EVIDENCE_SUMMARY_CHARS),
567
+ )
568
+
569
+
570
+ class InMemoryServiceBackend:
571
+ """Demo service backend. ``set_status`` is demo/test setup only;
572
+ the tool itself only reads."""
573
+
574
+ def __init__(self) -> None:
575
+ self._by_key: dict[tuple[str, str, str], ServiceStatus] = {}
576
+
577
+ def set_status(self, status: ServiceStatus) -> None:
578
+ key = (status.environment, status.namespace, status.service)
579
+ self._by_key[key] = status
580
+
581
+ def get(self, target: Target) -> ServiceStatus | None:
582
+ key = (target.environment, target.namespace, target.service)
583
+ return self._by_key.get(key)
584
+
585
+
586
+ # ---------------------------------------------------------------------------
587
+ # The tool layer itself
588
+ # ---------------------------------------------------------------------------
589
+
590
+
591
+ class ToolLayer:
592
+ """The three read-only tools, with bounds and cleaning applied
593
+ in one place so no caller can skip them."""
594
+
595
+ def __init__(
596
+ self,
597
+ metrics: InMemoryMetricsBackend | PrometheusMetricsBackend | None = None,
598
+ logs: InMemoryLogsBackend | FileLogsBackend | None = None,
599
+ services: InMemoryServiceBackend | None = None,
600
+ ) -> None:
601
+ self._metrics = metrics if metrics is not None else InMemoryMetricsBackend()
602
+ self._logs = logs if logs is not None else InMemoryLogsBackend()
603
+ self._services = services if services is not None else InMemoryServiceBackend()
604
+
605
+ def query_metrics(self, request: QueryMetricsRequest) -> QueryMetricsResult:
606
+ """Run a PromQL query and return bounded, cleaned series."""
607
+ found = self._metrics.fetch(request.query)
608
+ truncated = len(found) > request.limit_series
609
+ kept: list[MetricSeries] = []
610
+ for series in found[: request.limit_series]:
611
+ samples = series.samples
612
+ if len(samples) > request.limit_points_per_series:
613
+ truncated = True
614
+ samples = samples[-request.limit_points_per_series :]
615
+ clean_labels = {
616
+ k: redact_secrets(v) for k, v in series.labels.items()
617
+ }
618
+ kept.append(
619
+ MetricSeries(metric=series.metric, labels=clean_labels, samples=samples)
620
+ )
621
+ log.info("query_metrics %r -> %d series", request.query, len(kept))
622
+ return QueryMetricsResult(
623
+ query=request.query,
624
+ series=kept,
625
+ total_series_found=len(found),
626
+ truncated=truncated,
627
+ )
628
+
629
+ def search_logs(self, request: SearchLogsRequest) -> SearchLogsResult:
630
+ """Search logs and return bounded, cleaned lines."""
631
+ found = self._logs.search(request)
632
+ truncated = len(found) > request.limit
633
+ kept = [
634
+ LogEntry(
635
+ timestamp=e.timestamp,
636
+ service=e.service,
637
+ namespace=e.namespace,
638
+ level=e.level,
639
+ message=_bound(redact_secrets(e.message), MAX_LOG_MESSAGE_CHARS),
640
+ )
641
+ for e in found[: request.limit]
642
+ ]
643
+ log.info("search_logs %r -> %d lines", request.text, len(kept))
644
+ return SearchLogsResult(
645
+ text=request.text, entries=kept, total_found=len(found), truncated=truncated
646
+ )
647
+
648
+ def get_service_status(self, target: Target) -> ServiceStatusResult:
649
+ """Look up one service. An unknown service is a normal
650
+ answer (``found`` is false), not an error."""
651
+ status = self._services.get(target)
652
+ if status is None:
653
+ return ServiceStatusResult(target=target, found=False)
654
+ clean = status.model_copy(update={"message": redact_secrets(status.message)})
655
+ return ServiceStatusResult(target=target, found=True, status=clean)
656
+
657
+
658
+ def demo_tool_layer() -> ToolLayer:
659
+ """A small seeded demo, so the tools can be tried with no setup.
660
+
661
+ The data is made up on purpose: a checkout service in the demo
662
+ environment whose error rate is high and which is degraded.
663
+ It claims nothing about any real system.
664
+ """
665
+ now = datetime.now(UTC)
666
+ metrics = InMemoryMetricsBackend()
667
+ metrics.add_series(
668
+ "http_requests_total",
669
+ {"service": "checkout", "namespace": "shop"},
670
+ [
671
+ MetricSample(timestamp=now, value=1240.0),
672
+ ],
673
+ )
674
+ metrics.add_series(
675
+ "http_request_errors_total",
676
+ {"service": "checkout", "namespace": "shop"},
677
+ [
678
+ MetricSample(timestamp=now, value=162.0),
679
+ ],
680
+ )
681
+ logs = InMemoryLogsBackend(
682
+ [
683
+ LogEntry(
684
+ timestamp=now,
685
+ service="checkout",
686
+ namespace="shop",
687
+ level="ERROR",
688
+ message="payment gateway timeout after 2000ms",
689
+ ),
690
+ LogEntry(
691
+ timestamp=now,
692
+ service="checkout",
693
+ namespace="shop",
694
+ level="INFO",
695
+ message="checkout request completed in 120ms",
696
+ ),
697
+ ]
698
+ )
699
+ services = InMemoryServiceBackend()
700
+ services.set_status(
701
+ ServiceStatus(
702
+ service="checkout",
703
+ namespace="shop",
704
+ environment="demo",
705
+ status="degraded",
706
+ ready_replicas=2,
707
+ desired_replicas=3,
708
+ version="1.4.2",
709
+ message="one replica is not ready",
710
+ )
711
+ )
712
+ return ToolLayer(metrics=metrics, logs=logs, services=services)