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/__init__.py +3 -0
- sre_agent/adapters.py +833 -0
- sre_agent/approvals.py +243 -0
- sre_agent/audit.py +225 -0
- sre_agent/backend_scores.py +184 -0
- sre_agent/backend_url.py +146 -0
- sre_agent/cli.py +732 -0
- sre_agent/config.py +22 -0
- sre_agent/evaluation.py +391 -0
- sre_agent/evaluation_page.py +209 -0
- sre_agent/hosted_demo.py +153 -0
- sre_agent/ingestion.py +423 -0
- sre_agent/injection.py +580 -0
- sre_agent/logging_config.py +83 -0
- sre_agent/memory.py +411 -0
- sre_agent/model_backend.py +551 -0
- sre_agent/model_proposals.py +287 -0
- sre_agent/models.py +278 -0
- sre_agent/policy.py +208 -0
- sre_agent/postmortems.py +296 -0
- sre_agent/reasoning.py +547 -0
- sre_agent/regression.py +287 -0
- sre_agent/runbooks.py +290 -0
- sre_agent/security_review.py +826 -0
- sre_agent/server.py +684 -0
- sre_agent/storage.py +266 -0
- sre_agent/tier3.py +440 -0
- sre_agent/tools.py +712 -0
- sre_agent_cli-0.2.0.dist-info/METADATA +364 -0
- sre_agent_cli-0.2.0.dist-info/RECORD +34 -0
- sre_agent_cli-0.2.0.dist-info/WHEEL +5 -0
- sre_agent_cli-0.2.0.dist-info/entry_points.txt +2 -0
- sre_agent_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- sre_agent_cli-0.2.0.dist-info/top_level.txt +1 -0
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)
|