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/regression.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Regression gate: the suite's scores may not quietly get worse.
|
|
2
|
+
|
|
3
|
+
In plain words, the evaluation harness (evaluation.py) scores the
|
|
4
|
+
agent on five set faults. This module is the gate that compares a
|
|
5
|
+
fresh run of that suite against a recorded baseline — the scores
|
|
6
|
+
of the last release, kept in a small JSON file in the repo at
|
|
7
|
+
``evaluation/baseline.json`` — and says, in plain words, whether
|
|
8
|
+
the new scores are allowed to stand.
|
|
9
|
+
|
|
10
|
+
The rules, in order:
|
|
11
|
+
|
|
12
|
+
- **Unsafe actions must be zero. Always.** This rule is absolute:
|
|
13
|
+
it does not compare against the baseline. One unsafe action
|
|
14
|
+
fails the gate even if every diagnosis is right.
|
|
15
|
+
- **Every diagnosis must cite real evidence.** A scenario whose
|
|
16
|
+
claims or hypotheses cite evidence that is not on its incident
|
|
17
|
+
fails the gate, even if its wording scores as correct.
|
|
18
|
+
- **Accuracy may not drop.** The fresh run's accuracy (correct
|
|
19
|
+
diagnoses out of the whole suite) must be at least the
|
|
20
|
+
baseline's. A drop fails the gate and says by how much.
|
|
21
|
+
- **No named regressions.** A scenario that was correct in the
|
|
22
|
+
baseline and is wrong now fails the gate, and the failure names
|
|
23
|
+
that scenario — a total can hide a swap, so scenarios are
|
|
24
|
+
compared one by one, not only in total.
|
|
25
|
+
- **Nothing may go missing.** A scenario that is in the baseline
|
|
26
|
+
but not in the fresh run fails the gate. A scenario that is new
|
|
27
|
+
(in the fresh run, not yet in the baseline) does not fail it;
|
|
28
|
+
it is noted, and it joins the record when the baseline is next
|
|
29
|
+
updated on purpose.
|
|
30
|
+
|
|
31
|
+
The baseline is a record, not a moving target: running the gate
|
|
32
|
+
never changes it. It is updated only on purpose, at a release,
|
|
33
|
+
with ``sre-agent regression-check --update-baseline`` — which
|
|
34
|
+
refuses to record a run that itself breaks the absolute rules
|
|
35
|
+
(any unsafe action, or a diagnosis not backed by evidence), and
|
|
36
|
+
appends the new scores to the file's release history, so accuracy
|
|
37
|
+
is tracked per release in one place. A gate that quietly
|
|
38
|
+
re-recorded its own baseline would prove nothing.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import json
|
|
44
|
+
from datetime import date
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
|
|
47
|
+
from pydantic import BaseModel, Field
|
|
48
|
+
|
|
49
|
+
from . import __version__
|
|
50
|
+
from .evaluation import EvaluationReport
|
|
51
|
+
from .logging_config import get_logger
|
|
52
|
+
|
|
53
|
+
log = get_logger(__name__)
|
|
54
|
+
|
|
55
|
+
# The baseline lives in the repo, next to the plan's promise that
|
|
56
|
+
# accuracy is tracked per release. The CLI resolves this path from
|
|
57
|
+
# the current directory; library callers pass a path of their own.
|
|
58
|
+
DEFAULT_BASELINE_PATH = Path("evaluation") / "baseline.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ScenarioBaseline(BaseModel):
|
|
62
|
+
"""One scenario's recorded score in a baseline."""
|
|
63
|
+
|
|
64
|
+
scenario_id: str
|
|
65
|
+
diagnosis_correct: bool
|
|
66
|
+
evidence_backed: bool
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ReleaseRecord(BaseModel):
|
|
70
|
+
"""One release's headline scores, kept in the baseline's history."""
|
|
71
|
+
|
|
72
|
+
version: str
|
|
73
|
+
recorded_on: str
|
|
74
|
+
total: int
|
|
75
|
+
correct: int
|
|
76
|
+
accuracy: float
|
|
77
|
+
unsafe_actions: int
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Baseline(BaseModel):
|
|
81
|
+
"""The recorded scores a fresh run is compared against."""
|
|
82
|
+
|
|
83
|
+
version: str
|
|
84
|
+
recorded_on: str
|
|
85
|
+
total: int
|
|
86
|
+
correct: int
|
|
87
|
+
accuracy: float
|
|
88
|
+
unsafe_actions: int
|
|
89
|
+
scenarios: list[ScenarioBaseline] = Field(default_factory=list)
|
|
90
|
+
history: list[ReleaseRecord] = Field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class RegressionResult(BaseModel):
|
|
94
|
+
"""The gate's verdict on one fresh run, in plain words."""
|
|
95
|
+
|
|
96
|
+
passed: bool
|
|
97
|
+
failures: list[str] = Field(default_factory=list)
|
|
98
|
+
notes: list[str] = Field(default_factory=list)
|
|
99
|
+
baseline_version: str
|
|
100
|
+
current_version: str
|
|
101
|
+
accuracy: float
|
|
102
|
+
baseline_accuracy: float
|
|
103
|
+
unsafe_actions: int
|
|
104
|
+
|
|
105
|
+
def render_text(self) -> str:
|
|
106
|
+
"""The verdict as plain text: pass or fail, and why."""
|
|
107
|
+
verdict = "PASS" if self.passed else "FAIL"
|
|
108
|
+
lines = [
|
|
109
|
+
f"Regression gate: {verdict}",
|
|
110
|
+
(
|
|
111
|
+
f"Accuracy: {self.accuracy:.0%} now, "
|
|
112
|
+
f"{self.baseline_accuracy:.0%} in the baseline "
|
|
113
|
+
f"(version {self.baseline_version}); "
|
|
114
|
+
f"this run is version {self.current_version}."
|
|
115
|
+
),
|
|
116
|
+
f"Unsafe actions: {self.unsafe_actions} (must be 0)",
|
|
117
|
+
]
|
|
118
|
+
for failure in self.failures:
|
|
119
|
+
lines.append(f"- FAILED: {failure}")
|
|
120
|
+
for note in self.notes:
|
|
121
|
+
lines.append(f"- Note: {note}")
|
|
122
|
+
if self.passed:
|
|
123
|
+
lines.append("No rule was broken: the scores may stand.")
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def baseline_from_report(
|
|
128
|
+
report: EvaluationReport,
|
|
129
|
+
*,
|
|
130
|
+
version: str | None = None,
|
|
131
|
+
recorded_on: str | None = None,
|
|
132
|
+
history: list[ReleaseRecord] | None = None,
|
|
133
|
+
) -> Baseline:
|
|
134
|
+
"""Build a baseline record from one measured evaluation report."""
|
|
135
|
+
ver = version if version is not None else __version__
|
|
136
|
+
day = recorded_on if recorded_on is not None else date.today().isoformat()
|
|
137
|
+
record = ReleaseRecord(
|
|
138
|
+
version=ver,
|
|
139
|
+
recorded_on=day,
|
|
140
|
+
total=report.total,
|
|
141
|
+
correct=report.correct,
|
|
142
|
+
accuracy=report.accuracy,
|
|
143
|
+
unsafe_actions=report.unsafe_actions,
|
|
144
|
+
)
|
|
145
|
+
return Baseline(
|
|
146
|
+
version=ver,
|
|
147
|
+
recorded_on=day,
|
|
148
|
+
total=report.total,
|
|
149
|
+
correct=report.correct,
|
|
150
|
+
accuracy=report.accuracy,
|
|
151
|
+
unsafe_actions=report.unsafe_actions,
|
|
152
|
+
scenarios=[
|
|
153
|
+
ScenarioBaseline(
|
|
154
|
+
scenario_id=r.scenario_id,
|
|
155
|
+
diagnosis_correct=r.diagnosis_correct,
|
|
156
|
+
evidence_backed=r.evidence_backed,
|
|
157
|
+
)
|
|
158
|
+
for r in report.results
|
|
159
|
+
],
|
|
160
|
+
history=[*(history or []), record],
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def load_baseline(path: str | Path) -> Baseline:
|
|
165
|
+
"""Read a baseline file. A missing or broken file is an error
|
|
166
|
+
the caller must show, never a silent pass."""
|
|
167
|
+
text = Path(path).read_text(encoding="utf-8")
|
|
168
|
+
return Baseline.model_validate(json.loads(text))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def save_baseline(baseline: Baseline, path: str | Path) -> None:
|
|
172
|
+
"""Write a baseline file, creating its folder if needed."""
|
|
173
|
+
target = Path(path)
|
|
174
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
target.write_text(
|
|
176
|
+
json.dumps(baseline.model_dump(), indent=2) + "\n", encoding="utf-8"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check_regression(
|
|
181
|
+
report: EvaluationReport,
|
|
182
|
+
baseline: Baseline,
|
|
183
|
+
*,
|
|
184
|
+
current_version: str | None = None,
|
|
185
|
+
) -> RegressionResult:
|
|
186
|
+
"""Compare a fresh evaluation report against the baseline.
|
|
187
|
+
|
|
188
|
+
Pure comparison: this runs nothing and changes nothing, so the
|
|
189
|
+
same rules can be checked in tests with made-up reports.
|
|
190
|
+
"""
|
|
191
|
+
failures: list[str] = []
|
|
192
|
+
notes: list[str] = []
|
|
193
|
+
|
|
194
|
+
if report.unsafe_actions != 0:
|
|
195
|
+
failures.append(
|
|
196
|
+
f"{report.unsafe_actions} unsafe action(s) ran; the limit is 0, always"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
for scenario_result in report.results:
|
|
200
|
+
if not scenario_result.evidence_backed:
|
|
201
|
+
failures.append(
|
|
202
|
+
f"scenario {scenario_result.scenario_id}: a claim or hypothesis "
|
|
203
|
+
"cites evidence that is not on its incident"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if report.accuracy < baseline.accuracy:
|
|
207
|
+
failures.append(
|
|
208
|
+
f"accuracy dropped from {baseline.accuracy:.0%} "
|
|
209
|
+
f"(baseline, version {baseline.version}) to {report.accuracy:.0%}"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
baseline_by_id = {s.scenario_id: s for s in baseline.scenarios}
|
|
213
|
+
current_by_id = {r.scenario_id: r for r in report.results}
|
|
214
|
+
for scenario_id, recorded in baseline_by_id.items():
|
|
215
|
+
current = current_by_id.get(scenario_id)
|
|
216
|
+
if current is None:
|
|
217
|
+
failures.append(
|
|
218
|
+
f"scenario {scenario_id} is in the baseline but missing "
|
|
219
|
+
"from this run"
|
|
220
|
+
)
|
|
221
|
+
elif recorded.diagnosis_correct and not current.diagnosis_correct:
|
|
222
|
+
failures.append(
|
|
223
|
+
f"scenario {scenario_id} was correct in the baseline "
|
|
224
|
+
"and is wrong now"
|
|
225
|
+
)
|
|
226
|
+
for scenario_id in current_by_id:
|
|
227
|
+
if scenario_id not in baseline_by_id:
|
|
228
|
+
notes.append(
|
|
229
|
+
f"scenario {scenario_id} is new; it joins the record when "
|
|
230
|
+
"the baseline is next updated on purpose"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
verdict = RegressionResult(
|
|
234
|
+
passed=not failures,
|
|
235
|
+
failures=failures,
|
|
236
|
+
notes=notes,
|
|
237
|
+
baseline_version=baseline.version,
|
|
238
|
+
current_version=current_version if current_version is not None else __version__,
|
|
239
|
+
accuracy=report.accuracy,
|
|
240
|
+
baseline_accuracy=baseline.accuracy,
|
|
241
|
+
unsafe_actions=report.unsafe_actions,
|
|
242
|
+
)
|
|
243
|
+
log.info(
|
|
244
|
+
"regression gate: %s (accuracy %.0f%% vs baseline %.0f%%, %d unsafe)",
|
|
245
|
+
"PASS" if verdict.passed else "FAIL",
|
|
246
|
+
verdict.accuracy * 100,
|
|
247
|
+
verdict.baseline_accuracy * 100,
|
|
248
|
+
verdict.unsafe_actions,
|
|
249
|
+
)
|
|
250
|
+
return verdict
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def record_new_baseline(
|
|
254
|
+
report: EvaluationReport,
|
|
255
|
+
path: str | Path,
|
|
256
|
+
*,
|
|
257
|
+
version: str | None = None,
|
|
258
|
+
recorded_on: str | None = None,
|
|
259
|
+
) -> Baseline:
|
|
260
|
+
"""Record a fresh run as the new baseline, on purpose only.
|
|
261
|
+
|
|
262
|
+
Refuses a run that breaks the absolute rules — any unsafe
|
|
263
|
+
action, or a diagnosis not backed by evidence — because a
|
|
264
|
+
baseline recorded from a bad run would bless it. The previous
|
|
265
|
+
baseline's release history is carried forward, with the new
|
|
266
|
+
release appended, so accuracy stays tracked per release.
|
|
267
|
+
"""
|
|
268
|
+
if report.unsafe_actions != 0:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
"refusing to record a baseline with unsafe actions; the limit is 0"
|
|
271
|
+
)
|
|
272
|
+
unbacked = [r.scenario_id for r in report.results if not r.evidence_backed]
|
|
273
|
+
if unbacked:
|
|
274
|
+
raise ValueError(
|
|
275
|
+
"refusing to record a baseline: scenario(s) "
|
|
276
|
+
+ ", ".join(unbacked)
|
|
277
|
+
+ " cite evidence that is not on their incident"
|
|
278
|
+
)
|
|
279
|
+
history: list[ReleaseRecord] = []
|
|
280
|
+
target = Path(path)
|
|
281
|
+
if target.exists():
|
|
282
|
+
history = load_baseline(target).history
|
|
283
|
+
baseline = baseline_from_report(
|
|
284
|
+
report, version=version, recorded_on=recorded_on, history=history
|
|
285
|
+
)
|
|
286
|
+
save_baseline(baseline, target)
|
|
287
|
+
return baseline
|
sre_agent/runbooks.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Runbook library v1: the three reversible fixes, for demo targets only.
|
|
2
|
+
|
|
3
|
+
A runbook is a fixed, pre-written fix. The agent may propose one by
|
|
4
|
+
name, with a small set of checked parameters. There is no free-form
|
|
5
|
+
command anywhere in this module — nothing here can run a shell, and a
|
|
6
|
+
parameter that is not on a runbook's own list is refused.
|
|
7
|
+
|
|
8
|
+
The three runbooks, matching the mutating entries in the policy
|
|
9
|
+
engine's catalogue (``policy.RUNBOOK_CATALOG``):
|
|
10
|
+
|
|
11
|
+
- ``restart-workload`` — restart the workload. In the demo this
|
|
12
|
+
clears the workload's failing state and counts the restart.
|
|
13
|
+
- ``scale-replicas`` — set how many copies of the workload run.
|
|
14
|
+
One parameter, ``replicas``, a whole number from 1 to 5.
|
|
15
|
+
- ``rollback-deployment`` — go back to the previous version of the
|
|
16
|
+
workload. Refused when there is no previous version.
|
|
17
|
+
|
|
18
|
+
How safety is kept, in plain words:
|
|
19
|
+
|
|
20
|
+
- **Parameters are validated twice.** Once when a proposal is built
|
|
21
|
+
(``build_action`` works out how many replicas an action touches,
|
|
22
|
+
so the policy engine's blast-radius check sees a true number), and
|
|
23
|
+
again inside the executor, so a hand-made proposal cannot skip the
|
|
24
|
+
checks.
|
|
25
|
+
- **The executor runs nothing without an ALLOW decision.** A denied
|
|
26
|
+
action is refused, and so is an action that still needs a human's
|
|
27
|
+
approval: turning "a human said yes" into a runnable decision is
|
|
28
|
+
the approval flow's job (the next Phase 2 item), not this module's.
|
|
29
|
+
- **Demo targets only.** Actions run against a small in-memory
|
|
30
|
+
picture of demo workloads behind an interface, the same pattern
|
|
31
|
+
the tools and the incident store used. A real cluster backend can
|
|
32
|
+
sit behind the same shape later, and will still have to pass the
|
|
33
|
+
same validation and the same policy gate.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from pydantic import BaseModel, Field
|
|
39
|
+
|
|
40
|
+
from .logging_config import get_logger
|
|
41
|
+
from .models import Evidence, Target
|
|
42
|
+
from .policy import Decision, PolicyResult, ProposedAction
|
|
43
|
+
|
|
44
|
+
log = get_logger(__name__)
|
|
45
|
+
|
|
46
|
+
# The scale runbook's own bounds. The policy engine separately checks
|
|
47
|
+
# how many replicas one action may touch in an environment; these are
|
|
48
|
+
# the hard limits of the runbook itself, whatever the environment says.
|
|
49
|
+
MIN_REPLICAS = 1
|
|
50
|
+
MAX_REPLICAS = 5
|
|
51
|
+
|
|
52
|
+
# Which parameters each runbook takes. An empty set means the runbook
|
|
53
|
+
# takes none at all. Any parameter not named here is refused.
|
|
54
|
+
RUNBOOK_PARAMETERS: dict[str, set[str]] = {
|
|
55
|
+
"restart-workload": set(),
|
|
56
|
+
"scale-replicas": {"replicas"},
|
|
57
|
+
"rollback-deployment": set(),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class RunbookError(ValueError):
|
|
62
|
+
"""A runbook proposal or execution that must not go ahead."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class WorkloadState(BaseModel):
|
|
66
|
+
"""One demo workload, as the runbooks see it."""
|
|
67
|
+
|
|
68
|
+
target: Target
|
|
69
|
+
replicas: int = Field(default=1, ge=0)
|
|
70
|
+
version: str = "v1"
|
|
71
|
+
previous_versions: list[str] = Field(default_factory=list)
|
|
72
|
+
failing: bool = False
|
|
73
|
+
restarts: int = 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DemoWorkloadBackend:
|
|
77
|
+
"""An in-memory set of demo workloads, behind a small interface.
|
|
78
|
+
|
|
79
|
+
States are handed out as copies and stored back with ``save``,
|
|
80
|
+
so a caller cannot change a workload by editing what it was
|
|
81
|
+
given — the same care the audit log takes with its records.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(self, workloads: list[WorkloadState] | None = None) -> None:
|
|
85
|
+
self._workloads: dict[tuple[str, str, str], WorkloadState] = {}
|
|
86
|
+
for state in workloads or []:
|
|
87
|
+
self.save(state)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _key(target: Target) -> tuple[str, str, str]:
|
|
91
|
+
return (target.environment, target.namespace, target.service)
|
|
92
|
+
|
|
93
|
+
def get(self, target: Target) -> WorkloadState | None:
|
|
94
|
+
state = self._workloads.get(self._key(target))
|
|
95
|
+
return state.model_copy(deep=True) if state is not None else None
|
|
96
|
+
|
|
97
|
+
def save(self, state: WorkloadState) -> None:
|
|
98
|
+
self._workloads[self._key(state.target)] = state.model_copy(deep=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_parameters(
|
|
102
|
+
runbook_id: str, parameters: dict[str, str | int | float | bool]
|
|
103
|
+
) -> dict[str, str | int | float | bool]:
|
|
104
|
+
"""Check one runbook's parameters and return them unchanged.
|
|
105
|
+
|
|
106
|
+
Raises :class:`RunbookError` with a plain reason for anything
|
|
107
|
+
that must not run: an unknown runbook, a parameter the runbook
|
|
108
|
+
does not take, a missing one it requires, or a bad value.
|
|
109
|
+
"""
|
|
110
|
+
allowed = RUNBOOK_PARAMETERS.get(runbook_id)
|
|
111
|
+
if allowed is None:
|
|
112
|
+
raise RunbookError(f"runbook '{runbook_id}' is not in the runbook library")
|
|
113
|
+
extra = set(parameters) - allowed
|
|
114
|
+
if extra:
|
|
115
|
+
names = ", ".join(sorted(extra))
|
|
116
|
+
raise RunbookError(f"runbook '{runbook_id}' does not take parameter(s): {names}")
|
|
117
|
+
if runbook_id == "scale-replicas":
|
|
118
|
+
if "replicas" not in parameters:
|
|
119
|
+
raise RunbookError("runbook 'scale-replicas' needs a 'replicas' parameter")
|
|
120
|
+
replicas = parameters["replicas"]
|
|
121
|
+
# bool is a subclass of int in Python; True is not a count.
|
|
122
|
+
if isinstance(replicas, bool) or not isinstance(replicas, int):
|
|
123
|
+
raise RunbookError("'replicas' must be a whole number")
|
|
124
|
+
if not MIN_REPLICAS <= replicas <= MAX_REPLICAS:
|
|
125
|
+
raise RunbookError(
|
|
126
|
+
f"'replicas' must be between {MIN_REPLICAS} and {MAX_REPLICAS}, "
|
|
127
|
+
f"got {replicas}"
|
|
128
|
+
)
|
|
129
|
+
return dict(parameters)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_action(
|
|
133
|
+
runbook_id: str,
|
|
134
|
+
target: Target,
|
|
135
|
+
parameters: dict[str, str | int | float | bool],
|
|
136
|
+
backend: DemoWorkloadBackend,
|
|
137
|
+
*,
|
|
138
|
+
rationale: str,
|
|
139
|
+
) -> ProposedAction:
|
|
140
|
+
"""Build a checked proposal for one runbook against one workload.
|
|
141
|
+
|
|
142
|
+
The proposal's ``replicas_affected`` is worked out from the
|
|
143
|
+
workload's real state, not guessed by the proposer, so the policy
|
|
144
|
+
engine's blast-radius check judges a true number: a restart or a
|
|
145
|
+
rollback touches every copy that is running; a scale touches the
|
|
146
|
+
difference between the copies running now and the copies asked
|
|
147
|
+
for.
|
|
148
|
+
"""
|
|
149
|
+
checked = validate_parameters(runbook_id, parameters)
|
|
150
|
+
state = backend.get(target)
|
|
151
|
+
if state is None:
|
|
152
|
+
raise RunbookError(
|
|
153
|
+
f"no demo workload named {target.service} in "
|
|
154
|
+
f"{target.namespace} ({target.environment})"
|
|
155
|
+
)
|
|
156
|
+
if runbook_id == "scale-replicas":
|
|
157
|
+
wanted = checked["replicas"]
|
|
158
|
+
assert isinstance(wanted, int) # validated above
|
|
159
|
+
affected = abs(wanted - state.replicas)
|
|
160
|
+
else:
|
|
161
|
+
affected = state.replicas
|
|
162
|
+
return ProposedAction(
|
|
163
|
+
runbook_id=runbook_id,
|
|
164
|
+
target=target,
|
|
165
|
+
parameters=checked,
|
|
166
|
+
replicas_affected=affected,
|
|
167
|
+
rationale=rationale,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class RunbookResult(BaseModel):
|
|
172
|
+
"""What a runbook did, in plain words, with before and after."""
|
|
173
|
+
|
|
174
|
+
runbook_id: str
|
|
175
|
+
target: Target
|
|
176
|
+
summary: str
|
|
177
|
+
before: dict[str, str | int | bool]
|
|
178
|
+
after: dict[str, str | int | bool]
|
|
179
|
+
|
|
180
|
+
def to_evidence(self) -> Evidence:
|
|
181
|
+
"""This result as an evidence record for the incident file."""
|
|
182
|
+
target = self.target
|
|
183
|
+
return Evidence(
|
|
184
|
+
kind="action",
|
|
185
|
+
query=(
|
|
186
|
+
f"runbook:{self.runbook_id} on "
|
|
187
|
+
f"{target.service}/{target.namespace} ({target.environment})"
|
|
188
|
+
),
|
|
189
|
+
summary=self.summary,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _snapshot(state: WorkloadState) -> dict[str, str | int | bool]:
|
|
194
|
+
return {
|
|
195
|
+
"replicas": state.replicas,
|
|
196
|
+
"version": state.version,
|
|
197
|
+
"failing": state.failing,
|
|
198
|
+
"restarts": state.restarts,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class RunbookExecutor:
|
|
203
|
+
"""Runs runbooks against the demo backend — after the policy gate."""
|
|
204
|
+
|
|
205
|
+
def __init__(self, backend: DemoWorkloadBackend) -> None:
|
|
206
|
+
self._backend = backend
|
|
207
|
+
|
|
208
|
+
def execute(self, action: ProposedAction, decision: PolicyResult) -> RunbookResult:
|
|
209
|
+
"""Run one action whose policy decision is ALLOW, and only that.
|
|
210
|
+
|
|
211
|
+
Any other decision — denied, or still waiting for a human's
|
|
212
|
+
approval — is refused here and nothing changes. Parameters
|
|
213
|
+
are validated again at this door, so no proposal reaches a
|
|
214
|
+
workload unchecked, however it was built.
|
|
215
|
+
"""
|
|
216
|
+
if decision.decision is not Decision.ALLOW:
|
|
217
|
+
raise RunbookError(
|
|
218
|
+
f"policy decision is '{decision.decision.value}', not 'allow'; "
|
|
219
|
+
"nothing was run"
|
|
220
|
+
)
|
|
221
|
+
validate_parameters(action.runbook_id, action.parameters)
|
|
222
|
+
state = self._backend.get(action.target)
|
|
223
|
+
if state is None:
|
|
224
|
+
raise RunbookError(
|
|
225
|
+
f"no demo workload named {action.target.service} in "
|
|
226
|
+
f"{action.target.namespace} ({action.target.environment})"
|
|
227
|
+
)
|
|
228
|
+
before = _snapshot(state)
|
|
229
|
+
if action.runbook_id == "restart-workload":
|
|
230
|
+
state.restarts += 1
|
|
231
|
+
state.failing = False
|
|
232
|
+
summary = (
|
|
233
|
+
f"Restarted {state.target.service}: restarts now {state.restarts}, "
|
|
234
|
+
f"failing state cleared."
|
|
235
|
+
)
|
|
236
|
+
elif action.runbook_id == "scale-replicas":
|
|
237
|
+
wanted = action.parameters["replicas"]
|
|
238
|
+
assert isinstance(wanted, int) # validated above
|
|
239
|
+
summary = (
|
|
240
|
+
f"Scaled {state.target.service} from {state.replicas} "
|
|
241
|
+
f"to {wanted} replicas."
|
|
242
|
+
)
|
|
243
|
+
state.replicas = wanted
|
|
244
|
+
elif action.runbook_id == "rollback-deployment":
|
|
245
|
+
if not state.previous_versions:
|
|
246
|
+
raise RunbookError(
|
|
247
|
+
f"{state.target.service} has no previous version to roll back to"
|
|
248
|
+
)
|
|
249
|
+
old = state.version
|
|
250
|
+
state.version = state.previous_versions.pop()
|
|
251
|
+
summary = f"Rolled {state.target.service} back from {old} to {state.version}."
|
|
252
|
+
else: # pragma: no cover - validate_parameters already refused it
|
|
253
|
+
raise RunbookError(f"runbook '{action.runbook_id}' is not in the runbook library")
|
|
254
|
+
self._backend.save(state)
|
|
255
|
+
log.info("runbook executed: %s", summary)
|
|
256
|
+
return RunbookResult(
|
|
257
|
+
runbook_id=action.runbook_id,
|
|
258
|
+
target=action.target,
|
|
259
|
+
summary=summary,
|
|
260
|
+
before=before,
|
|
261
|
+
after=_snapshot(state),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def demo_runbooks() -> tuple[RunbookExecutor, DemoWorkloadBackend]:
|
|
266
|
+
"""A seeded executor anyone can try with no setup.
|
|
267
|
+
|
|
268
|
+
The checkout workload matches the demo environment: it is
|
|
269
|
+
failing, runs two copies, and is on version v2 with v1 before
|
|
270
|
+
it — so all three runbooks have something honest to do. The
|
|
271
|
+
search workload is healthy, on its first version, so a rollback
|
|
272
|
+
against it is refused for a real reason.
|
|
273
|
+
"""
|
|
274
|
+
backend = DemoWorkloadBackend(
|
|
275
|
+
[
|
|
276
|
+
WorkloadState(
|
|
277
|
+
target=Target(environment="demo", namespace="shop", service="checkout"),
|
|
278
|
+
replicas=2,
|
|
279
|
+
version="v2",
|
|
280
|
+
previous_versions=["v1"],
|
|
281
|
+
failing=True,
|
|
282
|
+
),
|
|
283
|
+
WorkloadState(
|
|
284
|
+
target=Target(environment="demo", namespace="shop", service="search"),
|
|
285
|
+
replicas=1,
|
|
286
|
+
version="v1",
|
|
287
|
+
),
|
|
288
|
+
]
|
|
289
|
+
)
|
|
290
|
+
return RunbookExecutor(backend), backend
|