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/server.py
ADDED
|
@@ -0,0 +1,684 @@
|
|
|
1
|
+
"""Webhook server: the agent's front door for alerts.
|
|
2
|
+
|
|
3
|
+
Run it with:
|
|
4
|
+
|
|
5
|
+
uvicorn sre_agent.server:app --port 8080
|
|
6
|
+
|
|
7
|
+
Endpoints, in plain words:
|
|
8
|
+
|
|
9
|
+
- ``GET /health`` — is the agent up? Always answers ``{"status": "ok"}``.
|
|
10
|
+
This one stays open even when a token is set, so a health check
|
|
11
|
+
never needs the secret.
|
|
12
|
+
- ``POST /webhooks/alertmanager`` — send an Alertmanager payload;
|
|
13
|
+
each alert in it is ingested (see ingestion.py for dedupe/grouping).
|
|
14
|
+
- ``POST /webhooks/generic`` — send one simple alert as JSON.
|
|
15
|
+
- ``GET /incidents`` — list the incidents the agent knows about.
|
|
16
|
+
- ``GET /incidents/{id}`` — one incident, in full.
|
|
17
|
+
- ``POST /incidents/{id}/actions`` — propose a runbook fix for a
|
|
18
|
+
diagnosed incident; the policy engine decides and, when it asks
|
|
19
|
+
for a human, the proposal is stored as a pending approval.
|
|
20
|
+
- ``GET /incidents/{id}/approvals`` — the pending approvals held
|
|
21
|
+
for one incident, with their state.
|
|
22
|
+
- ``POST /incidents/{id}/approvals/{pending_id}/approve`` — one
|
|
23
|
+
named human approves one pending action; it then runs, once.
|
|
24
|
+
- ``POST /incidents/{id}/approvals/{pending_id}/reject`` — one
|
|
25
|
+
named human turns one pending action down; nothing runs.
|
|
26
|
+
|
|
27
|
+
Authentication (security finding F-01, fixed after v0.1.0): set the
|
|
28
|
+
``SRE_AGENT_API_TOKEN`` environment variable (or pass ``auth_token``
|
|
29
|
+
to :func:`create_app`) and every endpoint except ``/health`` then
|
|
30
|
+
requires that exact token in the ``X-SRE-Agent-Token`` header —
|
|
31
|
+
webhooks and incident reads alike. A missing or wrong token gets a
|
|
32
|
+
401 and nothing is ingested or shown. With no token set, the server
|
|
33
|
+
is unauthenticated, exactly as v0.1.0 was: that stays fine for one
|
|
34
|
+
operator on their own machine, and only for that. Before the server
|
|
35
|
+
is shared with anyone else, set a token. Alertmanager can send the
|
|
36
|
+
header too (its webhook receiver's ``http_config``), and the CLI
|
|
37
|
+
sends it automatically when the same environment variable is set.
|
|
38
|
+
|
|
39
|
+
Diagnosis (Phase 7): when the app is built with a tool layer
|
|
40
|
+
(``create_app(tool_layer=...)``), the reasoning loop runs inside
|
|
41
|
+
the server. A firing alert that opens a new incident is diagnosed
|
|
42
|
+
at once: the loop's evidence, its possible causes and its
|
|
43
|
+
diagnosis are stored on the incident itself, so ``GET
|
|
44
|
+
/incidents/{id}`` — and the CLI's ``show`` command, which reads
|
|
45
|
+
that endpoint — show the diagnosis with the incident. An update
|
|
46
|
+
that groups onto an incident a human was handed (status
|
|
47
|
+
"escalated") is diagnosed again; a duplicate changes nothing and
|
|
48
|
+
is never re-diagnosed, and an incident already diagnosed keeps
|
|
49
|
+
its diagnosis when a grouped alert adds evidence. The shipped
|
|
50
|
+
``app`` is built with the demo tool layer, so its diagnoses come
|
|
51
|
+
from the seeded demo data and claim nothing about a real system;
|
|
52
|
+
an app built with no tool layer records incidents only, exactly
|
|
53
|
+
as before. If the loop itself fails, the incident stays as
|
|
54
|
+
ingestion left it and the failure is logged — a broken diagnosis
|
|
55
|
+
never loses an alert.
|
|
56
|
+
|
|
57
|
+
Adapter evidence (Phase 7): when the app is also built with a
|
|
58
|
+
bundle of read adapters (``create_app(adapters=...)``, see
|
|
59
|
+
``adapters.AdapterSources``), the loop asks each selected
|
|
60
|
+
adapter one fixed question during a diagnosis and stores the
|
|
61
|
+
answers on the incident as evidence, under the same rules as
|
|
62
|
+
in ``reasoning.py``: adapter questions are built only from
|
|
63
|
+
validated target names, a failing adapter is skipped, and
|
|
64
|
+
adapter answers can support a claim or a cause only as counts
|
|
65
|
+
and measured values. The shipped ``app`` is built with the demo
|
|
66
|
+
adapter bundle, so its diagnoses carry Kubernetes, Datadog and
|
|
67
|
+
Azure Monitor evidence from the same seeded demo picture; a
|
|
68
|
+
real deployment passes its own bundle, whose live adapters
|
|
69
|
+
already passed the backend address rules when they were built.
|
|
70
|
+
An app built with adapters but no tool layer still records
|
|
71
|
+
only, because adapters supplement the loop, they do not
|
|
72
|
+
replace it.
|
|
73
|
+
|
|
74
|
+
Memory advice (Phase 7): when the app is also built with a
|
|
75
|
+
memory of past incidents (``create_app(memory=...)``, see
|
|
76
|
+
``memory.IncidentMemory``), the loop attaches that memory's
|
|
77
|
+
report to each incident it diagnoses — similar past incidents,
|
|
78
|
+
each runbook's record, and suggested runbooks — stored on the
|
|
79
|
+
incident as ``memory_report``, so ``GET /incidents/{id}`` and
|
|
80
|
+
``sre-agent show`` carry it with the diagnosis. The report is
|
|
81
|
+
computed after the diagnosis is decided and is advice only:
|
|
82
|
+
it is never an input to a claim, a cause, a proposal or an
|
|
83
|
+
action, and the server creates no proposal from a suggestion —
|
|
84
|
+
a suggested fix still has to be proposed and approved through
|
|
85
|
+
the live approval flow like any other. The shipped ``app`` is
|
|
86
|
+
built with the seeded demo memory (four past incidents), so
|
|
87
|
+
its diagnosed demo incidents carry advice from that seeded
|
|
88
|
+
past and claim nothing about a real system; an app built with
|
|
89
|
+
no memory attaches none, and a memory handed in without a
|
|
90
|
+
tool layer still records only, because there is no diagnosis
|
|
91
|
+
to attach advice to.
|
|
92
|
+
|
|
93
|
+
Database store (Phase 7): the store is passed in, so a server
|
|
94
|
+
built with ``storage.SQLiteIncidentStore`` (a file path) keeps
|
|
95
|
+
its incidents across a restart — diagnoses included, because a
|
|
96
|
+
diagnosis is a field on the incident — and its pending approvals
|
|
97
|
+
too: the server restores the approvals the store holds when it
|
|
98
|
+
starts, keeps each new one through the store, and saves the
|
|
99
|
+
incident again after a diagnosis and after a run, which is what
|
|
100
|
+
writes those later changes through. A restored approval keeps
|
|
101
|
+
its original expiry, and a decided one stays decided. A server
|
|
102
|
+
built with the in-memory store (the shipped ``app`` included)
|
|
103
|
+
behaves exactly as before: stop it and its incidents and
|
|
104
|
+
approvals are gone.
|
|
105
|
+
|
|
106
|
+
Live approvals (Phase 7): when the app is built with an
|
|
107
|
+
environment policy and a runbook backend
|
|
108
|
+
(``create_app(approval_policy=..., runbook_backend=...)``), the
|
|
109
|
+
server also serves the Tier 2 approval flow. A fix is proposed
|
|
110
|
+
for one diagnosed incident: the proposal's target is the target
|
|
111
|
+
pinned on that incident — the request body cannot name a target,
|
|
112
|
+
so a proposal cannot be aimed anywhere else — and how many
|
|
113
|
+
replicas it touches is counted from the backend's real state by
|
|
114
|
+
``runbooks.build_action``, never claimed by the proposer. The
|
|
115
|
+
policy engine decides, and the decision is written to the
|
|
116
|
+
server's audit log before anything else happens. A denied
|
|
117
|
+
proposal is refused. A proposal the policy sends for approval
|
|
118
|
+
waits as a pending action for at most 15 minutes; approving it
|
|
119
|
+
re-runs the policy check at approval time (a halt or tightening
|
|
120
|
+
since the proposal stands), spends the approval before the run
|
|
121
|
+
starts, and runs the fix exactly once. Rejecting a pending
|
|
122
|
+
action runs nothing. A run that succeeds adds its result to the
|
|
123
|
+
incident as evidence and moves the incident to "mitigating" —
|
|
124
|
+
never straight to "resolved", because the server has not checked
|
|
125
|
+
that the fix worked; resolution still arrives as a resolved
|
|
126
|
+
alert, as it always has. The shipped ``app`` is built with the
|
|
127
|
+
demo policy (Tier 2, the demo environment) and the demo runbook
|
|
128
|
+
backend, so its approvals act on the seeded demo workloads only
|
|
129
|
+
and claim nothing about a real system; an app built without a
|
|
130
|
+
policy or a backend holds no approvals, and its approval routes
|
|
131
|
+
answer 409 saying so.
|
|
132
|
+
|
|
133
|
+
Hosted demo mode (Phase 10): a server built with
|
|
134
|
+
``tier3_only=True`` can run only what its policy allows outright.
|
|
135
|
+
A proposal the policy sends for a human's approval is refused
|
|
136
|
+
with 403 and no pending action is created, and the approve and
|
|
137
|
+
reject routes answer 409 — in that mode there is no approval
|
|
138
|
+
path at all, so the only actions that can ever run are the ones
|
|
139
|
+
a Tier 3 grant pre-approved. ``hosted_demo.py`` builds the
|
|
140
|
+
public-safe server this way: a token is required, the data is
|
|
141
|
+
the seeded demo data only, and the policy is the demo Tier 3
|
|
142
|
+
grant. See ``docs/hosted-demo.md``.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
from __future__ import annotations
|
|
146
|
+
|
|
147
|
+
import os
|
|
148
|
+
import secrets
|
|
149
|
+
from datetime import UTC, datetime
|
|
150
|
+
|
|
151
|
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
|
152
|
+
from pydantic import BaseModel, Field
|
|
153
|
+
|
|
154
|
+
from .adapters import AdapterSources, demo_adapter_sources
|
|
155
|
+
from .approvals import (
|
|
156
|
+
ApprovalError,
|
|
157
|
+
PendingAction,
|
|
158
|
+
approve_pending,
|
|
159
|
+
create_pending,
|
|
160
|
+
execute_approved,
|
|
161
|
+
)
|
|
162
|
+
from .audit import AuditLog, evaluate_and_audit
|
|
163
|
+
from .ingestion import (
|
|
164
|
+
AlertmanagerPayload,
|
|
165
|
+
GenericAlertPayload,
|
|
166
|
+
IncidentStore,
|
|
167
|
+
IngestionLimitError,
|
|
168
|
+
IngestResult,
|
|
169
|
+
normalize_alertmanager,
|
|
170
|
+
normalize_generic,
|
|
171
|
+
)
|
|
172
|
+
from .logging_config import get_logger
|
|
173
|
+
from .memory import IncidentMemory, demo_incident_memory
|
|
174
|
+
from .model_backend import DiagnosisBackend
|
|
175
|
+
from .models import Incident, IncidentStatus
|
|
176
|
+
from .policy import Decision, EnvironmentPolicy, Tier
|
|
177
|
+
from .reasoning import ReasoningLoop
|
|
178
|
+
from .runbooks import (
|
|
179
|
+
DemoWorkloadBackend,
|
|
180
|
+
RunbookError,
|
|
181
|
+
RunbookExecutor,
|
|
182
|
+
build_action,
|
|
183
|
+
demo_runbooks,
|
|
184
|
+
)
|
|
185
|
+
from .tools import ToolLayer, demo_tool_layer
|
|
186
|
+
|
|
187
|
+
log = get_logger(__name__)
|
|
188
|
+
|
|
189
|
+
ENV_API_TOKEN = "SRE_AGENT_API_TOKEN"
|
|
190
|
+
TOKEN_HEADER = "X-SRE-Agent-Token"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _result_view(result: IngestResult) -> dict[str, object]:
|
|
194
|
+
incident = result.incident
|
|
195
|
+
return {
|
|
196
|
+
"incident_id": incident.id if incident else None,
|
|
197
|
+
"created": result.created,
|
|
198
|
+
"duplicate": result.duplicate,
|
|
199
|
+
"grouped": result.grouped,
|
|
200
|
+
"resolved": result.resolved,
|
|
201
|
+
"status": incident.status.value if incident else None,
|
|
202
|
+
"diagnosis": incident.diagnosis if incident else None,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _incident_summary(store: IncidentStore, incident: Incident) -> dict[str, object]:
|
|
207
|
+
return {
|
|
208
|
+
"id": incident.id,
|
|
209
|
+
"title": incident.title,
|
|
210
|
+
"status": incident.status.value,
|
|
211
|
+
"target": incident.target.model_dump(),
|
|
212
|
+
"evidence_count": len(incident.evidence),
|
|
213
|
+
"duplicate_count": store.duplicate_count(incident.id),
|
|
214
|
+
"diagnosis": incident.diagnosis,
|
|
215
|
+
"memory_matches": (
|
|
216
|
+
len(incident.memory_report.matches) if incident.memory_report else 0
|
|
217
|
+
),
|
|
218
|
+
"memory_suggestions": (
|
|
219
|
+
len(incident.memory_report.suggestions)
|
|
220
|
+
if incident.memory_report
|
|
221
|
+
else 0
|
|
222
|
+
),
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class ProposeActionRequest(BaseModel):
|
|
227
|
+
"""One fix proposed through the server, for one incident.
|
|
228
|
+
|
|
229
|
+
There is deliberately no target field: the action's target is
|
|
230
|
+
the target pinned on the incident named in the URL, so a
|
|
231
|
+
request cannot aim a fix anywhere else.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
runbook_id: str = Field(max_length=200)
|
|
235
|
+
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
|
236
|
+
rationale: str = Field(
|
|
237
|
+
default="proposed through the server for this incident",
|
|
238
|
+
max_length=1000,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class ApprovalDecisionRequest(BaseModel):
|
|
243
|
+
"""One named human's decision on one pending action."""
|
|
244
|
+
|
|
245
|
+
by: str = Field(max_length=200)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _pending_view(
|
|
249
|
+
pending: PendingAction, decisions: dict[str, str]
|
|
250
|
+
) -> dict[str, object]:
|
|
251
|
+
"""One pending action as the API shows it, with its state."""
|
|
252
|
+
if pending.id in decisions:
|
|
253
|
+
status = decisions[pending.id]
|
|
254
|
+
elif pending.is_expired(datetime.now(UTC)):
|
|
255
|
+
status = "expired"
|
|
256
|
+
else:
|
|
257
|
+
status = "pending"
|
|
258
|
+
return {
|
|
259
|
+
"id": pending.id,
|
|
260
|
+
"incident_id": pending.incident_id,
|
|
261
|
+
"runbook_id": pending.action.runbook_id,
|
|
262
|
+
"target": pending.action.target.model_dump(),
|
|
263
|
+
"parameters": pending.action.parameters,
|
|
264
|
+
"replicas_affected": pending.action.replicas_affected,
|
|
265
|
+
"parameters_hash": pending.parameters_hash,
|
|
266
|
+
"reason": pending.reason,
|
|
267
|
+
"status": status,
|
|
268
|
+
"created_at": pending.created_at.isoformat(),
|
|
269
|
+
"expires_at": pending.expires_at.isoformat(),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def create_app(
|
|
274
|
+
store: IncidentStore | None = None,
|
|
275
|
+
auth_token: str | None = None,
|
|
276
|
+
tool_layer: ToolLayer | None = None,
|
|
277
|
+
approval_policy: EnvironmentPolicy | None = None,
|
|
278
|
+
runbook_backend: DemoWorkloadBackend | None = None,
|
|
279
|
+
audit_log: AuditLog | None = None,
|
|
280
|
+
adapters: AdapterSources | None = None,
|
|
281
|
+
memory: IncidentMemory | None = None,
|
|
282
|
+
diagnosis_backend: DiagnosisBackend | None = None,
|
|
283
|
+
tier3_only: bool = False,
|
|
284
|
+
) -> FastAPI:
|
|
285
|
+
"""Build the webhook app. Pass a store to share or inspect state.
|
|
286
|
+
|
|
287
|
+
``auth_token`` wins when given; otherwise the token is read from
|
|
288
|
+
the ``SRE_AGENT_API_TOKEN`` environment variable. An empty value
|
|
289
|
+
counts as no token.
|
|
290
|
+
|
|
291
|
+
``tool_layer`` turns on server-side diagnosis: with one given,
|
|
292
|
+
the reasoning loop diagnoses each new incident as its alert is
|
|
293
|
+
ingested, and the diagnosis is stored on the incident. With no
|
|
294
|
+
tool layer, the server records incidents only.
|
|
295
|
+
|
|
296
|
+
``approval_policy`` and ``runbook_backend`` together turn on
|
|
297
|
+
live approvals: proposals for diagnosed incidents are decided
|
|
298
|
+
by that policy (every decision written to ``audit_log``, a
|
|
299
|
+
fresh in-memory one when none is given) and approved actions
|
|
300
|
+
run against that backend. With either missing, the server
|
|
301
|
+
holds no approvals and the approval routes answer 409.
|
|
302
|
+
|
|
303
|
+
``adapters`` selects read adapters as extra evidence sources
|
|
304
|
+
for the loop's diagnoses. It only takes effect together with
|
|
305
|
+
a ``tool_layer``: with no tool layer there is no loop, and
|
|
306
|
+
the server records incidents only.
|
|
307
|
+
|
|
308
|
+
``memory`` hands the loop a memory of past incidents; the
|
|
309
|
+
loop attaches its advice (a ``MemoryReport``) to each
|
|
310
|
+
incident it diagnoses, after the diagnosis is decided. It
|
|
311
|
+
likewise only takes effect together with a ``tool_layer``.
|
|
312
|
+
|
|
313
|
+
``diagnosis_backend`` selects who reads the gathered
|
|
314
|
+
evidence for the loop's diagnoses (see
|
|
315
|
+
``model_backend.py``). With none given, the deterministic
|
|
316
|
+
backend runs — the default. A language-model backend takes
|
|
317
|
+
effect only when an operator builds one and passes it here
|
|
318
|
+
on purpose; the shipped ``app`` below passes none, so the
|
|
319
|
+
model backend is off in the shipped server. It likewise
|
|
320
|
+
only takes effect together with a ``tool_layer``.
|
|
321
|
+
|
|
322
|
+
``store`` may be a ``storage.SQLiteIncidentStore``: then
|
|
323
|
+
incidents, diagnoses and pending approvals persist in its
|
|
324
|
+
file and are restored when a server is built on it again.
|
|
325
|
+
With the in-memory store (the default) nothing persists.
|
|
326
|
+
|
|
327
|
+
``tier3_only`` turns off the approval path: a proposal the
|
|
328
|
+
policy does not allow outright is refused and never becomes
|
|
329
|
+
a pending action, and the approve/reject routes answer 409.
|
|
330
|
+
Only a policy ALLOW — in the hosted demo, the demo Tier 3
|
|
331
|
+
grant — can run an action on such a server.
|
|
332
|
+
"""
|
|
333
|
+
incident_store = store if store is not None else IncidentStore()
|
|
334
|
+
loop = (
|
|
335
|
+
ReasoningLoop(
|
|
336
|
+
tool_layer, adapters=adapters, memory=memory, backend=diagnosis_backend
|
|
337
|
+
)
|
|
338
|
+
if tool_layer is not None
|
|
339
|
+
else None
|
|
340
|
+
)
|
|
341
|
+
executor = (
|
|
342
|
+
RunbookExecutor(runbook_backend) if runbook_backend is not None else None
|
|
343
|
+
)
|
|
344
|
+
audit = audit_log if audit_log is not None else AuditLog()
|
|
345
|
+
# Restored approvals (a database store hands back what a
|
|
346
|
+
# previous server left; the in-memory store hands back none).
|
|
347
|
+
pendings, decisions = incident_store.load_approvals()
|
|
348
|
+
|
|
349
|
+
def diagnose_if_needed(result: IngestResult) -> None:
|
|
350
|
+
"""Run the reasoning loop for one ingested alert, if it fits.
|
|
351
|
+
|
|
352
|
+
Only two moments are diagnosed: a newly opened incident
|
|
353
|
+
(still in triage), and a grouped update to an incident that
|
|
354
|
+
was escalated to a human (sent back to triage for a fresh
|
|
355
|
+
look). Duplicates, resolved alerts, and incidents already
|
|
356
|
+
diagnosed or further along keep the record they have.
|
|
357
|
+
"""
|
|
358
|
+
if loop is None or result.incident is None:
|
|
359
|
+
return
|
|
360
|
+
if result.duplicate or result.resolved:
|
|
361
|
+
return
|
|
362
|
+
incident = result.incident
|
|
363
|
+
if incident.status is IncidentStatus.ESCALATED:
|
|
364
|
+
incident.transition(IncidentStatus.TRIAGING)
|
|
365
|
+
if incident.status is not IncidentStatus.TRIAGING:
|
|
366
|
+
return
|
|
367
|
+
try:
|
|
368
|
+
loop.diagnose(incident)
|
|
369
|
+
except Exception:
|
|
370
|
+
log.exception(
|
|
371
|
+
"diagnosis failed for incident %s; the incident is "
|
|
372
|
+
"kept as ingested",
|
|
373
|
+
incident.id,
|
|
374
|
+
)
|
|
375
|
+
# The diagnosis (or the triage move that preceded a failed
|
|
376
|
+
# one) changed the record outside ingestion: keep the
|
|
377
|
+
# change through the store, so a database store writes it.
|
|
378
|
+
incident_store.save(incident)
|
|
379
|
+
token = auth_token
|
|
380
|
+
if token is None:
|
|
381
|
+
token = os.environ.get(ENV_API_TOKEN) or None
|
|
382
|
+
if token == "":
|
|
383
|
+
token = None
|
|
384
|
+
app = FastAPI(title="sre-agent")
|
|
385
|
+
|
|
386
|
+
def require_token(
|
|
387
|
+
x_sre_agent_token: str | None = Header(default=None, alias=TOKEN_HEADER),
|
|
388
|
+
) -> None:
|
|
389
|
+
if token is None:
|
|
390
|
+
return
|
|
391
|
+
if x_sre_agent_token is None or not secrets.compare_digest(
|
|
392
|
+
x_sre_agent_token, token
|
|
393
|
+
):
|
|
394
|
+
raise HTTPException(
|
|
395
|
+
status_code=401, detail="missing or wrong API token"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
guarded = [Depends(require_token)]
|
|
399
|
+
|
|
400
|
+
@app.get("/health")
|
|
401
|
+
def health() -> dict[str, str]:
|
|
402
|
+
return {"status": "ok"}
|
|
403
|
+
|
|
404
|
+
@app.post("/webhooks/alertmanager", dependencies=guarded)
|
|
405
|
+
def alertmanager_webhook(payload: AlertmanagerPayload) -> dict[str, object]:
|
|
406
|
+
try:
|
|
407
|
+
results = [
|
|
408
|
+
incident_store.ingest(alert)
|
|
409
|
+
for alert in normalize_alertmanager(payload)
|
|
410
|
+
]
|
|
411
|
+
except IngestionLimitError as exc:
|
|
412
|
+
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
413
|
+
for result in results:
|
|
414
|
+
diagnose_if_needed(result)
|
|
415
|
+
return {
|
|
416
|
+
"received": len(results),
|
|
417
|
+
"results": [_result_view(r) for r in results],
|
|
418
|
+
"active_incidents": sum(
|
|
419
|
+
1
|
|
420
|
+
for inc in incident_store.list_incidents()
|
|
421
|
+
if inc.status.value != "resolved"
|
|
422
|
+
),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@app.post("/webhooks/generic", dependencies=guarded)
|
|
426
|
+
def generic_webhook(payload: GenericAlertPayload) -> dict[str, object]:
|
|
427
|
+
try:
|
|
428
|
+
result = incident_store.ingest(normalize_generic(payload))
|
|
429
|
+
except IngestionLimitError as exc:
|
|
430
|
+
raise HTTPException(status_code=429, detail=str(exc)) from exc
|
|
431
|
+
diagnose_if_needed(result)
|
|
432
|
+
return _result_view(result)
|
|
433
|
+
|
|
434
|
+
@app.get("/incidents", dependencies=guarded)
|
|
435
|
+
def list_incidents() -> list[dict[str, object]]:
|
|
436
|
+
return [
|
|
437
|
+
_incident_summary(incident_store, inc)
|
|
438
|
+
for inc in incident_store.list_incidents()
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
@app.get("/incidents/{incident_id}", dependencies=guarded)
|
|
442
|
+
def get_incident(incident_id: str) -> dict[str, object]:
|
|
443
|
+
incident = incident_store.get(incident_id)
|
|
444
|
+
if incident is None:
|
|
445
|
+
raise HTTPException(status_code=404, detail="incident not found")
|
|
446
|
+
return {
|
|
447
|
+
**incident.model_dump(mode="json"),
|
|
448
|
+
"duplicate_count": incident_store.duplicate_count(incident.id),
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
def _incident_or_404(incident_id: str) -> Incident:
|
|
452
|
+
incident = incident_store.get(incident_id)
|
|
453
|
+
if incident is None:
|
|
454
|
+
raise HTTPException(status_code=404, detail="incident not found")
|
|
455
|
+
return incident
|
|
456
|
+
|
|
457
|
+
def _approvals_or_409() -> tuple[EnvironmentPolicy, RunbookExecutor]:
|
|
458
|
+
if approval_policy is None or executor is None or runbook_backend is None:
|
|
459
|
+
raise HTTPException(
|
|
460
|
+
status_code=409,
|
|
461
|
+
detail="live approvals are not configured on this server",
|
|
462
|
+
)
|
|
463
|
+
return approval_policy, executor
|
|
464
|
+
|
|
465
|
+
def _pending_or_404(incident_id: str, pending_id: str) -> PendingAction:
|
|
466
|
+
pending = pendings.get(pending_id)
|
|
467
|
+
if pending is None or pending.incident_id != incident_id:
|
|
468
|
+
raise HTTPException(status_code=404, detail="approval not found")
|
|
469
|
+
return pending
|
|
470
|
+
|
|
471
|
+
@app.post(
|
|
472
|
+
"/incidents/{incident_id}/actions",
|
|
473
|
+
dependencies=guarded,
|
|
474
|
+
status_code=201,
|
|
475
|
+
)
|
|
476
|
+
def propose_action(
|
|
477
|
+
incident_id: str, body: ProposeActionRequest
|
|
478
|
+
) -> dict[str, object]:
|
|
479
|
+
incident = _incident_or_404(incident_id)
|
|
480
|
+
policy, _ = _approvals_or_409()
|
|
481
|
+
assert runbook_backend is not None # checked by _approvals_or_409
|
|
482
|
+
if incident.status is not IncidentStatus.DIAGNOSED:
|
|
483
|
+
raise HTTPException(
|
|
484
|
+
status_code=409,
|
|
485
|
+
detail=(
|
|
486
|
+
f"incident is '{incident.status.value}'; only a diagnosed "
|
|
487
|
+
"incident can have a fix proposed for it"
|
|
488
|
+
),
|
|
489
|
+
)
|
|
490
|
+
try:
|
|
491
|
+
action = build_action(
|
|
492
|
+
body.runbook_id,
|
|
493
|
+
incident.target,
|
|
494
|
+
body.parameters,
|
|
495
|
+
runbook_backend,
|
|
496
|
+
rationale=body.rationale,
|
|
497
|
+
)
|
|
498
|
+
except RunbookError as exc:
|
|
499
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
500
|
+
result = evaluate_and_audit(
|
|
501
|
+
action,
|
|
502
|
+
policy,
|
|
503
|
+
audit,
|
|
504
|
+
incident_id=incident.id,
|
|
505
|
+
incident_target=incident.target,
|
|
506
|
+
)
|
|
507
|
+
if result.decision is Decision.DENY:
|
|
508
|
+
raise HTTPException(status_code=403, detail=result.reason)
|
|
509
|
+
if result.decision is Decision.ALLOW:
|
|
510
|
+
# A policy that allows outright (a Tier 3 grant) runs
|
|
511
|
+
# through the same executor; the decision is already
|
|
512
|
+
# in the audit log.
|
|
513
|
+
assert executor is not None
|
|
514
|
+
try:
|
|
515
|
+
run_result = executor.execute(action, result)
|
|
516
|
+
except RunbookError as exc:
|
|
517
|
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
|
518
|
+
incident.add_evidence(run_result.to_evidence())
|
|
519
|
+
incident.transition(IncidentStatus.MITIGATING)
|
|
520
|
+
incident_store.save(incident)
|
|
521
|
+
return {
|
|
522
|
+
"decision": result.decision.value,
|
|
523
|
+
"reason": result.reason,
|
|
524
|
+
"summary": run_result.summary,
|
|
525
|
+
"incident_status": incident.status.value,
|
|
526
|
+
"pending": None,
|
|
527
|
+
}
|
|
528
|
+
if tier3_only:
|
|
529
|
+
# Hosted demo mode: there is no approval path, so a
|
|
530
|
+
# proposal that is not allowed outright cannot run.
|
|
531
|
+
# The decision is already in the audit log; nothing
|
|
532
|
+
# becomes pending and nothing runs.
|
|
533
|
+
raise HTTPException(
|
|
534
|
+
status_code=403,
|
|
535
|
+
detail=(
|
|
536
|
+
"this server runs only actions its policy allows "
|
|
537
|
+
"outright (the Tier 3 demo grant); "
|
|
538
|
+
f"{body.runbook_id} needs a human approval, and "
|
|
539
|
+
"approvals are disabled in hosted demo mode"
|
|
540
|
+
),
|
|
541
|
+
)
|
|
542
|
+
pending = create_pending(action, result, incident_id=incident.id)
|
|
543
|
+
pendings[pending.id] = pending
|
|
544
|
+
incident_store.save_approval(pending, None)
|
|
545
|
+
return {
|
|
546
|
+
"decision": result.decision.value,
|
|
547
|
+
"reason": result.reason,
|
|
548
|
+
"summary": None,
|
|
549
|
+
"incident_status": incident.status.value,
|
|
550
|
+
"pending": _pending_view(pending, decisions),
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
@app.get("/incidents/{incident_id}/approvals", dependencies=guarded)
|
|
554
|
+
def list_approvals(incident_id: str) -> list[dict[str, object]]:
|
|
555
|
+
_incident_or_404(incident_id)
|
|
556
|
+
_approvals_or_409()
|
|
557
|
+
return [
|
|
558
|
+
_pending_view(pending, decisions)
|
|
559
|
+
for pending in pendings.values()
|
|
560
|
+
if pending.incident_id == incident_id
|
|
561
|
+
]
|
|
562
|
+
|
|
563
|
+
@app.post(
|
|
564
|
+
"/incidents/{incident_id}/approvals/{pending_id}/approve",
|
|
565
|
+
dependencies=guarded,
|
|
566
|
+
)
|
|
567
|
+
def approve_action(
|
|
568
|
+
incident_id: str, pending_id: str, body: ApprovalDecisionRequest
|
|
569
|
+
) -> dict[str, object]:
|
|
570
|
+
incident = _incident_or_404(incident_id)
|
|
571
|
+
policy, executor = _approvals_or_409()
|
|
572
|
+
if tier3_only:
|
|
573
|
+
raise HTTPException(
|
|
574
|
+
status_code=409,
|
|
575
|
+
detail=(
|
|
576
|
+
"approvals are disabled in hosted demo mode; "
|
|
577
|
+
"only the Tier 3 demo grant can run an action"
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
pending = _pending_or_404(incident_id, pending_id)
|
|
581
|
+
if pending.id in decisions:
|
|
582
|
+
raise HTTPException(
|
|
583
|
+
status_code=409,
|
|
584
|
+
detail="this approval has already been decided; "
|
|
585
|
+
"propose the action again for a new approval",
|
|
586
|
+
)
|
|
587
|
+
try:
|
|
588
|
+
grant, allowed = approve_pending(
|
|
589
|
+
pending,
|
|
590
|
+
approved_by=body.by,
|
|
591
|
+
policy=policy,
|
|
592
|
+
audit_log=audit,
|
|
593
|
+
incident_target=incident.target,
|
|
594
|
+
)
|
|
595
|
+
except ApprovalError as exc:
|
|
596
|
+
status_code = 403 if "policy now denies" in str(exc) else 409
|
|
597
|
+
raise HTTPException(status_code=status_code, detail=str(exc)) from exc
|
|
598
|
+
# The grant is spent by the run attempt, success or failure
|
|
599
|
+
# (see approvals.execute_approved), so the pending action
|
|
600
|
+
# is decided from here on either way.
|
|
601
|
+
decisions[pending.id] = "approved"
|
|
602
|
+
incident_store.save_approval(pending, "approved")
|
|
603
|
+
try:
|
|
604
|
+
run_result = execute_approved(executor, pending, grant, allowed)
|
|
605
|
+
except RunbookError as exc:
|
|
606
|
+
raise HTTPException(
|
|
607
|
+
status_code=422,
|
|
608
|
+
detail=f"the approved action could not run: {exc}",
|
|
609
|
+
) from exc
|
|
610
|
+
incident.add_evidence(run_result.to_evidence())
|
|
611
|
+
if incident.status is IncidentStatus.DIAGNOSED:
|
|
612
|
+
incident.transition(IncidentStatus.MITIGATING)
|
|
613
|
+
incident_store.save(incident)
|
|
614
|
+
return {
|
|
615
|
+
"pending_id": pending.id,
|
|
616
|
+
"decision": "approved",
|
|
617
|
+
"approved_by": grant.approved_by,
|
|
618
|
+
"runbook_id": pending.action.runbook_id,
|
|
619
|
+
"summary": run_result.summary,
|
|
620
|
+
"incident_status": incident.status.value,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
@app.post(
|
|
624
|
+
"/incidents/{incident_id}/approvals/{pending_id}/reject",
|
|
625
|
+
dependencies=guarded,
|
|
626
|
+
)
|
|
627
|
+
def reject_action(
|
|
628
|
+
incident_id: str, pending_id: str, body: ApprovalDecisionRequest
|
|
629
|
+
) -> dict[str, object]:
|
|
630
|
+
incident = _incident_or_404(incident_id)
|
|
631
|
+
_approvals_or_409()
|
|
632
|
+
if tier3_only:
|
|
633
|
+
raise HTTPException(
|
|
634
|
+
status_code=409,
|
|
635
|
+
detail=(
|
|
636
|
+
"approvals are disabled in hosted demo mode; "
|
|
637
|
+
"only the Tier 3 demo grant can run an action"
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
pending = _pending_or_404(incident_id, pending_id)
|
|
641
|
+
if not body.by.strip():
|
|
642
|
+
raise HTTPException(
|
|
643
|
+
status_code=400,
|
|
644
|
+
detail="a rejection must name who is rejecting",
|
|
645
|
+
)
|
|
646
|
+
if pending.id in decisions:
|
|
647
|
+
raise HTTPException(
|
|
648
|
+
status_code=409,
|
|
649
|
+
detail="this approval has already been decided",
|
|
650
|
+
)
|
|
651
|
+
decisions[pending.id] = "rejected"
|
|
652
|
+
incident_store.save_approval(pending, "rejected")
|
|
653
|
+
return {
|
|
654
|
+
"pending_id": pending.id,
|
|
655
|
+
"decision": "rejected",
|
|
656
|
+
"rejected_by": body.by.strip(),
|
|
657
|
+
"runbook_id": pending.action.runbook_id,
|
|
658
|
+
"summary": None,
|
|
659
|
+
"incident_status": incident.status.value,
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return app
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
# The shipped app is the demo server: it diagnoses new incidents
|
|
666
|
+
# from the seeded demo tool layer's data (see tools.demo_tool_layer),
|
|
667
|
+
# with the seeded demo read adapters selected as extra evidence
|
|
668
|
+
# sources and the seeded demo memory attached as advice, and serves
|
|
669
|
+
# live approvals against the seeded demo workloads, under a Tier 2
|
|
670
|
+
# policy for the demo environment. It passes no diagnosis backend,
|
|
671
|
+
# so its diagnoses come from the deterministic backend — the
|
|
672
|
+
# language-model backend is off in the shipped server.
|
|
673
|
+
_demo_backend = demo_runbooks()[1]
|
|
674
|
+
app = create_app(
|
|
675
|
+
tool_layer=demo_tool_layer(),
|
|
676
|
+
adapters=demo_adapter_sources(),
|
|
677
|
+
memory=demo_incident_memory(),
|
|
678
|
+
approval_policy=EnvironmentPolicy(
|
|
679
|
+
environment="demo",
|
|
680
|
+
granted_tier=Tier.ACT_WITH_APPROVAL,
|
|
681
|
+
max_replicas_affected=5,
|
|
682
|
+
),
|
|
683
|
+
runbook_backend=_demo_backend,
|
|
684
|
+
)
|