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/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