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/policy.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""The policy engine: the only path between a proposal and an action.
|
|
2
|
+
|
|
3
|
+
Pure functions over explicit inputs. No model output is ever trusted here;
|
|
4
|
+
a proposal arrives as structured data and leaves as a decision with a reason.
|
|
5
|
+
See docs/safety-model.md for the contract this code implements.
|
|
6
|
+
|
|
7
|
+
What this module decides, in plain words:
|
|
8
|
+
|
|
9
|
+
- **Classification.** Every action names a runbook, and every runbook the
|
|
10
|
+
agent may ever propose is listed in one fixed catalogue in this file.
|
|
11
|
+
The catalogue says whether the action only reads (it changes nothing)
|
|
12
|
+
or mutates reversibly (it changes something that can be undone, like a
|
|
13
|
+
restart, a scale, or a rollback). A runbook that is not in the
|
|
14
|
+
catalogue is unknown, and unknown actions are denied — a made-up
|
|
15
|
+
runbook name can never slip through as "needs approval".
|
|
16
|
+
- **Blast radius.** A mutating action is checked against the limits of
|
|
17
|
+
its environment: how many replicas it touches, which namespaces are
|
|
18
|
+
allowed at all, which namespaces are on the no-touch list, and whether
|
|
19
|
+
its target is still the target pinned on the incident it belongs to.
|
|
20
|
+
- **Tier gate.** What remains is decided by the environment's tier
|
|
21
|
+
(Section 6 of the plan): read-only actions are allowed, mutations
|
|
22
|
+
need a human's approval at Tier 2, and only pre-approved runbook
|
|
23
|
+
classes run without one at Tier 3.
|
|
24
|
+
|
|
25
|
+
Recording the decision is the audit log's job (``audit.py``); this
|
|
26
|
+
module only decides. ``audit.evaluate_and_audit`` is the normal way to
|
|
27
|
+
call both together, so no decision goes unrecorded.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from enum import Enum
|
|
33
|
+
|
|
34
|
+
from pydantic import BaseModel, Field
|
|
35
|
+
|
|
36
|
+
from .models import Target
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Tier(int, Enum):
|
|
40
|
+
OBSERVE = 0
|
|
41
|
+
ADVISE = 1
|
|
42
|
+
ACT_WITH_APPROVAL = 2
|
|
43
|
+
ACT_WITHIN_BOUNDS = 3
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Decision(str, Enum):
|
|
47
|
+
ALLOW = "allow"
|
|
48
|
+
REQUIRE_APPROVAL = "require_approval"
|
|
49
|
+
DENY = "deny"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ActionClass(str, Enum):
|
|
53
|
+
"""What kind of action a runbook is. Fixed by the catalogue below."""
|
|
54
|
+
|
|
55
|
+
READ_ONLY = "read_only"
|
|
56
|
+
REVERSIBLE_MUTATION = "reversible_mutation"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# The allow-list. Every runbook id the agent may propose must be named
|
|
60
|
+
# here, with its class. Anything not named here is unknown and denied.
|
|
61
|
+
# The three read-only entries are the tool layer's tools; the three
|
|
62
|
+
# mutating entries are the runbook library's first runbooks (Phase 2).
|
|
63
|
+
RUNBOOK_CATALOG: dict[str, ActionClass] = {
|
|
64
|
+
"query-metrics": ActionClass.READ_ONLY,
|
|
65
|
+
"search-logs": ActionClass.READ_ONLY,
|
|
66
|
+
"get-service-status": ActionClass.READ_ONLY,
|
|
67
|
+
"restart-workload": ActionClass.REVERSIBLE_MUTATION,
|
|
68
|
+
"scale-replicas": ActionClass.REVERSIBLE_MUTATION,
|
|
69
|
+
"rollback-deployment": ActionClass.REVERSIBLE_MUTATION,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def classify_action(runbook_id: str) -> ActionClass | None:
|
|
74
|
+
"""Return the class of a runbook, or None if it is not allow-listed."""
|
|
75
|
+
return RUNBOOK_CATALOG.get(runbook_id)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ProposedAction(BaseModel):
|
|
79
|
+
"""A structured proposal. Free-form commands are not representable."""
|
|
80
|
+
|
|
81
|
+
runbook_id: str
|
|
82
|
+
target: Target
|
|
83
|
+
parameters: dict[str, str | int | float | bool] = Field(default_factory=dict)
|
|
84
|
+
replicas_affected: int = Field(default=0, ge=0)
|
|
85
|
+
rationale: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class EnvironmentPolicy(BaseModel):
|
|
89
|
+
"""What the agent may do in one environment. Defaults are restrictive."""
|
|
90
|
+
|
|
91
|
+
environment: str
|
|
92
|
+
granted_tier: Tier = Tier.ADVISE
|
|
93
|
+
# runbook classes allowed to run at Tier 3 in this environment
|
|
94
|
+
tier3_runbooks: set[str] = Field(default_factory=set)
|
|
95
|
+
max_replicas_affected: int = 1
|
|
96
|
+
# if non-empty, only these namespaces may be touched at all
|
|
97
|
+
allowed_namespaces: set[str] = Field(default_factory=set)
|
|
98
|
+
denied_namespaces: set[str] = Field(default_factory=set)
|
|
99
|
+
halted: bool = False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class PolicyResult(BaseModel):
|
|
103
|
+
decision: Decision
|
|
104
|
+
reason: str
|
|
105
|
+
action_class: ActionClass | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def evaluate(
|
|
109
|
+
action: ProposedAction,
|
|
110
|
+
policy: EnvironmentPolicy,
|
|
111
|
+
*,
|
|
112
|
+
incident_target: Target | None = None,
|
|
113
|
+
) -> PolicyResult:
|
|
114
|
+
"""Decide whether a proposed action may run, needs approval, or is denied.
|
|
115
|
+
|
|
116
|
+
Order matters: hard stops are checked before any allowance, so a halted
|
|
117
|
+
or out-of-scope environment can never be overridden by a grant.
|
|
118
|
+
|
|
119
|
+
``incident_target`` is the target pinned on the incident the action
|
|
120
|
+
belongs to, when the caller has one. An action aimed anywhere else is
|
|
121
|
+
denied: actions cannot retarget mid-flight.
|
|
122
|
+
"""
|
|
123
|
+
action_class = classify_action(action.runbook_id)
|
|
124
|
+
|
|
125
|
+
if action.target.environment != policy.environment:
|
|
126
|
+
return PolicyResult(
|
|
127
|
+
decision=Decision.DENY,
|
|
128
|
+
reason="action target environment does not match this policy",
|
|
129
|
+
action_class=action_class,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if action.target.namespace in policy.denied_namespaces:
|
|
133
|
+
return PolicyResult(
|
|
134
|
+
decision=Decision.DENY,
|
|
135
|
+
reason=f"namespace '{action.target.namespace}' is on the no-touch list",
|
|
136
|
+
action_class=action_class,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if incident_target is not None and action.target != incident_target:
|
|
140
|
+
return PolicyResult(
|
|
141
|
+
decision=Decision.DENY,
|
|
142
|
+
reason="action target does not match the target pinned on the incident",
|
|
143
|
+
action_class=action_class,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if action_class is None:
|
|
147
|
+
return PolicyResult(
|
|
148
|
+
decision=Decision.DENY,
|
|
149
|
+
reason=f"runbook '{action.runbook_id}' is not on the allow-list",
|
|
150
|
+
action_class=None,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if action_class is ActionClass.READ_ONLY:
|
|
154
|
+
# Reads change nothing, so the tier gate and the halt switch do
|
|
155
|
+
# not block them (docs/safety-model.md: "Reads may continue").
|
|
156
|
+
return PolicyResult(
|
|
157
|
+
decision=Decision.ALLOW,
|
|
158
|
+
reason="read-only action; it changes nothing",
|
|
159
|
+
action_class=action_class,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if policy.halted:
|
|
163
|
+
return PolicyResult(
|
|
164
|
+
decision=Decision.DENY,
|
|
165
|
+
reason="agent is halted in this environment",
|
|
166
|
+
action_class=action_class,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
if policy.allowed_namespaces and action.target.namespace not in policy.allowed_namespaces:
|
|
170
|
+
return PolicyResult(
|
|
171
|
+
decision=Decision.DENY,
|
|
172
|
+
reason=f"namespace '{action.target.namespace}' is not in this environment's "
|
|
173
|
+
"allowed namespaces",
|
|
174
|
+
action_class=action_class,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if action.replicas_affected > policy.max_replicas_affected:
|
|
178
|
+
return PolicyResult(
|
|
179
|
+
decision=Decision.DENY,
|
|
180
|
+
reason=(
|
|
181
|
+
f"blast radius too large: {action.replicas_affected} replicas affected, "
|
|
182
|
+
f"limit is {policy.max_replicas_affected}"
|
|
183
|
+
),
|
|
184
|
+
action_class=action_class,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if policy.granted_tier < Tier.ACT_WITH_APPROVAL:
|
|
188
|
+
return PolicyResult(
|
|
189
|
+
decision=Decision.DENY,
|
|
190
|
+
reason=f"environment is at Tier {policy.granted_tier}; mutations are not granted",
|
|
191
|
+
action_class=action_class,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if (
|
|
195
|
+
policy.granted_tier >= Tier.ACT_WITHIN_BOUNDS
|
|
196
|
+
and action.runbook_id in policy.tier3_runbooks
|
|
197
|
+
):
|
|
198
|
+
return PolicyResult(
|
|
199
|
+
decision=Decision.ALLOW,
|
|
200
|
+
reason="pre-approved runbook class within blast-radius limits (Tier 3)",
|
|
201
|
+
action_class=action_class,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return PolicyResult(
|
|
205
|
+
decision=Decision.REQUIRE_APPROVAL,
|
|
206
|
+
reason="mutation requires a human to approve this specific action (Tier 2)",
|
|
207
|
+
action_class=action_class,
|
|
208
|
+
)
|
sre_agent/postmortems.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Postmortem draft generation: the incident report, from the record.
|
|
2
|
+
|
|
3
|
+
In plain words, this is the last step of an incident: after the
|
|
4
|
+
problem is fixed, someone has to write down what happened. This
|
|
5
|
+
module writes the *first draft* of that report, using only what
|
|
6
|
+
the incident record itself holds — its evidence, its diagnosis,
|
|
7
|
+
the actions recorded on it, and the audit records the caller
|
|
8
|
+
hands in for it. A human edits and publishes the draft; nothing
|
|
9
|
+
here publishes anything.
|
|
10
|
+
|
|
11
|
+
How honesty is kept, in plain words:
|
|
12
|
+
|
|
13
|
+
- **The draft invents nothing.** It is a pure reader of the
|
|
14
|
+
record: it changes no incident, runs no tool and proposes no
|
|
15
|
+
action. If the record holds no diagnosis, the draft says the
|
|
16
|
+
cause was not recorded instead of guessing one. If no action
|
|
17
|
+
was recorded, it says so. What the record does not know is
|
|
18
|
+
listed, in its own section, as not known.
|
|
19
|
+
- **The timeline uses only timestamps the record really has.**
|
|
20
|
+
Incidents do not stamp each status change — a transition only
|
|
21
|
+
moves ``updated_at`` — so the timeline is built from the
|
|
22
|
+
incident's creation time, each evidence record's observation
|
|
23
|
+
time, and each audit record's decision time, and it ends with
|
|
24
|
+
the record's last update. No time is made up for a change the
|
|
25
|
+
record did not date.
|
|
26
|
+
- **An unfinished incident gets a preliminary draft, labelled
|
|
27
|
+
as one.** A draft can be generated at any point — it is useful
|
|
28
|
+
mid-incident too — but until the incident is resolved the
|
|
29
|
+
draft says plainly that it is not a final postmortem.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from datetime import UTC, datetime
|
|
35
|
+
|
|
36
|
+
from pydantic import BaseModel, Field
|
|
37
|
+
|
|
38
|
+
from .approvals import approve_pending, create_pending, execute_approved
|
|
39
|
+
from .audit import AuditLog, evaluate_and_audit
|
|
40
|
+
from .logging_config import get_logger
|
|
41
|
+
from .models import AuditRecord, Evidence, Hypothesis, Incident, IncidentStatus, Target
|
|
42
|
+
from .policy import EnvironmentPolicy, Tier
|
|
43
|
+
from .reasoning import ReasoningLoop
|
|
44
|
+
from .runbooks import build_action, demo_runbooks
|
|
45
|
+
from .tools import demo_tool_layer
|
|
46
|
+
|
|
47
|
+
log = get_logger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _utcnow() -> datetime:
|
|
51
|
+
return datetime.now(UTC)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class TimelineEntry(BaseModel):
|
|
55
|
+
"""One dated line in the draft's timeline, from the record."""
|
|
56
|
+
|
|
57
|
+
at: datetime
|
|
58
|
+
text: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class PostmortemDraft(BaseModel):
|
|
62
|
+
"""A first draft of an incident's postmortem, ready to edit."""
|
|
63
|
+
|
|
64
|
+
incident_id: str
|
|
65
|
+
title: str
|
|
66
|
+
target: Target
|
|
67
|
+
status: IncidentStatus
|
|
68
|
+
resolved: bool
|
|
69
|
+
generated_at: datetime = Field(default_factory=_utcnow)
|
|
70
|
+
diagnosis: str | None = None
|
|
71
|
+
timeline: list[TimelineEntry] = Field(default_factory=list)
|
|
72
|
+
hypotheses: list[Hypothesis] = Field(default_factory=list)
|
|
73
|
+
evidence: list[Evidence] = Field(default_factory=list)
|
|
74
|
+
actions: list[Evidence] = Field(default_factory=list)
|
|
75
|
+
audit_records: list[AuditRecord] = Field(default_factory=list)
|
|
76
|
+
unknowns: list[str] = Field(default_factory=list)
|
|
77
|
+
|
|
78
|
+
def render_markdown(self) -> str:
|
|
79
|
+
"""Write the draft as Markdown a human can edit and publish.
|
|
80
|
+
|
|
81
|
+
Every section is present even when the record behind it is
|
|
82
|
+
empty; an empty section says so in plain words rather than
|
|
83
|
+
being quietly dropped, so a reader can tell the difference
|
|
84
|
+
between "none" and "not written yet".
|
|
85
|
+
"""
|
|
86
|
+
target = self.target
|
|
87
|
+
lines = [
|
|
88
|
+
f"# Postmortem draft: {self.title}",
|
|
89
|
+
"",
|
|
90
|
+
f"Incident: {self.incident_id}",
|
|
91
|
+
(
|
|
92
|
+
f"Target: {target.service} in {target.namespace} "
|
|
93
|
+
f"({target.environment})"
|
|
94
|
+
),
|
|
95
|
+
f"Status: {self.status.value}",
|
|
96
|
+
]
|
|
97
|
+
if not self.resolved:
|
|
98
|
+
lines.append(
|
|
99
|
+
"**Preliminary draft — this incident is not resolved yet, "
|
|
100
|
+
"so this is not a final postmortem.**"
|
|
101
|
+
)
|
|
102
|
+
lines += ["", "## Timeline", ""]
|
|
103
|
+
if self.timeline:
|
|
104
|
+
for entry in self.timeline:
|
|
105
|
+
lines.append(f"- {entry.at.isoformat()} — {entry.text}")
|
|
106
|
+
else:
|
|
107
|
+
lines.append("- No dated events were recorded.")
|
|
108
|
+
lines += ["", "## Diagnosis", ""]
|
|
109
|
+
lines.append(
|
|
110
|
+
self.diagnosis
|
|
111
|
+
if self.diagnosis
|
|
112
|
+
else "Not recorded: the record holds no diagnosis, so no cause is stated."
|
|
113
|
+
)
|
|
114
|
+
lines += ["", "## Possible causes, as recorded", ""]
|
|
115
|
+
if self.hypotheses:
|
|
116
|
+
for hyp in self.hypotheses:
|
|
117
|
+
lines.append(
|
|
118
|
+
f"- {hyp.statement} (confidence {hyp.confidence:.2f}, "
|
|
119
|
+
f"supported by {len(hyp.supporting_evidence)} evidence record(s))"
|
|
120
|
+
)
|
|
121
|
+
else:
|
|
122
|
+
lines.append("- None were recorded.")
|
|
123
|
+
lines += ["", "## Evidence", ""]
|
|
124
|
+
if self.evidence:
|
|
125
|
+
for ev in self.evidence:
|
|
126
|
+
lines.append(
|
|
127
|
+
f"- [{ev.kind}] {ev.summary} (query: {ev.query})"
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
lines.append("- No evidence was recorded.")
|
|
131
|
+
lines += ["", "## Actions taken", ""]
|
|
132
|
+
if self.actions:
|
|
133
|
+
for ev in self.actions:
|
|
134
|
+
lines.append(f"- {ev.summary} (query: {ev.query})")
|
|
135
|
+
else:
|
|
136
|
+
lines.append("- No action was recorded on this incident.")
|
|
137
|
+
lines += ["", "## Decisions and approvals (audit log)", ""]
|
|
138
|
+
if self.audit_records:
|
|
139
|
+
for record in self.audit_records:
|
|
140
|
+
approver = (
|
|
141
|
+
f", approved by {record.approved_by}" if record.approved_by else ""
|
|
142
|
+
)
|
|
143
|
+
lines.append(
|
|
144
|
+
f"- {record.action_summary} — decision: {record.decision} "
|
|
145
|
+
f"({record.reason}){approver}"
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
lines.append("- No audit records were supplied for this incident.")
|
|
149
|
+
lines += ["", "## What is not known", ""]
|
|
150
|
+
if self.unknowns:
|
|
151
|
+
for unknown in self.unknowns:
|
|
152
|
+
lines.append(f"- {unknown}")
|
|
153
|
+
else:
|
|
154
|
+
lines.append("- Nothing: the record covers every section above.")
|
|
155
|
+
lines.append("")
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def generate_postmortem(
|
|
160
|
+
incident: Incident,
|
|
161
|
+
audit_records: list[AuditRecord] | None = None,
|
|
162
|
+
*,
|
|
163
|
+
now: datetime | None = None,
|
|
164
|
+
) -> PostmortemDraft:
|
|
165
|
+
"""Build a postmortem draft from *incident*'s own record.
|
|
166
|
+
|
|
167
|
+
Pure: the incident is read, never changed, and nothing is run.
|
|
168
|
+
Audit records are taken from the caller's list but filtered to
|
|
169
|
+
this incident, so one incident's draft can never carry another
|
|
170
|
+
incident's decisions.
|
|
171
|
+
"""
|
|
172
|
+
records = [r for r in audit_records or [] if r.incident_id == incident.id]
|
|
173
|
+
records.sort(key=lambda r: r.decided_at)
|
|
174
|
+
|
|
175
|
+
timeline = [
|
|
176
|
+
TimelineEntry(
|
|
177
|
+
at=incident.created_at,
|
|
178
|
+
text=f"Incident opened: {incident.title}",
|
|
179
|
+
)
|
|
180
|
+
]
|
|
181
|
+
for ev in sorted(incident.evidence, key=lambda e: e.observed_at):
|
|
182
|
+
timeline.append(
|
|
183
|
+
TimelineEntry(
|
|
184
|
+
at=ev.observed_at,
|
|
185
|
+
text=f"Evidence ({ev.kind}): {ev.summary}",
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
for record in records:
|
|
189
|
+
timeline.append(
|
|
190
|
+
TimelineEntry(
|
|
191
|
+
at=record.decided_at,
|
|
192
|
+
text=(
|
|
193
|
+
f"Decision recorded: {record.action_summary} — "
|
|
194
|
+
f"{record.decision}"
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
timeline.append(
|
|
199
|
+
TimelineEntry(
|
|
200
|
+
at=incident.updated_at,
|
|
201
|
+
text=f"Record last updated; status is {incident.status.value}.",
|
|
202
|
+
)
|
|
203
|
+
)
|
|
204
|
+
timeline.sort(key=lambda e: e.at)
|
|
205
|
+
|
|
206
|
+
unknowns: list[str] = []
|
|
207
|
+
if incident.status is not IncidentStatus.RESOLVED:
|
|
208
|
+
unknowns.append(
|
|
209
|
+
f"The incident is not resolved yet (status: {incident.status.value}); "
|
|
210
|
+
"this draft is preliminary and the ending is not known."
|
|
211
|
+
)
|
|
212
|
+
if not incident.diagnosis:
|
|
213
|
+
unknowns.append(
|
|
214
|
+
"No diagnosis was recorded, so the cause is not stated in this draft."
|
|
215
|
+
)
|
|
216
|
+
if not incident.hypotheses:
|
|
217
|
+
unknowns.append("No possible causes were recorded.")
|
|
218
|
+
if not incident.evidence:
|
|
219
|
+
unknowns.append("No evidence was recorded.")
|
|
220
|
+
actions = [ev for ev in incident.evidence if ev.kind == "action"]
|
|
221
|
+
if not actions:
|
|
222
|
+
unknowns.append("No runbook action was recorded on this incident.")
|
|
223
|
+
if not records:
|
|
224
|
+
unknowns.append("No audit records were supplied for this incident.")
|
|
225
|
+
|
|
226
|
+
log.info(
|
|
227
|
+
"postmortem draft generated for incident %s (status %s, %d unknown(s))",
|
|
228
|
+
incident.id,
|
|
229
|
+
incident.status.value,
|
|
230
|
+
len(unknowns),
|
|
231
|
+
)
|
|
232
|
+
return PostmortemDraft(
|
|
233
|
+
incident_id=incident.id,
|
|
234
|
+
title=incident.title,
|
|
235
|
+
target=incident.target,
|
|
236
|
+
status=incident.status,
|
|
237
|
+
resolved=incident.status is IncidentStatus.RESOLVED,
|
|
238
|
+
generated_at=now if now is not None else _utcnow(),
|
|
239
|
+
diagnosis=incident.diagnosis,
|
|
240
|
+
timeline=timeline,
|
|
241
|
+
hypotheses=list(incident.hypotheses),
|
|
242
|
+
evidence=list(incident.evidence),
|
|
243
|
+
actions=actions,
|
|
244
|
+
audit_records=records,
|
|
245
|
+
unknowns=unknowns,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def demo_postmortem() -> PostmortemDraft:
|
|
250
|
+
"""A full demo postmortem, built end to end with no setup.
|
|
251
|
+
|
|
252
|
+
One seeded demo incident is diagnosed by the real reasoning
|
|
253
|
+
loop, fixed by the real Tier 2 path (propose, approve by name,
|
|
254
|
+
run the restart once), and resolved — then drafted here. The
|
|
255
|
+
draft therefore shows a real diagnosis, a real action record
|
|
256
|
+
and real audit records, all about the demo checkout workload
|
|
257
|
+
and nothing real.
|
|
258
|
+
"""
|
|
259
|
+
target = Target(environment="demo", namespace="shop", service="checkout")
|
|
260
|
+
incident = Incident(
|
|
261
|
+
id="demo-incident-1",
|
|
262
|
+
title="HighErrorRate on checkout",
|
|
263
|
+
target=target,
|
|
264
|
+
)
|
|
265
|
+
ReasoningLoop(demo_tool_layer()).diagnose(incident)
|
|
266
|
+
|
|
267
|
+
executor, backend = demo_runbooks()
|
|
268
|
+
action = build_action(
|
|
269
|
+
"restart-workload",
|
|
270
|
+
target,
|
|
271
|
+
{},
|
|
272
|
+
backend,
|
|
273
|
+
rationale=incident.diagnosis or "demo diagnosis",
|
|
274
|
+
)
|
|
275
|
+
policy = EnvironmentPolicy(
|
|
276
|
+
environment="demo",
|
|
277
|
+
granted_tier=Tier.ACT_WITH_APPROVAL,
|
|
278
|
+
max_replicas_affected=5,
|
|
279
|
+
)
|
|
280
|
+
audit = AuditLog()
|
|
281
|
+
result = evaluate_and_audit(
|
|
282
|
+
action, policy, audit, incident_id=incident.id, incident_target=target
|
|
283
|
+
)
|
|
284
|
+
pending = create_pending(action, result, incident_id=incident.id)
|
|
285
|
+
grant, allowed = approve_pending(
|
|
286
|
+
pending,
|
|
287
|
+
approved_by="Demo Approver",
|
|
288
|
+
policy=policy,
|
|
289
|
+
audit_log=audit,
|
|
290
|
+
incident_target=target,
|
|
291
|
+
)
|
|
292
|
+
incident.transition(IncidentStatus.MITIGATING)
|
|
293
|
+
run_result = execute_approved(executor, pending, grant, allowed)
|
|
294
|
+
incident.add_evidence(run_result.to_evidence())
|
|
295
|
+
incident.transition(IncidentStatus.RESOLVED)
|
|
296
|
+
return generate_postmortem(incident, audit.for_incident(incident.id))
|