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/storage.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Database store: incidents, diagnoses and approvals that last.
|
|
2
|
+
|
|
3
|
+
In plain words, this is the Phase 7 database item. Until now every
|
|
4
|
+
incident lived in the server process's memory, so stopping the
|
|
5
|
+
server lost them all — diagnoses, evidence, and any approval still
|
|
6
|
+
waiting for a human included.
|
|
7
|
+
|
|
8
|
+
``SQLiteIncidentStore`` keeps exactly the same interface as the
|
|
9
|
+
in-memory ``IncidentStore`` in ``ingestion.py`` — same ``ingest``,
|
|
10
|
+
``get``, ``list_incidents`` and ``duplicate_count``, same dedupe,
|
|
11
|
+
grouping and intake caps, because all of that logic is inherited,
|
|
12
|
+
not rewritten — and writes every change through to one SQLite
|
|
13
|
+
file, using only Python's standard library (``sqlite3``), so no
|
|
14
|
+
new dependency was added. A server built with this store instead
|
|
15
|
+
of the in-memory one keeps working after a restart:
|
|
16
|
+
|
|
17
|
+
- **Incidents persist**, with everything on the record: status,
|
|
18
|
+
evidence, hypotheses, the diagnosis, and the memory report.
|
|
19
|
+
A diagnosis is a field on the incident, so it persists with it;
|
|
20
|
+
the server calls the store's ``save`` after a diagnosis and
|
|
21
|
+
after a run, which is what writes those later changes through.
|
|
22
|
+
- **The dedupe and grouping indexes persist too** — which alert
|
|
23
|
+
fingerprints belong to which incident, which group is still
|
|
24
|
+
open, and how many duplicates each incident has counted — so
|
|
25
|
+
after a restart a repeated alert is still a duplicate, a
|
|
26
|
+
related alert still groups, and a resolved alert still closes
|
|
27
|
+
its incident.
|
|
28
|
+
- **Approvals persist.** The server asks the store to keep each
|
|
29
|
+
pending approval when it is created and again when it is
|
|
30
|
+
decided, and asks for them back when it starts
|
|
31
|
+
(``save_approval`` / ``load_approvals``). A pending approval
|
|
32
|
+
restored after a restart is the same pending action, with the
|
|
33
|
+
same 15-minute expiry measured from when it was proposed —
|
|
34
|
+
a restart does not give a stale proposal new life, and a
|
|
35
|
+
decided approval stays decided, so it cannot be run twice
|
|
36
|
+
across a restart either.
|
|
37
|
+
|
|
38
|
+
How it is stored, plainly: each incident is one row holding its
|
|
39
|
+
full record as JSON (the same shape the API returns), the two
|
|
40
|
+
indexes and the duplicate counts are small rows of their own,
|
|
41
|
+
and each approval is one row holding the pending action as JSON
|
|
42
|
+
plus its decision (blank while it still waits). Every write
|
|
43
|
+
replaces those small tables from the in-memory state in one
|
|
44
|
+
transaction — the store is capped at 1,000 incidents, so this
|
|
45
|
+
stays small and simple, and a crash mid-write cannot leave half
|
|
46
|
+
an update behind.
|
|
47
|
+
|
|
48
|
+
Honest scope: this is a single-operator, single-file store, the
|
|
49
|
+
SQLite-first step the roadmap named — not a multi-writer
|
|
50
|
+
database, and nothing here claims Postgres or concurrent servers
|
|
51
|
+
sharing one file. The shipped ``app`` still uses the in-memory
|
|
52
|
+
store, so the demo behaves exactly as before; a deployment that
|
|
53
|
+
wants persistence builds its app with this store, in code:
|
|
54
|
+
|
|
55
|
+
store = SQLiteIncidentStore("sre-agent.db")
|
|
56
|
+
app = create_app(store=store, tool_layer=..., ...)
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import json
|
|
62
|
+
import sqlite3
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
from typing import Self
|
|
65
|
+
|
|
66
|
+
from .approvals import PendingAction
|
|
67
|
+
from .ingestion import (
|
|
68
|
+
MAX_EVIDENCE_PER_INCIDENT,
|
|
69
|
+
MAX_INCIDENTS,
|
|
70
|
+
IncidentStore,
|
|
71
|
+
IncomingAlert,
|
|
72
|
+
IngestResult,
|
|
73
|
+
)
|
|
74
|
+
from .logging_config import get_logger
|
|
75
|
+
from .models import Incident
|
|
76
|
+
|
|
77
|
+
log = get_logger(__name__)
|
|
78
|
+
|
|
79
|
+
_SCHEMA = """
|
|
80
|
+
CREATE TABLE IF NOT EXISTS incidents (
|
|
81
|
+
id TEXT PRIMARY KEY,
|
|
82
|
+
data TEXT NOT NULL
|
|
83
|
+
);
|
|
84
|
+
CREATE TABLE IF NOT EXISTS store_index (
|
|
85
|
+
kind TEXT NOT NULL,
|
|
86
|
+
key TEXT NOT NULL,
|
|
87
|
+
incident_id TEXT NOT NULL,
|
|
88
|
+
PRIMARY KEY (kind, key)
|
|
89
|
+
);
|
|
90
|
+
CREATE TABLE IF NOT EXISTS duplicate_counts (
|
|
91
|
+
incident_id TEXT PRIMARY KEY,
|
|
92
|
+
count INTEGER NOT NULL
|
|
93
|
+
);
|
|
94
|
+
CREATE TABLE IF NOT EXISTS approvals (
|
|
95
|
+
pending_id TEXT PRIMARY KEY,
|
|
96
|
+
incident_id TEXT NOT NULL,
|
|
97
|
+
data TEXT NOT NULL,
|
|
98
|
+
decision TEXT
|
|
99
|
+
);
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SQLiteIncidentStore(IncidentStore):
|
|
104
|
+
"""An ``IncidentStore`` that writes through to one SQLite file.
|
|
105
|
+
|
|
106
|
+
The in-memory logic is inherited unchanged; this class loads
|
|
107
|
+
the file's state at start-up and re-writes it after every
|
|
108
|
+
change (ingestion, a ``save`` from the server, an approval
|
|
109
|
+
being created or decided).
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
path: str | Path,
|
|
115
|
+
max_incidents: int = MAX_INCIDENTS,
|
|
116
|
+
max_evidence_per_incident: int = MAX_EVIDENCE_PER_INCIDENT,
|
|
117
|
+
) -> None:
|
|
118
|
+
super().__init__(
|
|
119
|
+
max_incidents=max_incidents,
|
|
120
|
+
max_evidence_per_incident=max_evidence_per_incident,
|
|
121
|
+
)
|
|
122
|
+
self._path = Path(path)
|
|
123
|
+
if self._path.parent != Path(""):
|
|
124
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
self._conn = sqlite3.connect(str(self._path), check_same_thread=False)
|
|
126
|
+
self._conn.executescript(_SCHEMA)
|
|
127
|
+
self._conn.commit()
|
|
128
|
+
self._load()
|
|
129
|
+
|
|
130
|
+
# -- lifecycle -------------------------------------------------
|
|
131
|
+
|
|
132
|
+
def close(self) -> None:
|
|
133
|
+
"""Close the database file. The data stays in the file."""
|
|
134
|
+
self._conn.close()
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> Self:
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *exc: object) -> None:
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
# -- loading ---------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _load(self) -> None:
|
|
145
|
+
for incident_id, data in self._conn.execute(
|
|
146
|
+
"SELECT id, data FROM incidents"
|
|
147
|
+
):
|
|
148
|
+
try:
|
|
149
|
+
incident = Incident.model_validate_json(data)
|
|
150
|
+
except ValueError:
|
|
151
|
+
log.exception(
|
|
152
|
+
"stored incident %s could not be read back; skipped",
|
|
153
|
+
incident_id,
|
|
154
|
+
)
|
|
155
|
+
continue
|
|
156
|
+
self._incidents[incident.id] = incident
|
|
157
|
+
for kind, key, incident_id in self._conn.execute(
|
|
158
|
+
"SELECT kind, key, incident_id FROM store_index"
|
|
159
|
+
):
|
|
160
|
+
if kind == "fingerprint":
|
|
161
|
+
self._incident_by_fingerprint[key] = incident_id
|
|
162
|
+
elif kind == "group":
|
|
163
|
+
group_key = tuple(json.loads(key))
|
|
164
|
+
self._active_by_group[group_key] = incident_id # type: ignore[assignment]
|
|
165
|
+
for incident_id, count in self._conn.execute(
|
|
166
|
+
"SELECT incident_id, count FROM duplicate_counts"
|
|
167
|
+
):
|
|
168
|
+
self._duplicate_counts[incident_id] = int(count)
|
|
169
|
+
if self._incidents:
|
|
170
|
+
log.info(
|
|
171
|
+
"loaded %d incident(s) from %s",
|
|
172
|
+
len(self._incidents),
|
|
173
|
+
self._path,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# -- writing ---------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _sync(self) -> None:
|
|
179
|
+
"""Write the incidents, indexes and duplicate counts out.
|
|
180
|
+
|
|
181
|
+
One transaction, replacing the three tables from the
|
|
182
|
+
in-memory state, so the file always holds a whole,
|
|
183
|
+
consistent picture — never half an ingestion.
|
|
184
|
+
"""
|
|
185
|
+
with self._conn:
|
|
186
|
+
self._conn.execute("DELETE FROM incidents")
|
|
187
|
+
self._conn.executemany(
|
|
188
|
+
"INSERT INTO incidents (id, data) VALUES (?, ?)",
|
|
189
|
+
[
|
|
190
|
+
(incident.id, incident.model_dump_json())
|
|
191
|
+
for incident in self._incidents.values()
|
|
192
|
+
],
|
|
193
|
+
)
|
|
194
|
+
self._conn.execute("DELETE FROM store_index")
|
|
195
|
+
rows: list[tuple[str, str, str]] = [
|
|
196
|
+
("fingerprint", fingerprint, incident_id)
|
|
197
|
+
for fingerprint, incident_id in (
|
|
198
|
+
self._incident_by_fingerprint.items()
|
|
199
|
+
)
|
|
200
|
+
]
|
|
201
|
+
rows += [
|
|
202
|
+
("group", json.dumps(list(group_key)), incident_id)
|
|
203
|
+
for group_key, incident_id in self._active_by_group.items()
|
|
204
|
+
]
|
|
205
|
+
self._conn.executemany(
|
|
206
|
+
"INSERT INTO store_index (kind, key, incident_id) "
|
|
207
|
+
"VALUES (?, ?, ?)",
|
|
208
|
+
rows,
|
|
209
|
+
)
|
|
210
|
+
self._conn.execute("DELETE FROM duplicate_counts")
|
|
211
|
+
self._conn.executemany(
|
|
212
|
+
"INSERT INTO duplicate_counts (incident_id, count) "
|
|
213
|
+
"VALUES (?, ?)",
|
|
214
|
+
list(self._duplicate_counts.items()),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# -- the store interface, with write-through -------------------
|
|
218
|
+
|
|
219
|
+
def ingest(self, alert: IncomingAlert) -> IngestResult:
|
|
220
|
+
result = super().ingest(alert)
|
|
221
|
+
self._sync()
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
def save(self, incident: Incident) -> None:
|
|
225
|
+
super().save(incident)
|
|
226
|
+
self._sync()
|
|
227
|
+
|
|
228
|
+
# -- approvals ---------------------------------------------------
|
|
229
|
+
|
|
230
|
+
def save_approval(
|
|
231
|
+
self, pending: PendingAction, decision: str | None
|
|
232
|
+
) -> None:
|
|
233
|
+
with self._conn:
|
|
234
|
+
self._conn.execute(
|
|
235
|
+
"INSERT INTO approvals (pending_id, incident_id, data, decision) "
|
|
236
|
+
"VALUES (?, ?, ?, ?) "
|
|
237
|
+
"ON CONFLICT(pending_id) DO UPDATE SET "
|
|
238
|
+
"data = excluded.data, decision = excluded.decision",
|
|
239
|
+
(
|
|
240
|
+
pending.id,
|
|
241
|
+
pending.incident_id,
|
|
242
|
+
pending.model_dump_json(),
|
|
243
|
+
decision,
|
|
244
|
+
),
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def load_approvals(
|
|
248
|
+
self,
|
|
249
|
+
) -> tuple[dict[str, PendingAction], dict[str, str]]:
|
|
250
|
+
pendings: dict[str, PendingAction] = {}
|
|
251
|
+
decisions: dict[str, str] = {}
|
|
252
|
+
for pending_id, data, decision in self._conn.execute(
|
|
253
|
+
"SELECT pending_id, data, decision FROM approvals"
|
|
254
|
+
):
|
|
255
|
+
try:
|
|
256
|
+
pending = PendingAction.model_validate_json(data)
|
|
257
|
+
except ValueError:
|
|
258
|
+
log.exception(
|
|
259
|
+
"stored approval %s could not be read back; skipped",
|
|
260
|
+
pending_id,
|
|
261
|
+
)
|
|
262
|
+
continue
|
|
263
|
+
pendings[pending.id] = pending
|
|
264
|
+
if decision:
|
|
265
|
+
decisions[pending.id] = decision
|
|
266
|
+
return pendings, decisions
|
sre_agent/tier3.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""Tier 3 for the demo: pre-approved fixes, and kill switch drills.
|
|
2
|
+
|
|
3
|
+
In plain words, Tier 3 is bounded autonomy. At Tier 2 a human
|
|
4
|
+
approves every fix one by one. At Tier 3 an environment is given,
|
|
5
|
+
in advance and in writing, a small set of fix types it may run on
|
|
6
|
+
its own — and only inside strict limits. Everything outside that
|
|
7
|
+
set still needs a human, and everything outside the limits is
|
|
8
|
+
refused outright.
|
|
9
|
+
|
|
10
|
+
This module is that grant for the one environment where it has
|
|
11
|
+
been earned so far: the demo. It has three parts:
|
|
12
|
+
|
|
13
|
+
- **The demo Tier 3 policy** (``demo_tier3_policy``). In the demo
|
|
14
|
+
environment, in the ``shop`` namespace only, the agent may run
|
|
15
|
+
two fix types on its own: restart a workload, and change how
|
|
16
|
+
many copies of it run (scale). Rolling back a deploy is *not*
|
|
17
|
+
pre-approved: a rollback changes which version is running, so
|
|
18
|
+
it still needs a human's yes, every time. One action may touch
|
|
19
|
+
at most three copies at once. This is the plan's rule in code:
|
|
20
|
+
grants widen one environment and one fix type at a time.
|
|
21
|
+
|
|
22
|
+
- **The Tier 3 runner** (``execute_tier3``). The one path a
|
|
23
|
+
pre-approved fix runs through. It asks the policy engine,
|
|
24
|
+
writes the decision to the audit log *before* anything runs,
|
|
25
|
+
and runs the fix only on an ALLOW. A decision that still needs
|
|
26
|
+
approval does not run here — needing a human means a human is
|
|
27
|
+
asked, through the Tier 2 approval flow, not skipped. A denied
|
|
28
|
+
action does not run either. A fix that runs leaves its result
|
|
29
|
+
available to be stored on the incident as evidence, like any
|
|
30
|
+
other run.
|
|
31
|
+
|
|
32
|
+
- **Kill switch drills** (``run_tier3_drills``). A drill is a
|
|
33
|
+
rehearsed check that the stop switch works, run the way a fire
|
|
34
|
+
drill is run: on purpose, in the demo, before it is needed for
|
|
35
|
+
real. The drills cover both halves of this module. Tier 3
|
|
36
|
+
drills prove the pre-approved fixes run, the fix that is not
|
|
37
|
+
pre-approved does not run on its own, and an action that is
|
|
38
|
+
too big or aimed outside the allowed namespace is refused.
|
|
39
|
+
Kill switch drills prove the halt: a fix runs, the environment
|
|
40
|
+
is halted, the same fix is then refused and nothing changes —
|
|
41
|
+
while read-only checks, which change nothing, may continue.
|
|
42
|
+
|
|
43
|
+
One design lesson from this pass, now part of the design: the
|
|
44
|
+
halt is checked at run time, inside the same policy call the
|
|
45
|
+
runner makes for every run, not remembered from when a fix was
|
|
46
|
+
first proposed. A halt pressed after a proposal but before a
|
|
47
|
+
run therefore stops that run — the same rule the approval flow
|
|
48
|
+
already follows when it re-checks policy at approval time. The
|
|
49
|
+
kill switch drill presses the halt *between* two runs to prove
|
|
50
|
+
exactly that, rather than only checking a halt that was set
|
|
51
|
+
before anything started.
|
|
52
|
+
|
|
53
|
+
Scope, stated honestly: Tier 3 here is the demo environment
|
|
54
|
+
only, against the in-memory demo workloads, like the rest of
|
|
55
|
+
the runbook work. No real system is touched, and no other
|
|
56
|
+
environment has a Tier 3 grant. The drills run with no server
|
|
57
|
+
and no Docker, so they run in pytest, in CI, and as
|
|
58
|
+
``sre-agent tier3-check``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
from pydantic import BaseModel, Field
|
|
64
|
+
|
|
65
|
+
from .audit import AuditLog, evaluate_and_audit
|
|
66
|
+
from .logging_config import get_logger
|
|
67
|
+
from .models import Target
|
|
68
|
+
from .policy import Decision, EnvironmentPolicy, PolicyResult, ProposedAction, Tier, evaluate
|
|
69
|
+
from .runbooks import (
|
|
70
|
+
DemoWorkloadBackend,
|
|
71
|
+
RunbookExecutor,
|
|
72
|
+
RunbookResult,
|
|
73
|
+
WorkloadState,
|
|
74
|
+
build_action,
|
|
75
|
+
demo_runbooks,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
log = get_logger(__name__)
|
|
79
|
+
|
|
80
|
+
CHECKOUT = Target(environment="demo", namespace="shop", service="checkout")
|
|
81
|
+
|
|
82
|
+
# The demo Tier 3 grant, named in one place so the policy, the
|
|
83
|
+
# drills, and the docs cannot quietly disagree about it.
|
|
84
|
+
TIER3_RUNBOOKS: set[str] = {"restart-workload", "scale-replicas"}
|
|
85
|
+
TIER3_MAX_REPLICAS_AFFECTED = 3
|
|
86
|
+
TIER3_ALLOWED_NAMESPACES: set[str] = {"shop"}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def demo_tier3_policy(*, halted: bool = False) -> EnvironmentPolicy:
|
|
90
|
+
"""The Tier 3 policy for the demo environment.
|
|
91
|
+
|
|
92
|
+
Restart and scale are pre-approved, inside the blast-radius
|
|
93
|
+
limit and the ``shop`` namespace only. Rollback is not in
|
|
94
|
+
the pre-approved set, so it is sent for a human's approval
|
|
95
|
+
like any other Tier 2 action. ``halted`` is how the kill
|
|
96
|
+
switch is pressed in a drill: the same policy, stopped.
|
|
97
|
+
"""
|
|
98
|
+
return EnvironmentPolicy(
|
|
99
|
+
environment="demo",
|
|
100
|
+
granted_tier=Tier.ACT_WITHIN_BOUNDS,
|
|
101
|
+
tier3_runbooks=set(TIER3_RUNBOOKS),
|
|
102
|
+
max_replicas_affected=TIER3_MAX_REPLICAS_AFFECTED,
|
|
103
|
+
allowed_namespaces=set(TIER3_ALLOWED_NAMESPACES),
|
|
104
|
+
halted=halted,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def execute_tier3(
|
|
109
|
+
executor: RunbookExecutor,
|
|
110
|
+
policy: EnvironmentPolicy,
|
|
111
|
+
audit_log: AuditLog,
|
|
112
|
+
action: ProposedAction,
|
|
113
|
+
*,
|
|
114
|
+
incident_id: str,
|
|
115
|
+
incident_target: Target | None = None,
|
|
116
|
+
) -> tuple[PolicyResult, RunbookResult | None]:
|
|
117
|
+
"""Run one fix through the Tier 3 path, or not at all.
|
|
118
|
+
|
|
119
|
+
The policy engine decides, the decision is written to the
|
|
120
|
+
audit log, and the fix runs only if the decision is ALLOW.
|
|
121
|
+
Any other decision — needs approval, or denied — runs
|
|
122
|
+
nothing, and the decision is returned so the caller can
|
|
123
|
+
route it honestly (for approval, through the Tier 2 flow).
|
|
124
|
+
"""
|
|
125
|
+
result = evaluate_and_audit(
|
|
126
|
+
action, policy, audit_log,
|
|
127
|
+
incident_id=incident_id, incident_target=incident_target,
|
|
128
|
+
)
|
|
129
|
+
if result.decision is not Decision.ALLOW:
|
|
130
|
+
return result, None
|
|
131
|
+
return result, executor.execute(action, result)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class DrillResult(BaseModel):
|
|
135
|
+
"""The scored result of one drill."""
|
|
136
|
+
|
|
137
|
+
drill_id: str
|
|
138
|
+
title: str
|
|
139
|
+
kind: str # "tier3" | "kill-switch"
|
|
140
|
+
passed: bool
|
|
141
|
+
checks: dict[str, bool] = Field(default_factory=dict)
|
|
142
|
+
failed_checks: list[str] = Field(default_factory=list)
|
|
143
|
+
actions_run: int = 0
|
|
144
|
+
summary: str = ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Tier3Report(BaseModel):
|
|
148
|
+
"""The scored result of the whole drill set."""
|
|
149
|
+
|
|
150
|
+
results: list[DrillResult] = Field(default_factory=list)
|
|
151
|
+
total: int = 0
|
|
152
|
+
passed_count: int = 0
|
|
153
|
+
actions_run: int = 0
|
|
154
|
+
|
|
155
|
+
def render_text(self) -> str:
|
|
156
|
+
"""The drill scores as plain text."""
|
|
157
|
+
lines = [
|
|
158
|
+
"Tier 3 drills (demo environment, seeded demo workloads)",
|
|
159
|
+
(
|
|
160
|
+
f"Drills: {self.total} Passed: {self.passed_count} "
|
|
161
|
+
f"Fixes run across the drills: {self.actions_run} "
|
|
162
|
+
"(each one pre-approved, inside its limits, and audited)"
|
|
163
|
+
),
|
|
164
|
+
(
|
|
165
|
+
"Demo Tier 3 grant: restart-workload and scale-replicas, "
|
|
166
|
+
"shop namespace only, at most "
|
|
167
|
+
f"{TIER3_MAX_REPLICAS_AFFECTED} replicas touched per action. "
|
|
168
|
+
"rollback-deployment is not pre-approved and still needs a human."
|
|
169
|
+
),
|
|
170
|
+
]
|
|
171
|
+
for result in self.results:
|
|
172
|
+
mark = "PASS" if result.passed else "FAIL"
|
|
173
|
+
lines.append(f"- [{mark}] {result.drill_id} ({result.kind}): {result.title}")
|
|
174
|
+
for name in result.failed_checks:
|
|
175
|
+
lines.append(f" FAILED check: {name}")
|
|
176
|
+
lines.append(
|
|
177
|
+
"Kill switch: halting the demo environment refuses every "
|
|
178
|
+
"further fix and changes nothing, while read-only checks "
|
|
179
|
+
"may continue. The halt is checked at run time, on every run."
|
|
180
|
+
)
|
|
181
|
+
return "\n".join(lines)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _finish(
|
|
185
|
+
drill_id: str,
|
|
186
|
+
title: str,
|
|
187
|
+
kind: str,
|
|
188
|
+
checks: dict[str, bool],
|
|
189
|
+
*,
|
|
190
|
+
actions_run: int,
|
|
191
|
+
summary: str,
|
|
192
|
+
) -> DrillResult:
|
|
193
|
+
failed = [name for name, ok in checks.items() if not ok]
|
|
194
|
+
return DrillResult(
|
|
195
|
+
drill_id=drill_id,
|
|
196
|
+
title=title,
|
|
197
|
+
kind=kind,
|
|
198
|
+
passed=not failed,
|
|
199
|
+
checks=checks,
|
|
200
|
+
failed_checks=failed,
|
|
201
|
+
actions_run=actions_run,
|
|
202
|
+
summary=summary,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _drill_restart_runs() -> DrillResult:
|
|
207
|
+
executor, backend = demo_runbooks()
|
|
208
|
+
audit = AuditLog()
|
|
209
|
+
action = build_action(
|
|
210
|
+
"restart-workload", CHECKOUT, {}, backend,
|
|
211
|
+
rationale="drill: checkout is failing",
|
|
212
|
+
)
|
|
213
|
+
result, run = execute_tier3(
|
|
214
|
+
executor, demo_tier3_policy(), audit, action,
|
|
215
|
+
incident_id="drill-restart", incident_target=CHECKOUT,
|
|
216
|
+
)
|
|
217
|
+
state = backend.get(CHECKOUT)
|
|
218
|
+
assert state is not None
|
|
219
|
+
checks = {
|
|
220
|
+
"policy_allowed_without_approval": result.decision is Decision.ALLOW,
|
|
221
|
+
"fix_ran": run is not None,
|
|
222
|
+
"failure_cleared": state.failing is False and state.restarts == 1,
|
|
223
|
+
"decision_audited_before_the_run": len(audit.for_incident("drill-restart")) == 1,
|
|
224
|
+
}
|
|
225
|
+
return _finish(
|
|
226
|
+
"tier3-restart-runs", "A pre-approved restart runs on its own",
|
|
227
|
+
"tier3", checks, actions_run=1 if run else 0,
|
|
228
|
+
summary=result.reason,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _drill_scale_runs() -> DrillResult:
|
|
233
|
+
executor, backend = demo_runbooks()
|
|
234
|
+
audit = AuditLog()
|
|
235
|
+
action = build_action(
|
|
236
|
+
"scale-replicas", CHECKOUT, {"replicas": 3}, backend,
|
|
237
|
+
rationale="drill: one more copy is needed",
|
|
238
|
+
)
|
|
239
|
+
result, run = execute_tier3(
|
|
240
|
+
executor, demo_tier3_policy(), audit, action,
|
|
241
|
+
incident_id="drill-scale", incident_target=CHECKOUT,
|
|
242
|
+
)
|
|
243
|
+
state = backend.get(CHECKOUT)
|
|
244
|
+
assert state is not None
|
|
245
|
+
checks = {
|
|
246
|
+
"policy_allowed_without_approval": result.decision is Decision.ALLOW,
|
|
247
|
+
"fix_ran": run is not None,
|
|
248
|
+
"copies_now_three": state.replicas == 3,
|
|
249
|
+
"decision_audited_before_the_run": len(audit.for_incident("drill-scale")) == 1,
|
|
250
|
+
}
|
|
251
|
+
return _finish(
|
|
252
|
+
"tier3-scale-runs", "A pre-approved scale runs on its own",
|
|
253
|
+
"tier3", checks, actions_run=1 if run else 0,
|
|
254
|
+
summary=result.reason,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _drill_rollback_needs_approval() -> DrillResult:
|
|
259
|
+
executor, backend = demo_runbooks()
|
|
260
|
+
audit = AuditLog()
|
|
261
|
+
action = build_action(
|
|
262
|
+
"rollback-deployment", CHECKOUT, {}, backend,
|
|
263
|
+
rationale="drill: the new version looks bad",
|
|
264
|
+
)
|
|
265
|
+
result, run = execute_tier3(
|
|
266
|
+
executor, demo_tier3_policy(), audit, action,
|
|
267
|
+
incident_id="drill-rollback", incident_target=CHECKOUT,
|
|
268
|
+
)
|
|
269
|
+
state = backend.get(CHECKOUT)
|
|
270
|
+
assert state is not None
|
|
271
|
+
checks = {
|
|
272
|
+
"policy_sends_it_for_approval": result.decision is Decision.REQUIRE_APPROVAL,
|
|
273
|
+
"nothing_ran_on_its_own": run is None,
|
|
274
|
+
"version_unchanged": state.version == "v2",
|
|
275
|
+
"decision_audited": len(audit.for_incident("drill-rollback")) == 1,
|
|
276
|
+
}
|
|
277
|
+
return _finish(
|
|
278
|
+
"tier3-rollback-needs-approval",
|
|
279
|
+
"Rollback is not pre-approved: it still needs a human",
|
|
280
|
+
"tier3", checks, actions_run=0,
|
|
281
|
+
summary=result.reason,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _drill_blast_radius_denied() -> DrillResult:
|
|
286
|
+
big = Target(environment="demo", namespace="shop", service="gateway")
|
|
287
|
+
backend = DemoWorkloadBackend(
|
|
288
|
+
[WorkloadState(target=big, replicas=5, version="v1", failing=True)]
|
|
289
|
+
)
|
|
290
|
+
executor = RunbookExecutor(backend)
|
|
291
|
+
audit = AuditLog()
|
|
292
|
+
action = build_action(
|
|
293
|
+
"restart-workload", big, {}, backend,
|
|
294
|
+
rationale="drill: gateway is failing",
|
|
295
|
+
)
|
|
296
|
+
result, run = execute_tier3(
|
|
297
|
+
executor, demo_tier3_policy(), audit, action,
|
|
298
|
+
incident_id="drill-blast", incident_target=big,
|
|
299
|
+
)
|
|
300
|
+
state = backend.get(big)
|
|
301
|
+
assert state is not None
|
|
302
|
+
checks = {
|
|
303
|
+
"action_touches_five_copies": action.replicas_affected == 5,
|
|
304
|
+
"policy_denied_it": result.decision is Decision.DENY,
|
|
305
|
+
"nothing_ran": run is None and state.restarts == 0 and state.failing is True,
|
|
306
|
+
"denial_audited": len(audit.for_incident("drill-blast")) == 1,
|
|
307
|
+
}
|
|
308
|
+
return _finish(
|
|
309
|
+
"tier3-blast-radius-denied",
|
|
310
|
+
"A fix that touches too many copies is refused",
|
|
311
|
+
"tier3", checks, actions_run=0,
|
|
312
|
+
summary=result.reason,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _drill_outside_namespace_denied() -> DrillResult:
|
|
317
|
+
outside = Target(environment="demo", namespace="billing", service="checkout")
|
|
318
|
+
backend = DemoWorkloadBackend(
|
|
319
|
+
[WorkloadState(target=outside, replicas=1, version="v1", failing=True)]
|
|
320
|
+
)
|
|
321
|
+
executor = RunbookExecutor(backend)
|
|
322
|
+
audit = AuditLog()
|
|
323
|
+
action = build_action(
|
|
324
|
+
"restart-workload", outside, {}, backend,
|
|
325
|
+
rationale="drill: a workload outside the granted namespace",
|
|
326
|
+
)
|
|
327
|
+
result, run = execute_tier3(
|
|
328
|
+
executor, demo_tier3_policy(), audit, action,
|
|
329
|
+
incident_id="drill-namespace", incident_target=outside,
|
|
330
|
+
)
|
|
331
|
+
state = backend.get(outside)
|
|
332
|
+
assert state is not None
|
|
333
|
+
checks = {
|
|
334
|
+
"policy_denied_it": result.decision is Decision.DENY,
|
|
335
|
+
"nothing_ran": run is None and state.restarts == 0,
|
|
336
|
+
"denial_audited": len(audit.for_incident("drill-namespace")) == 1,
|
|
337
|
+
}
|
|
338
|
+
return _finish(
|
|
339
|
+
"tier3-outside-namespace-denied",
|
|
340
|
+
"A fix outside the granted namespace is refused",
|
|
341
|
+
"tier3", checks, actions_run=0,
|
|
342
|
+
summary=result.reason,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _drill_kill_switch_stops_mutations() -> DrillResult:
|
|
347
|
+
executor, backend = demo_runbooks()
|
|
348
|
+
audit = AuditLog()
|
|
349
|
+
policy = demo_tier3_policy()
|
|
350
|
+
first = build_action(
|
|
351
|
+
"restart-workload", CHECKOUT, {}, backend,
|
|
352
|
+
rationale="drill: first fix, before the halt",
|
|
353
|
+
)
|
|
354
|
+
first_result, first_run = execute_tier3(
|
|
355
|
+
executor, policy, audit, first,
|
|
356
|
+
incident_id="drill-halt", incident_target=CHECKOUT,
|
|
357
|
+
)
|
|
358
|
+
# The kill switch is pressed here, between the two runs.
|
|
359
|
+
halted = demo_tier3_policy(halted=True)
|
|
360
|
+
second = build_action(
|
|
361
|
+
"restart-workload", CHECKOUT, {}, backend,
|
|
362
|
+
rationale="drill: second fix, after the halt",
|
|
363
|
+
)
|
|
364
|
+
second_result, second_run = execute_tier3(
|
|
365
|
+
executor, halted, audit, second,
|
|
366
|
+
incident_id="drill-halt", incident_target=CHECKOUT,
|
|
367
|
+
)
|
|
368
|
+
state = backend.get(CHECKOUT)
|
|
369
|
+
assert state is not None
|
|
370
|
+
checks = {
|
|
371
|
+
"first_fix_ran": first_result.decision is Decision.ALLOW and first_run is not None,
|
|
372
|
+
"halt_denies_the_second_fix": second_result.decision is Decision.DENY,
|
|
373
|
+
"nothing_ran_after_the_halt": second_run is None and state.restarts == 1,
|
|
374
|
+
"both_decisions_audited": len(audit.for_incident("drill-halt")) == 2,
|
|
375
|
+
}
|
|
376
|
+
return _finish(
|
|
377
|
+
"kill-switch-stops-mutations",
|
|
378
|
+
"The halt, pressed between two runs, stops the second one",
|
|
379
|
+
"kill-switch", checks, actions_run=1 if first_run else 0,
|
|
380
|
+
summary=second_result.reason,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _drill_kill_switch_reads_continue() -> DrillResult:
|
|
385
|
+
halted = demo_tier3_policy(halted=True)
|
|
386
|
+
read = ProposedAction(
|
|
387
|
+
runbook_id="query-metrics",
|
|
388
|
+
target=CHECKOUT,
|
|
389
|
+
parameters={},
|
|
390
|
+
replicas_affected=0,
|
|
391
|
+
rationale="drill: check the numbers while halted",
|
|
392
|
+
)
|
|
393
|
+
mutation = ProposedAction(
|
|
394
|
+
runbook_id="restart-workload",
|
|
395
|
+
target=CHECKOUT,
|
|
396
|
+
parameters={},
|
|
397
|
+
replicas_affected=2,
|
|
398
|
+
rationale="drill: a fix while halted",
|
|
399
|
+
)
|
|
400
|
+
read_result = evaluate(read, halted, incident_target=CHECKOUT)
|
|
401
|
+
mutation_result = evaluate(mutation, halted, incident_target=CHECKOUT)
|
|
402
|
+
checks = {
|
|
403
|
+
"reads_still_allowed": read_result.decision is Decision.ALLOW,
|
|
404
|
+
"mutations_denied": mutation_result.decision is Decision.DENY,
|
|
405
|
+
}
|
|
406
|
+
return _finish(
|
|
407
|
+
"kill-switch-reads-continue",
|
|
408
|
+
"While halted, looking may continue and fixing may not",
|
|
409
|
+
"kill-switch", checks, actions_run=0,
|
|
410
|
+
summary=mutation_result.reason,
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
_DRILLS = [
|
|
415
|
+
_drill_restart_runs,
|
|
416
|
+
_drill_scale_runs,
|
|
417
|
+
_drill_rollback_needs_approval,
|
|
418
|
+
_drill_blast_radius_denied,
|
|
419
|
+
_drill_outside_namespace_denied,
|
|
420
|
+
_drill_kill_switch_stops_mutations,
|
|
421
|
+
_drill_kill_switch_reads_continue,
|
|
422
|
+
]
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def run_tier3_drills() -> Tier3Report:
|
|
426
|
+
"""Run the whole drill set and return its scored report."""
|
|
427
|
+
results = [drill() for drill in _DRILLS]
|
|
428
|
+
report = Tier3Report(
|
|
429
|
+
results=results,
|
|
430
|
+
total=len(results),
|
|
431
|
+
passed_count=sum(1 for r in results if r.passed),
|
|
432
|
+
actions_run=sum(r.actions_run for r in results),
|
|
433
|
+
)
|
|
434
|
+
log.info(
|
|
435
|
+
"tier3 drills: %d/%d passed, %d fix(es) run",
|
|
436
|
+
report.passed_count,
|
|
437
|
+
report.total,
|
|
438
|
+
report.actions_run,
|
|
439
|
+
)
|
|
440
|
+
return report
|