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/hosted_demo.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""The public-safe hosted demo server (Phase 10).
|
|
2
|
+
|
|
3
|
+
In plain words, this is the one server build that is meant to be
|
|
4
|
+
put on a public address later. It is the shipped demo server with
|
|
5
|
+
every public-safety rule switched on at once, in one place, so a
|
|
6
|
+
deployment cannot quietly forget one of them:
|
|
7
|
+
|
|
8
|
+
- **A token is required.** The app is not built at all without
|
|
9
|
+
one: ``create_hosted_demo_app`` refuses an empty token instead
|
|
10
|
+
of falling back to the unauthenticated local mode the shipped
|
|
11
|
+
app allows. With a token set, every route except ``/health``
|
|
12
|
+
and the landing page at ``/`` requires it, exactly as in
|
|
13
|
+
``server.py`` (finding F-01). The landing page is open for the
|
|
14
|
+
same reason ``/health`` is: it serves no data and runs no
|
|
15
|
+
action — it only explains the demo and prints the visitor
|
|
16
|
+
token, which is public on purpose.
|
|
17
|
+
- **Seeded demo data only.** Diagnosis reads the seeded demo tool
|
|
18
|
+
layer, the seeded demo read adapters and the seeded demo
|
|
19
|
+
memory, and actions run against the seeded demo workloads.
|
|
20
|
+
No real system is read or touched, and no real credential is
|
|
21
|
+
used anywhere in the stack: the only secret is the shared
|
|
22
|
+
token itself, supplied by whoever hosts it.
|
|
23
|
+
- **The Tier 3 demo grant is the only action that can run.**
|
|
24
|
+
The policy is ``tier3.demo_tier3_policy`` — restart and scale,
|
|
25
|
+
in the ``shop`` namespace only, at most three replicas touched
|
|
26
|
+
per action — and the server is built ``tier3_only=True``, so
|
|
27
|
+
there is no approval path: a fix the grant does not pre-approve
|
|
28
|
+
(rollback is the example) is refused and can never run here.
|
|
29
|
+
- **The ingestion caps stay on.** The store is the ordinary
|
|
30
|
+
incident store, so the F-02 caps apply unchanged: at most 100
|
|
31
|
+
alerts per Alertmanager payload, capped labels and summaries,
|
|
32
|
+
at most 200 evidence records per incident and 1,000 incidents
|
|
33
|
+
held, with a clear refusal past a cap.
|
|
34
|
+
|
|
35
|
+
Run it locally the same way it will run when hosted, with the
|
|
36
|
+
token in the environment:
|
|
37
|
+
|
|
38
|
+
SRE_AGENT_API_TOKEN=<a token you choose> \
|
|
39
|
+
uvicorn --factory sre_agent.hosted_demo:create_hosted_demo_app \
|
|
40
|
+
--host 0.0.0.0 --port 8080
|
|
41
|
+
|
|
42
|
+
Honest scope: this module is the public-safe *mode*. The
|
|
43
|
+
deployment it was built for is live (Render free plan,
|
|
44
|
+
https://sre-agent-demo.onrender.com, deployed 2026-11-12 with
|
|
45
|
+
the host and cost named first, as the plan required); the mode
|
|
46
|
+
itself claims nothing beyond what is written here. See
|
|
47
|
+
``docs/hosted-demo.md``.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
from fastapi import FastAPI
|
|
55
|
+
from fastapi.responses import HTMLResponse
|
|
56
|
+
|
|
57
|
+
from .adapters import demo_adapter_sources
|
|
58
|
+
from .memory import demo_incident_memory
|
|
59
|
+
from .runbooks import demo_runbooks
|
|
60
|
+
from .server import ENV_API_TOKEN, create_app
|
|
61
|
+
from .tier3 import demo_tier3_policy
|
|
62
|
+
from .tools import demo_tool_layer
|
|
63
|
+
|
|
64
|
+
LANDING_PAGE = """<!DOCTYPE html>
|
|
65
|
+
<html lang="en">
|
|
66
|
+
<head>
|
|
67
|
+
<meta charset="utf-8">
|
|
68
|
+
<title>sre-agent — hosted demo</title>
|
|
69
|
+
<style>
|
|
70
|
+
body { font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 44rem; padding: 0 1rem; line-height: 1.5; }
|
|
71
|
+
code, pre { background: #f2f2f2; padding: 0.1rem 0.3rem; border-radius: 4px; }
|
|
72
|
+
pre { padding: 0.75rem; overflow-x: auto; }
|
|
73
|
+
</style>
|
|
74
|
+
</head>
|
|
75
|
+
<body>
|
|
76
|
+
<h1>sre-agent — hosted demo</h1>
|
|
77
|
+
<p>An autonomous SRE agent that detects production incidents, works out
|
|
78
|
+
what is wrong using real evidence, and helps fix them — without ever
|
|
79
|
+
being allowed to make things worse on its own. This is its public demo:
|
|
80
|
+
seeded demo data only, nothing real is read or touched.</p>
|
|
81
|
+
|
|
82
|
+
<h2>The visitor token</h2>
|
|
83
|
+
<p>Every API route needs the token in the <code>X-SRE-Agent-Token</code>
|
|
84
|
+
header. The demo token is public on purpose:</p>
|
|
85
|
+
<pre>X-SRE-Agent-Token: sre-agent-demo-visitor</pre>
|
|
86
|
+
|
|
87
|
+
<h2>Try it</h2>
|
|
88
|
+
<p>Send the demo checkout alert, and read the diagnosis it opens:</p>
|
|
89
|
+
<pre>curl -X POST https://sre-agent-demo.onrender.com/webhooks/generic \
|
|
90
|
+
-H 'Content-Type: application/json' \
|
|
91
|
+
-H 'X-SRE-Agent-Token: sre-agent-demo-visitor' \
|
|
92
|
+
-d '{"alert_name":"HighErrorRate","service":"checkout","namespace":"shop","environment":"demo","summary":"Checkout errors are above 10%"}'
|
|
93
|
+
|
|
94
|
+
curl https://sre-agent-demo.onrender.com/incidents \
|
|
95
|
+
-H 'X-SRE-Agent-Token: sre-agent-demo-visitor'</pre>
|
|
96
|
+
<p>Propose the restart for the incident and it runs at once — the
|
|
97
|
+
demo's Tier 3 grant pre-approves it. Send the same alert again with
|
|
98
|
+
<code>"status": "resolved"</code> to close the incident.</p>
|
|
99
|
+
|
|
100
|
+
<h2>What this demo cannot do</h2>
|
|
101
|
+
<ul>
|
|
102
|
+
<li>It cannot touch anything real. Every number, workload and past
|
|
103
|
+
incident is seeded demo data.</li>
|
|
104
|
+
<li>It cannot run a fix outside the Tier 3 demo grant (restart and
|
|
105
|
+
scale, shop namespace only, at most three replicas). Rollback always
|
|
106
|
+
needs a human, and hosted mode has no approval path at all — that is
|
|
107
|
+
deliberate.</li>
|
|
108
|
+
<li>It sleeps. On the free hosting plan the service spins down after
|
|
109
|
+
about 15 minutes idle, so the first request can take a minute.</li>
|
|
110
|
+
</ul>
|
|
111
|
+
<p>Source, scores and the full record:
|
|
112
|
+
<a href="https://github.com/KakoozaJerry/sre-agent">github.com/KakoozaJerry/sre-agent</a></p>
|
|
113
|
+
</body>
|
|
114
|
+
</html>
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def create_hosted_demo_app(auth_token: str | None = None) -> FastAPI:
|
|
119
|
+
"""Build the public-safe hosted demo server, or refuse.
|
|
120
|
+
|
|
121
|
+
``auth_token`` wins when given; otherwise the token is read
|
|
122
|
+
from the ``SRE_AGENT_API_TOKEN`` environment variable, the
|
|
123
|
+
same variable the server and the CLI already use. An empty
|
|
124
|
+
or missing token raises ``ValueError``: a hosted demo with
|
|
125
|
+
no token would be an unauthenticated server on a public
|
|
126
|
+
address, and that build is refused here rather than warned
|
|
127
|
+
about in a guide a deployer might not read.
|
|
128
|
+
"""
|
|
129
|
+
token = auth_token
|
|
130
|
+
if token is None:
|
|
131
|
+
token = os.environ.get(ENV_API_TOKEN) or None
|
|
132
|
+
if not token:
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"the hosted demo requires an API token: pass auth_token "
|
|
135
|
+
f"or set {ENV_API_TOKEN}; refusing to build an "
|
|
136
|
+
"unauthenticated server for a public address"
|
|
137
|
+
)
|
|
138
|
+
_, runbook_backend = demo_runbooks()
|
|
139
|
+
app = create_app(
|
|
140
|
+
auth_token=token,
|
|
141
|
+
tool_layer=demo_tool_layer(),
|
|
142
|
+
adapters=demo_adapter_sources(),
|
|
143
|
+
memory=demo_incident_memory(),
|
|
144
|
+
approval_policy=demo_tier3_policy(),
|
|
145
|
+
runbook_backend=runbook_backend,
|
|
146
|
+
tier3_only=True,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@app.get("/", response_class=HTMLResponse)
|
|
150
|
+
def landing() -> str:
|
|
151
|
+
return LANDING_PAGE
|
|
152
|
+
|
|
153
|
+
return app
|
sre_agent/ingestion.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"""Alert ingestion: turn webhook payloads into incidents.
|
|
2
|
+
|
|
3
|
+
Two webhook shapes are understood:
|
|
4
|
+
|
|
5
|
+
- **Alertmanager.** The payload Prometheus Alertmanager sends: a list of
|
|
6
|
+
alerts with labels, annotations, a start time and a fingerprint.
|
|
7
|
+
- **Generic.** One simple alert as a small JSON object, for tools that
|
|
8
|
+
do not speak Alertmanager's shape.
|
|
9
|
+
|
|
10
|
+
What happens to an alert, in plain words:
|
|
11
|
+
|
|
12
|
+
- **Dedupe.** The same alert (same fingerprint) firing again while its
|
|
13
|
+
incident is still open is a copy. A copy never opens a second
|
|
14
|
+
incident; it is counted on the incident it belongs to.
|
|
15
|
+
- **Grouping.** A different alert (a different fingerprint) for the
|
|
16
|
+
same target and the same alert name, while an incident for them is
|
|
17
|
+
still open, belongs to that incident. It is added as evidence.
|
|
18
|
+
- **Resolving.** When a "resolved" alert arrives for an open incident,
|
|
19
|
+
the incident is resolved and its group closes, so the next firing
|
|
20
|
+
opens a fresh incident.
|
|
21
|
+
|
|
22
|
+
Alert text is data, never instructions. Labels and annotations are
|
|
23
|
+
stored as evidence only; nothing in them is ever run or obeyed. See
|
|
24
|
+
docs/safety-model.md for why that matters.
|
|
25
|
+
|
|
26
|
+
Intake limits (security finding F-02, fixed after v0.1.0): every
|
|
27
|
+
payload is bounded before it can grow the store — at most
|
|
28
|
+
``MAX_ALERTS_PER_PAYLOAD`` alerts in one Alertmanager payload, at
|
|
29
|
+
most ``MAX_METADATA_ENTRIES`` labels or annotations on one alert,
|
|
30
|
+
each label or annotation key and value length-capped, and short
|
|
31
|
+
fields (names, summaries) length-capped too. The store itself is
|
|
32
|
+
bounded: at most ``MAX_INCIDENTS`` incidents held, and at most
|
|
33
|
+
``MAX_EVIDENCE_PER_INCIDENT`` evidence records on one incident.
|
|
34
|
+
Past a store cap, ingestion raises :class:`IngestionLimitError`
|
|
35
|
+
and the server answers with a clear error instead of accepting
|
|
36
|
+
the work. The caps bound how much memory a flood — hostile, or a
|
|
37
|
+
broken sender retrying — can make the server hold. A per-sender
|
|
38
|
+
rate limit was not added; that is stated plainly in the review.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import hashlib
|
|
44
|
+
import json
|
|
45
|
+
from datetime import datetime
|
|
46
|
+
from typing import TYPE_CHECKING
|
|
47
|
+
|
|
48
|
+
from pydantic import BaseModel, Field, field_validator
|
|
49
|
+
|
|
50
|
+
from .logging_config import get_logger
|
|
51
|
+
from .models import Evidence, Incident, IncidentStatus, Target
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
from .approvals import PendingAction
|
|
55
|
+
|
|
56
|
+
log = get_logger(__name__)
|
|
57
|
+
|
|
58
|
+
FIRING = "firing"
|
|
59
|
+
RESOLVED = "resolved"
|
|
60
|
+
|
|
61
|
+
# Intake limits (F-02). Plain numbers, named once, here.
|
|
62
|
+
MAX_ALERTS_PER_PAYLOAD = 100
|
|
63
|
+
MAX_METADATA_ENTRIES = 50
|
|
64
|
+
MAX_METADATA_KEY_LENGTH = 100
|
|
65
|
+
MAX_METADATA_VALUE_LENGTH = 1000
|
|
66
|
+
MAX_SHORT_TEXT_LENGTH = 200
|
|
67
|
+
MAX_SUMMARY_LENGTH = 1000
|
|
68
|
+
MAX_INCIDENTS = 1000
|
|
69
|
+
MAX_EVIDENCE_PER_INCIDENT = 200
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class IngestionLimitError(Exception):
|
|
73
|
+
"""A store intake cap was reached; the alert was not accepted."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _check_metadata(values: dict[str, str]) -> dict[str, str]:
|
|
77
|
+
"""Bound one labels/annotations dict: count, key and value size."""
|
|
78
|
+
if len(values) > MAX_METADATA_ENTRIES:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"too many labels/annotations: {len(values)} > {MAX_METADATA_ENTRIES}"
|
|
81
|
+
)
|
|
82
|
+
for key, value in values.items():
|
|
83
|
+
if len(key) > MAX_METADATA_KEY_LENGTH:
|
|
84
|
+
raise ValueError("a label/annotation key is too long")
|
|
85
|
+
if len(value) > MAX_METADATA_VALUE_LENGTH:
|
|
86
|
+
raise ValueError("a label/annotation value is too long")
|
|
87
|
+
return values
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class IncomingAlert(BaseModel):
|
|
91
|
+
"""One alert, normalised into the same shape whatever sent it."""
|
|
92
|
+
|
|
93
|
+
fingerprint: str
|
|
94
|
+
alert_name: str
|
|
95
|
+
title: str
|
|
96
|
+
target: Target
|
|
97
|
+
severity: str = "unknown"
|
|
98
|
+
status: str = FIRING
|
|
99
|
+
source: str # "alertmanager" or "generic"
|
|
100
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
101
|
+
annotations: dict[str, str] = Field(default_factory=dict)
|
|
102
|
+
starts_at: datetime | None = None
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def group_key(self) -> tuple[str, str, str, str]:
|
|
106
|
+
"""Alerts that share a target and a name belong to one incident."""
|
|
107
|
+
return (
|
|
108
|
+
self.target.environment,
|
|
109
|
+
self.target.namespace,
|
|
110
|
+
self.target.service,
|
|
111
|
+
self.alert_name,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AlertmanagerAlert(BaseModel):
|
|
116
|
+
"""One alert inside an Alertmanager payload."""
|
|
117
|
+
|
|
118
|
+
model_config = {"populate_by_name": True}
|
|
119
|
+
|
|
120
|
+
status: str = Field(default=FIRING, max_length=MAX_SHORT_TEXT_LENGTH)
|
|
121
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
122
|
+
annotations: dict[str, str] = Field(default_factory=dict)
|
|
123
|
+
starts_at: datetime | None = Field(default=None, alias="startsAt")
|
|
124
|
+
fingerprint: str = Field(default="", max_length=MAX_SHORT_TEXT_LENGTH)
|
|
125
|
+
|
|
126
|
+
@field_validator("labels", "annotations")
|
|
127
|
+
@classmethod
|
|
128
|
+
def _metadata_is_bounded(cls, v: dict[str, str]) -> dict[str, str]:
|
|
129
|
+
return _check_metadata(v)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class AlertmanagerPayload(BaseModel):
|
|
133
|
+
"""The webhook body Alertmanager sends."""
|
|
134
|
+
|
|
135
|
+
model_config = {"populate_by_name": True}
|
|
136
|
+
|
|
137
|
+
status: str = Field(default=FIRING, max_length=MAX_SHORT_TEXT_LENGTH)
|
|
138
|
+
common_labels: dict[str, str] = Field(default_factory=dict, alias="commonLabels")
|
|
139
|
+
common_annotations: dict[str, str] = Field(
|
|
140
|
+
default_factory=dict, alias="commonAnnotations"
|
|
141
|
+
)
|
|
142
|
+
alerts: list[AlertmanagerAlert] = Field(
|
|
143
|
+
default_factory=list, max_length=MAX_ALERTS_PER_PAYLOAD
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@field_validator("common_labels", "common_annotations")
|
|
147
|
+
@classmethod
|
|
148
|
+
def _common_metadata_is_bounded(cls, v: dict[str, str]) -> dict[str, str]:
|
|
149
|
+
return _check_metadata(v)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class GenericAlertPayload(BaseModel):
|
|
153
|
+
"""The simple webhook body: one alert, plain fields."""
|
|
154
|
+
|
|
155
|
+
alert_name: str = Field(max_length=MAX_SHORT_TEXT_LENGTH)
|
|
156
|
+
service: str = Field(max_length=MAX_SHORT_TEXT_LENGTH)
|
|
157
|
+
namespace: str = Field(default="default", max_length=MAX_SHORT_TEXT_LENGTH)
|
|
158
|
+
environment: str = Field(default="demo", max_length=MAX_SHORT_TEXT_LENGTH)
|
|
159
|
+
severity: str = Field(default="unknown", max_length=MAX_SHORT_TEXT_LENGTH)
|
|
160
|
+
status: str = Field(default=FIRING, max_length=MAX_SHORT_TEXT_LENGTH)
|
|
161
|
+
fingerprint: str = Field(default="", max_length=MAX_SHORT_TEXT_LENGTH)
|
|
162
|
+
summary: str = Field(default="", max_length=MAX_SUMMARY_LENGTH)
|
|
163
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
164
|
+
annotations: dict[str, str] = Field(default_factory=dict)
|
|
165
|
+
starts_at: datetime | None = None
|
|
166
|
+
|
|
167
|
+
@field_validator("labels", "annotations")
|
|
168
|
+
@classmethod
|
|
169
|
+
def _metadata_is_bounded(cls, v: dict[str, str]) -> dict[str, str]:
|
|
170
|
+
return _check_metadata(v)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def fingerprint_for_labels(labels: dict[str, str]) -> str:
|
|
174
|
+
"""Make a stable fingerprint from labels when the sender gave none.
|
|
175
|
+
|
|
176
|
+
The same labels always make the same fingerprint, so a repeated
|
|
177
|
+
alert is recognised as a copy.
|
|
178
|
+
"""
|
|
179
|
+
canonical = json.dumps(labels, sort_keys=True, separators=(",", ":"))
|
|
180
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _title_for(alert_name: str, service: str, annotations: dict[str, str]) -> str:
|
|
184
|
+
return (
|
|
185
|
+
annotations.get("summary")
|
|
186
|
+
or annotations.get("description")
|
|
187
|
+
or f"{alert_name} on {service}"
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def normalize_alertmanager(
|
|
192
|
+
payload: AlertmanagerPayload, default_environment: str = "demo"
|
|
193
|
+
) -> list[IncomingAlert]:
|
|
194
|
+
"""Turn an Alertmanager payload into a list of incoming alerts."""
|
|
195
|
+
incoming: list[IncomingAlert] = []
|
|
196
|
+
for alert in payload.alerts:
|
|
197
|
+
labels = {**payload.common_labels, **alert.labels}
|
|
198
|
+
annotations = {**payload.common_annotations, **alert.annotations}
|
|
199
|
+
alert_name = labels.get("alertname", "unknown")
|
|
200
|
+
service = labels.get("service") or labels.get("job") or "unknown"
|
|
201
|
+
target = Target(
|
|
202
|
+
environment=labels.get("environment", default_environment),
|
|
203
|
+
namespace=labels.get("namespace", "default"),
|
|
204
|
+
service=service,
|
|
205
|
+
)
|
|
206
|
+
incoming.append(
|
|
207
|
+
IncomingAlert(
|
|
208
|
+
fingerprint=alert.fingerprint or fingerprint_for_labels(labels),
|
|
209
|
+
alert_name=alert_name,
|
|
210
|
+
title=_title_for(alert_name, service, annotations),
|
|
211
|
+
target=target,
|
|
212
|
+
severity=labels.get("severity", "unknown"),
|
|
213
|
+
status=alert.status or payload.status,
|
|
214
|
+
source="alertmanager",
|
|
215
|
+
labels=labels,
|
|
216
|
+
annotations=annotations,
|
|
217
|
+
starts_at=alert.starts_at,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
return incoming
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def normalize_generic(payload: GenericAlertPayload) -> IncomingAlert:
|
|
224
|
+
"""Turn a generic webhook body into one incoming alert."""
|
|
225
|
+
labels = {
|
|
226
|
+
"alertname": payload.alert_name,
|
|
227
|
+
"service": payload.service,
|
|
228
|
+
"namespace": payload.namespace,
|
|
229
|
+
"environment": payload.environment,
|
|
230
|
+
"severity": payload.severity,
|
|
231
|
+
**payload.labels,
|
|
232
|
+
}
|
|
233
|
+
annotations = dict(payload.annotations)
|
|
234
|
+
if payload.summary and "summary" not in annotations:
|
|
235
|
+
annotations["summary"] = payload.summary
|
|
236
|
+
return IncomingAlert(
|
|
237
|
+
fingerprint=payload.fingerprint or fingerprint_for_labels(labels),
|
|
238
|
+
alert_name=payload.alert_name,
|
|
239
|
+
title=_title_for(payload.alert_name, payload.service, annotations),
|
|
240
|
+
target=Target(
|
|
241
|
+
environment=payload.environment,
|
|
242
|
+
namespace=payload.namespace,
|
|
243
|
+
service=payload.service,
|
|
244
|
+
),
|
|
245
|
+
severity=payload.severity,
|
|
246
|
+
status=payload.status,
|
|
247
|
+
source="generic",
|
|
248
|
+
labels=labels,
|
|
249
|
+
annotations=annotations,
|
|
250
|
+
starts_at=payload.starts_at,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class IngestResult(BaseModel):
|
|
255
|
+
"""What ingestion did with one alert."""
|
|
256
|
+
|
|
257
|
+
incident: Incident | None = None
|
|
258
|
+
created: bool = False
|
|
259
|
+
duplicate: bool = False
|
|
260
|
+
grouped: bool = False
|
|
261
|
+
resolved: bool = False
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class IncidentStore:
|
|
265
|
+
"""Keeps incidents in memory and decides what each new alert means.
|
|
266
|
+
|
|
267
|
+
This is the small store interface the plan talks about: webhooks
|
|
268
|
+
and (later) tools talk to this class, so swapping the memory here
|
|
269
|
+
for SQLite later does not change the webhook shapes. That swap
|
|
270
|
+
now exists: ``storage.SQLiteIncidentStore`` keeps the same
|
|
271
|
+
interface and adds persistence behind it (see ``storage.py``).
|
|
272
|
+
|
|
273
|
+
The interface now also carries the two hooks a persistent store
|
|
274
|
+
needs and an in-memory store can answer trivially:
|
|
275
|
+
|
|
276
|
+
- ``save(incident)`` — an incident changed outside ingestion
|
|
277
|
+
(a diagnosis was written on it, a fix ran on it): keep the
|
|
278
|
+
change. In memory the record is already the object held, so
|
|
279
|
+
this only makes sure it is in the store; a database store
|
|
280
|
+
writes it through.
|
|
281
|
+
- ``load_approvals()`` / ``save_approval(pending, decision)`` —
|
|
282
|
+
the server's pending approvals, so a database-backed server
|
|
283
|
+
can restore them after a restart. In memory there is nothing
|
|
284
|
+
extra to load or write: the server's own dicts are the record,
|
|
285
|
+
and they are lost on restart, exactly as before.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
max_incidents: int = MAX_INCIDENTS,
|
|
291
|
+
max_evidence_per_incident: int = MAX_EVIDENCE_PER_INCIDENT,
|
|
292
|
+
) -> None:
|
|
293
|
+
self._max_incidents = max_incidents
|
|
294
|
+
self._max_evidence_per_incident = max_evidence_per_incident
|
|
295
|
+
self._incidents: dict[str, Incident] = {}
|
|
296
|
+
self._active_by_group: dict[tuple[str, str, str, str], str] = {}
|
|
297
|
+
self._incident_by_fingerprint: dict[str, str] = {}
|
|
298
|
+
self._duplicate_counts: dict[str, int] = {}
|
|
299
|
+
|
|
300
|
+
def list_incidents(self) -> list[Incident]:
|
|
301
|
+
return list(self._incidents.values())
|
|
302
|
+
|
|
303
|
+
def get(self, incident_id: str) -> Incident | None:
|
|
304
|
+
return self._incidents.get(incident_id)
|
|
305
|
+
|
|
306
|
+
def duplicate_count(self, incident_id: str) -> int:
|
|
307
|
+
return self._duplicate_counts.get(incident_id, 0)
|
|
308
|
+
|
|
309
|
+
def save(self, incident: Incident) -> None:
|
|
310
|
+
"""Keep a change made to *incident* outside ingestion.
|
|
311
|
+
|
|
312
|
+
In memory this is almost a no-op — the store already holds
|
|
313
|
+
this exact object — but it is the hook a persistent store
|
|
314
|
+
overrides to write the change through, and callers (the
|
|
315
|
+
server, after a diagnosis or a run) must call it rather
|
|
316
|
+
than assume their mutation was kept.
|
|
317
|
+
"""
|
|
318
|
+
self._incidents[incident.id] = incident
|
|
319
|
+
|
|
320
|
+
def load_approvals(
|
|
321
|
+
self,
|
|
322
|
+
) -> tuple[dict[str, PendingAction], dict[str, str]]:
|
|
323
|
+
"""Pending approvals a previous run of the server left behind.
|
|
324
|
+
|
|
325
|
+
An in-memory store kept none across a restart, so there is
|
|
326
|
+
nothing to restore.
|
|
327
|
+
"""
|
|
328
|
+
return {}, {}
|
|
329
|
+
|
|
330
|
+
def save_approval(
|
|
331
|
+
self, pending: PendingAction, decision: str | None
|
|
332
|
+
) -> None:
|
|
333
|
+
"""Keep one pending approval (and its decision, if decided).
|
|
334
|
+
|
|
335
|
+
In memory the server's own dicts are the record, so there
|
|
336
|
+
is nothing extra to write; a database store persists the
|
|
337
|
+
pending action so a restart restores it.
|
|
338
|
+
"""
|
|
339
|
+
|
|
340
|
+
def ingest(self, alert: IncomingAlert) -> IngestResult:
|
|
341
|
+
if alert.status == RESOLVED:
|
|
342
|
+
return self._resolve(alert)
|
|
343
|
+
return self._fire(alert)
|
|
344
|
+
|
|
345
|
+
def _fire(self, alert: IncomingAlert) -> IngestResult:
|
|
346
|
+
known_id = self._incident_by_fingerprint.get(alert.fingerprint)
|
|
347
|
+
if known_id is not None and known_id in self._incidents:
|
|
348
|
+
incident = self._incidents[known_id]
|
|
349
|
+
if incident.status is not IncidentStatus.RESOLVED:
|
|
350
|
+
self._duplicate_counts[incident.id] = (
|
|
351
|
+
self._duplicate_counts.get(incident.id, 0) + 1
|
|
352
|
+
)
|
|
353
|
+
log.info(
|
|
354
|
+
"duplicate alert counted for incident %s (fingerprint %s)",
|
|
355
|
+
incident.id,
|
|
356
|
+
alert.fingerprint,
|
|
357
|
+
)
|
|
358
|
+
return IngestResult(incident=incident, duplicate=True)
|
|
359
|
+
|
|
360
|
+
grouped_id = self._active_by_group.get(alert.group_key)
|
|
361
|
+
if grouped_id is not None:
|
|
362
|
+
incident = self._incidents[grouped_id]
|
|
363
|
+
if len(incident.evidence) >= self._max_evidence_per_incident:
|
|
364
|
+
raise IngestionLimitError(
|
|
365
|
+
f"incident {incident.id} already holds "
|
|
366
|
+
f"{self._max_evidence_per_incident} evidence records; "
|
|
367
|
+
"grouped alert refused"
|
|
368
|
+
)
|
|
369
|
+
incident.add_evidence(self._evidence_for(alert))
|
|
370
|
+
self._incident_by_fingerprint[alert.fingerprint] = incident.id
|
|
371
|
+
log.info("alert grouped into incident %s", incident.id)
|
|
372
|
+
return IngestResult(incident=incident, grouped=True)
|
|
373
|
+
|
|
374
|
+
if len(self._incidents) >= self._max_incidents:
|
|
375
|
+
raise IngestionLimitError(
|
|
376
|
+
f"incident store is full ({self._max_incidents} incidents); "
|
|
377
|
+
"new incident refused"
|
|
378
|
+
)
|
|
379
|
+
incident = Incident(title=alert.title, target=alert.target)
|
|
380
|
+
# Ingestion hands the incident straight to triage: the agent
|
|
381
|
+
# has started looking at it. This also makes a later
|
|
382
|
+
# "resolved" alert a legal closing move.
|
|
383
|
+
incident.transition(IncidentStatus.TRIAGING)
|
|
384
|
+
incident.add_evidence(self._evidence_for(alert))
|
|
385
|
+
self._incidents[incident.id] = incident
|
|
386
|
+
self._active_by_group[alert.group_key] = incident.id
|
|
387
|
+
self._incident_by_fingerprint[alert.fingerprint] = incident.id
|
|
388
|
+
log.info("incident %s opened from %s alert", incident.id, alert.source)
|
|
389
|
+
return IngestResult(incident=incident, created=True)
|
|
390
|
+
|
|
391
|
+
def _resolve(self, alert: IncomingAlert) -> IngestResult:
|
|
392
|
+
incident_id = self._active_by_group.get(alert.group_key)
|
|
393
|
+
if incident_id is None:
|
|
394
|
+
incident_id = self._incident_by_fingerprint.get(alert.fingerprint)
|
|
395
|
+
if incident_id is not None:
|
|
396
|
+
candidate = self._incidents.get(incident_id)
|
|
397
|
+
if candidate is None or candidate.status is IncidentStatus.RESOLVED:
|
|
398
|
+
incident_id = None
|
|
399
|
+
if incident_id is None:
|
|
400
|
+
log.info("resolved alert with no open incident; nothing to close")
|
|
401
|
+
return IngestResult()
|
|
402
|
+
|
|
403
|
+
incident = self._incidents[incident_id]
|
|
404
|
+
if incident.status is IncidentStatus.OPEN:
|
|
405
|
+
incident.transition(IncidentStatus.TRIAGING)
|
|
406
|
+
incident.transition(IncidentStatus.RESOLVED)
|
|
407
|
+
# Close the group: the next firing opens a fresh incident.
|
|
408
|
+
self._active_by_group.pop(alert.group_key, None)
|
|
409
|
+
stale = [
|
|
410
|
+
fp for fp, iid in self._incident_by_fingerprint.items() if iid == incident.id
|
|
411
|
+
]
|
|
412
|
+
for fp in stale:
|
|
413
|
+
del self._incident_by_fingerprint[fp]
|
|
414
|
+
log.info("incident %s resolved by alert", incident.id)
|
|
415
|
+
return IngestResult(incident=incident, resolved=True)
|
|
416
|
+
|
|
417
|
+
@staticmethod
|
|
418
|
+
def _evidence_for(alert: IncomingAlert) -> Evidence:
|
|
419
|
+
return Evidence(
|
|
420
|
+
kind="alert",
|
|
421
|
+
query=f"webhook:{alert.source} fingerprint:{alert.fingerprint}",
|
|
422
|
+
summary=f"{alert.alert_name} ({alert.severity}): {alert.title}",
|
|
423
|
+
)
|