mayhem-cli 0.5.1__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.
- mayhem/agent/__init__.py +1 -0
- mayhem/agent/cli.py +36 -0
- mayhem/agents/__init__.py +1 -0
- mayhem/agents/capabilities.py +106 -0
- mayhem/agents/executors.py +430 -0
- mayhem/agents/impact.py +729 -0
- mayhem/agents/lease_client.py +141 -0
- mayhem/agents/probes.py +284 -0
- mayhem/agents/protocol.py +134 -0
- mayhem/agents/server.py +281 -0
- mayhem/agents/sinks.py +60 -0
- mayhem/agents/transports.py +134 -0
- mayhem/agents/watchdog.py +140 -0
- mayhem/cli/__init__.py +11 -0
- mayhem/cli/app.py +154 -0
- mayhem/cli/campaign.py +496 -0
- mayhem/cli/config_cmd.py +47 -0
- mayhem/cli/context.py +23 -0
- mayhem/cli/dependency.py +429 -0
- mayhem/cli/exit_codes.py +24 -0
- mayhem/cli/experiment.py +24 -0
- mayhem/cli/lifecycle.py +805 -0
- mayhem/cli/resolver.py +72 -0
- mayhem/cli/services.py +459 -0
- mayhem/cli/style.py +101 -0
- mayhem/cli/toolkit.py +41 -0
- mayhem/cli/topology.py +127 -0
- mayhem/config.py +208 -0
- mayhem/controller/__init__.py +1 -0
- mayhem/controller/compensation.py +2156 -0
- mayhem/controller/executor.py +1719 -0
- mayhem/controller/janitor.py +196 -0
- mayhem/controller/observability_collector.py +382 -0
- mayhem/controller/observations.py +102 -0
- mayhem/controller/planner.py +715 -0
- mayhem/controller/recovery.py +245 -0
- mayhem/controller/resilience_report.py +585 -0
- mayhem/controller/resource_manager.py +457 -0
- mayhem/controller/safety.py +392 -0
- mayhem/domain/__init__.py +6 -0
- mayhem/domain/campaigns.py +118 -0
- mayhem/domain/cancellation.py +110 -0
- mayhem/domain/candidates.py +101 -0
- mayhem/domain/capabilities.py +86 -0
- mayhem/domain/catalog.py +727 -0
- mayhem/domain/checks.py +173 -0
- mayhem/domain/common.py +104 -0
- mayhem/domain/coverage.py +106 -0
- mayhem/domain/decisions.py +57 -0
- mayhem/domain/errors.py +87 -0
- mayhem/domain/events.py +61 -0
- mayhem/domain/execution_context.py +120 -0
- mayhem/domain/execution_loci.py +94 -0
- mayhem/domain/experiments.py +370 -0
- mayhem/domain/faults.py +239 -0
- mayhem/domain/identity.py +200 -0
- mayhem/domain/k8s_adapter.py +132 -0
- mayhem/domain/leases.py +186 -0
- mayhem/domain/load_strategy.py +98 -0
- mayhem/domain/m5_campaign.py +120 -0
- mayhem/domain/maniac.py +93 -0
- mayhem/domain/observability.py +146 -0
- mayhem/domain/outcomes.py +92 -0
- mayhem/domain/remote_agent_interface.py +70 -0
- mayhem/domain/resources.py +245 -0
- mayhem/domain/risks.py +61 -0
- mayhem/domain/run_outcome.py +146 -0
- mayhem/domain/runtime_adapter.py +256 -0
- mayhem/domain/success.py +329 -0
- mayhem/domain/topology.py +452 -0
- mayhem/infra/__init__.py +1 -0
- mayhem/infra/campaign_engine.py +205 -0
- mayhem/infra/candidate_gates.py +124 -0
- mayhem/infra/candidate_generator.py +110 -0
- mayhem/infra/coverage_repository.py +101 -0
- mayhem/infra/lease_repository.py +129 -0
- mayhem/infra/maniac.py +103 -0
- mayhem/infra/migrations.py +596 -0
- mayhem/infra/migrator.py +149 -0
- mayhem/infra/report.py +227 -0
- mayhem/infra/store.py +200 -0
- mayhem/py.typed +0 -0
- mayhem/spec.py +52 -0
- mayhem/toolkit/__init__.py +1 -0
- mayhem/toolkit/fingerprint.py +69 -0
- mayhem/toolkit/hashing.py +32 -0
- mayhem/toolkit/manifests/docker.yaml +11 -0
- mayhem/toolkit/manifests/podman.yaml +11 -0
- mayhem/toolkit/manifests/stress-ng.yaml +11 -0
- mayhem/toolkit/manifests/tc-netem.yaml +11 -0
- mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
- mayhem/toolkit/registry.py +185 -0
- mayhem/toolkit/tool_runner.py +129 -0
- mayhem/topology/__init__.py +10 -0
- mayhem/topology/providers/__init__.py +0 -0
- mayhem/topology/providers/adapter_registry.py +60 -0
- mayhem/topology/providers/base.py +31 -0
- mayhem/topology/providers/compose.py +207 -0
- mayhem/topology/providers/docker_adapter.py +277 -0
- mayhem/topology/providers/docker_runtime.py +461 -0
- mayhem/topology/providers/podman_adapter.py +328 -0
- mayhem/topology/resolve.py +196 -0
- mayhem/topology/service.py +158 -0
- mayhem_cli-0.5.1.dist-info/METADATA +555 -0
- mayhem_cli-0.5.1.dist-info/RECORD +107 -0
- mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
- mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
"""RunEngine — executes a frozen ExecutionPlan against real executors.
|
|
2
|
+
|
|
3
|
+
The engine owns every durable artifact of a run: runs/step_runs rows,
|
|
4
|
+
fault_invocations, recovery_records, and the event journal. Lease lifecycles
|
|
5
|
+
go through the LeaseClient so no state transition bypasses the domain rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import signal
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from mayhem.agents.executors import executor_for, read_boot_time
|
|
23
|
+
from mayhem.agents.impact import OBSERVATION_BLIND
|
|
24
|
+
from mayhem.agents.lease_client import LeaseClient
|
|
25
|
+
from mayhem.agents.probes import run_probe, verify_all
|
|
26
|
+
from mayhem.controller.observability_collector import (
|
|
27
|
+
SourceCollection,
|
|
28
|
+
collect_observability,
|
|
29
|
+
probe_to_verify,
|
|
30
|
+
)
|
|
31
|
+
from mayhem.controller.resilience_report import (
|
|
32
|
+
ResilienceReport,
|
|
33
|
+
build_resilience_report,
|
|
34
|
+
)
|
|
35
|
+
from mayhem.controller.safety import pre_exec_assertion, validate_plan
|
|
36
|
+
from mayhem.domain.cancellation import CancellationLevel, CancellationToken
|
|
37
|
+
from mayhem.domain.checks import CheckLocus
|
|
38
|
+
from mayhem.domain.common import utc_now
|
|
39
|
+
from mayhem.domain.events import Event, EventKind
|
|
40
|
+
from mayhem.domain.experiments import OnFailure
|
|
41
|
+
from mayhem.domain.leases import FaultLease, LeaseState, UndoOp, VerifyProbe
|
|
42
|
+
from mayhem.domain.run_outcome import RunVerdict
|
|
43
|
+
from mayhem.domain.success import (
|
|
44
|
+
CriteriaEvaluation,
|
|
45
|
+
Observation,
|
|
46
|
+
evaluate_criteria,
|
|
47
|
+
observations_for_step,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
if TYPE_CHECKING:
|
|
51
|
+
import sqlite3
|
|
52
|
+
|
|
53
|
+
from mayhem.domain.decisions import DecisionRef
|
|
54
|
+
from mayhem.topology.resolve import (
|
|
55
|
+
resolve_container,
|
|
56
|
+
resolve_identity,
|
|
57
|
+
resolve_process_identity,
|
|
58
|
+
resolve_status,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if TYPE_CHECKING:
|
|
62
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
63
|
+
from pathlib import Path
|
|
64
|
+
|
|
65
|
+
from mayhem.agents.sinks import LeaseSink
|
|
66
|
+
from mayhem.controller.resource_manager import ResourceManager
|
|
67
|
+
from mayhem.controller.safety import SafetyContext
|
|
68
|
+
from mayhem.domain.checks import SteadyStateCheck
|
|
69
|
+
from mayhem.domain.experiments import ExecutionPlan, PlannedFault, PlannedStep
|
|
70
|
+
from mayhem.domain.identity import ProcessRuntimeIdentity
|
|
71
|
+
from mayhem.domain.topology import TopologyGraph
|
|
72
|
+
from mayhem.infra.store import Store
|
|
73
|
+
from mayhem.toolkit.tool_runner import ToolResult
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class StepReport:
|
|
78
|
+
step_id: str
|
|
79
|
+
ok: bool
|
|
80
|
+
detail: str
|
|
81
|
+
status: str = (
|
|
82
|
+
"ok" # ok | bypassed | failed | target_drift | failed_to_apply | resource_conflict
|
|
83
|
+
)
|
|
84
|
+
measured: Mapping[str, object] = field(default_factory=dict) # ADR-M4-3 observations
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def bypassed(self) -> bool:
|
|
88
|
+
return self.status == "bypassed"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def target_drift(self) -> bool:
|
|
92
|
+
return self.status == "target_drift"
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def failed_to_apply(self) -> bool:
|
|
96
|
+
return self.status == "failed_to_apply"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def resource_conflict(self) -> bool:
|
|
100
|
+
return self.status == "resource_conflict"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class RunResult:
|
|
105
|
+
run_id: str
|
|
106
|
+
status: str # completed | failed | aborted
|
|
107
|
+
started_at_epoch_s: float
|
|
108
|
+
ended_at_epoch_s: float
|
|
109
|
+
steps: tuple[StepReport, ...] = ()
|
|
110
|
+
dirty_leases: tuple[str, ...] = field(default=())
|
|
111
|
+
verdict: RunVerdict | None = None # criteria-derived (ADR-M4-3), None when undecided
|
|
112
|
+
criteria_evaluation: CriteriaEvaluation | None = None
|
|
113
|
+
observability: tuple[SourceCollection, ...] = () # collected evidence (ADR-M4-4)
|
|
114
|
+
governing_decisions: tuple[DecisionRef, ...] = () # decision trace (ADR-M4-1)
|
|
115
|
+
resilience_report: ResilienceReport | None = None # end-of-run score + diagnosis
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def wall_seconds(self) -> float:
|
|
119
|
+
return self.ended_at_epoch_s - self.started_at_epoch_s
|
|
120
|
+
|
|
121
|
+
def summary_md(self) -> str:
|
|
122
|
+
"""End-of-run report: outcome, criteria, steps, cleanup, resilience."""
|
|
123
|
+
lines = [f"# Run {self.run_id}", "", "## outcome"]
|
|
124
|
+
lines.append(f"- **status**: {self.status}")
|
|
125
|
+
if self.verdict is not None:
|
|
126
|
+
lines.append(f"- **verdict**: {self.verdict.value}")
|
|
127
|
+
if self.governing_decisions:
|
|
128
|
+
summary = "; ".join(d.summary() for d in self.governing_decisions)
|
|
129
|
+
lines.append(f"- **decisions**: {summary}")
|
|
130
|
+
if any(not source.skipped for source in self.observability):
|
|
131
|
+
collected = sum(1 for c in self.observability if c.ok)
|
|
132
|
+
lines.append(
|
|
133
|
+
f"- **observations**: {collected}/{len(self.observability)} sources collected"
|
|
134
|
+
)
|
|
135
|
+
if self.started_at_epoch_s > 0:
|
|
136
|
+
start_iso = datetime.fromtimestamp(self.started_at_epoch_s, tz=UTC).isoformat(
|
|
137
|
+
timespec="seconds"
|
|
138
|
+
)
|
|
139
|
+
end_iso = datetime.fromtimestamp(self.ended_at_epoch_s, tz=UTC).isoformat(
|
|
140
|
+
timespec="seconds"
|
|
141
|
+
)
|
|
142
|
+
lines.append(f"- **window**: {start_iso} → {end_iso} ({self.wall_seconds:.1f}s)")
|
|
143
|
+
else:
|
|
144
|
+
lines.append(f"- **wall time**: {self.wall_seconds:.1f}s")
|
|
145
|
+
if self.criteria_evaluation is not None and not self.criteria_evaluation.empty:
|
|
146
|
+
lines += ["", "## success criteria"]
|
|
147
|
+
lines.append(self.criteria_evaluation.summary_md())
|
|
148
|
+
lines += ["", "## steps"]
|
|
149
|
+
if not self.steps:
|
|
150
|
+
lines.append("_no steps executed_")
|
|
151
|
+
for step in self.steps:
|
|
152
|
+
mark = "bypass" if step.status == "bypassed" else "ok" if step.ok else "FAIL"
|
|
153
|
+
lines.append(f"- [{mark}] {step.step_id}: {step.detail}")
|
|
154
|
+
if self.dirty_leases:
|
|
155
|
+
lines += ["", "## cleanup"]
|
|
156
|
+
for lease_id in self.dirty_leases:
|
|
157
|
+
lines.append(f"- **DIRTY LEASE** {lease_id}: manual remediation required")
|
|
158
|
+
if self.resilience_report is not None:
|
|
159
|
+
lines += ["", "## resilience"]
|
|
160
|
+
lines.append(self.resilience_report.summary_md())
|
|
161
|
+
return "\n".join(lines)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# Placeholder PID emitted by compensation templates; replaced with the live PID
|
|
165
|
+
# at execution time (ADR-0020), so the value is never older than the syscall.
|
|
166
|
+
_LIVE_PID = "@live-pid"
|
|
167
|
+
|
|
168
|
+
# Sentinel for a running container whose numeric host PID is unreachable
|
|
169
|
+
# (podman-machine on macOS, Docker Desktop): exec addressing still works.
|
|
170
|
+
_PID_UNAVAILABLE = 0
|
|
171
|
+
|
|
172
|
+
_STATUS_DETAIL_RE = re.compile(r"->\s*(?P<status>\d{3})\s*$")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _probe_status_from_detail(detail: str) -> int | None:
|
|
176
|
+
"""Extract an observed HTTP status from a probe detail like ``url -> 200``.
|
|
177
|
+
|
|
178
|
+
Returns None when the detail does not carry an observable status (probe
|
|
179
|
+
never reached the service), so a status criterion then reads *absent*.
|
|
180
|
+
"""
|
|
181
|
+
match = _STATUS_DETAIL_RE.search(detail.strip())
|
|
182
|
+
if match is None:
|
|
183
|
+
return None
|
|
184
|
+
return int(match.group("status"))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _step_db_status(report: StepReport) -> str:
|
|
188
|
+
"""Persisted step_runs.status for a report (verdict statuses echo)."""
|
|
189
|
+
if report.bypassed:
|
|
190
|
+
return "bypassed"
|
|
191
|
+
if report.target_drift:
|
|
192
|
+
return "target_drift"
|
|
193
|
+
if report.failed_to_apply:
|
|
194
|
+
return "failed_to_apply"
|
|
195
|
+
if report.resource_conflict:
|
|
196
|
+
return "resource_conflict"
|
|
197
|
+
return "completed" if report.ok else "failed"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _substitute_pids(
|
|
201
|
+
undo_ops: tuple[UndoOp, ...],
|
|
202
|
+
verify_probes: tuple[VerifyProbe, ...],
|
|
203
|
+
live_pids: dict[str, int],
|
|
204
|
+
*,
|
|
205
|
+
engine: str | None = None,
|
|
206
|
+
live_targets: Mapping[str, tuple[int, str]] | None = None,
|
|
207
|
+
) -> tuple[tuple[UndoOp, ...], tuple[VerifyProbe, ...]]:
|
|
208
|
+
"""Replace the ``@live-pid`` placeholder with a freshly resolved PID.
|
|
209
|
+
|
|
210
|
+
``live_pids`` maps a node_id to its current host PID. The placeholder value
|
|
211
|
+
itself is ``node_id:@live-pid`` (e.g. ``svc-api:@live-pid``) so we can tell
|
|
212
|
+
*which* target a placeholder belongs to; substitution is by node_id. When no
|
|
213
|
+
live PID is available the placeholder is left untouched, preserving the
|
|
214
|
+
plan-time value baked in by the compensation template.
|
|
215
|
+
|
|
216
|
+
When ``engine`` and ``live_targets`` (node_id → ``(pid, container_name)``)
|
|
217
|
+
are supplied for a **proc.pause** op, the substituted PID is additionally
|
|
218
|
+
addressed by its owning ``cont`` + ``engine`` so the executor can signal the
|
|
219
|
+
process *inside* the container (podman/docker ``exec``). This is required
|
|
220
|
+
when the container runtime lives in a detached VM (podman-machine on macOS),
|
|
221
|
+
where a host ``os.kill`` cannot reach the container PID namespace.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
cont_by_node = {nid: cont for nid, (_pid, cont) in (live_targets or {}).items()}
|
|
225
|
+
|
|
226
|
+
def _swap(value: object) -> object:
|
|
227
|
+
if isinstance(value, (list, tuple)):
|
|
228
|
+
return type(value)(_swap_leaf(v) for v in value)
|
|
229
|
+
return _swap_leaf(value)
|
|
230
|
+
|
|
231
|
+
def _swap_leaf(value: object) -> object:
|
|
232
|
+
if not isinstance(value, str) or _LIVE_PID not in value:
|
|
233
|
+
return value
|
|
234
|
+
node_id = value.split(":", 1)[0]
|
|
235
|
+
pid = live_pids.get(node_id)
|
|
236
|
+
if pid is None:
|
|
237
|
+
return value
|
|
238
|
+
return str(pid)
|
|
239
|
+
|
|
240
|
+
def _address(node_id: str) -> dict[str, str]:
|
|
241
|
+
if not engine:
|
|
242
|
+
return {}
|
|
243
|
+
cont = cont_by_node.get(node_id)
|
|
244
|
+
if not cont:
|
|
245
|
+
return {}
|
|
246
|
+
return {"cont": cont, "engine": engine}
|
|
247
|
+
|
|
248
|
+
def _boot_address(swapped: dict[str, object], has_live_pid: bool) -> dict[str, str]:
|
|
249
|
+
"""Attach the PID-reuse boot_time for host-mode process ops.
|
|
250
|
+
|
|
251
|
+
When a ``@live-pid`` placeholder was substituted for a process not
|
|
252
|
+
addressed by a container (host mode), we read the process's start time
|
|
253
|
+
(``/proc/<pid>/stat`` field 22) and record it on the op. The process
|
|
254
|
+
executor re-reads boot_time before signalling and refuses to signal a
|
|
255
|
+
recycled PID (ADR-M2 Phase 2.4 / ADR-M6-2).
|
|
256
|
+
"""
|
|
257
|
+
if not has_live_pid:
|
|
258
|
+
return {}
|
|
259
|
+
if "cont" in swapped or "engine" in swapped:
|
|
260
|
+
return {}
|
|
261
|
+
raw = swapped.get("pid")
|
|
262
|
+
try:
|
|
263
|
+
pid = int(str(raw))
|
|
264
|
+
except (TypeError, ValueError):
|
|
265
|
+
return {}
|
|
266
|
+
boot = read_boot_time(pid)
|
|
267
|
+
if boot is None:
|
|
268
|
+
return {}
|
|
269
|
+
return {"boot_time": str(boot)}
|
|
270
|
+
|
|
271
|
+
new_undo = tuple(
|
|
272
|
+
UndoOp(
|
|
273
|
+
op=op.op,
|
|
274
|
+
args={
|
|
275
|
+
**{k: _swap(v) for k, v in op.args.items()},
|
|
276
|
+
**_address(_resolved_node(op)),
|
|
277
|
+
**_boot_address(
|
|
278
|
+
{k: _swap(v) for k, v in op.args.items()},
|
|
279
|
+
any(isinstance(v, str) and _LIVE_PID in v for v in op.args.values()),
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
idempotent=op.idempotent,
|
|
283
|
+
)
|
|
284
|
+
for op in undo_ops
|
|
285
|
+
)
|
|
286
|
+
new_verify = tuple(
|
|
287
|
+
VerifyProbe(
|
|
288
|
+
probe=p.probe,
|
|
289
|
+
args={
|
|
290
|
+
**{k: _swap(v) for k, v in p.args.items()},
|
|
291
|
+
**_address(_resolved_verify_node(p)),
|
|
292
|
+
},
|
|
293
|
+
expect_present=p.expect_present,
|
|
294
|
+
)
|
|
295
|
+
for p in verify_probes
|
|
296
|
+
)
|
|
297
|
+
return new_undo, new_verify
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _resolved_verify_node(probe: VerifyProbe) -> str | None:
|
|
301
|
+
"""node_id whose ``@live-pid`` placeholder appears in a verify probe."""
|
|
302
|
+
for value in probe.args.values():
|
|
303
|
+
if isinstance(value, list):
|
|
304
|
+
for item in value:
|
|
305
|
+
if isinstance(item, str) and _LIVE_PID in item:
|
|
306
|
+
return item.split(":", 1)[0] or None
|
|
307
|
+
elif isinstance(value, str) and _LIVE_PID in value:
|
|
308
|
+
return value.split(":", 1)[0] or None
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _resolved_node(op: UndoOp) -> str | None:
|
|
313
|
+
"""node_id whose ``@live-pid`` placeholder was resolved in ``op``, if any."""
|
|
314
|
+
for value in op.args.values():
|
|
315
|
+
if not isinstance(value, str) or _LIVE_PID not in value:
|
|
316
|
+
continue
|
|
317
|
+
node_id = value.split(":", 1)[0]
|
|
318
|
+
if node_id:
|
|
319
|
+
return node_id
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _group_by_seq(steps: Sequence[PlannedStep]) -> list[list[PlannedStep]]:
|
|
324
|
+
"""Partition *consecutive* steps into batches that share a ``seq``.
|
|
325
|
+
|
|
326
|
+
Parallel blocks emit one step per container with a shared ``seq``; those form
|
|
327
|
+
a single batch the executor runs concurrently.
|
|
328
|
+
"""
|
|
329
|
+
groups: list[list[PlannedStep]] = []
|
|
330
|
+
for step in steps:
|
|
331
|
+
if groups and groups[-1][0].seq == step.seq:
|
|
332
|
+
groups[-1].append(step)
|
|
333
|
+
else:
|
|
334
|
+
groups.append([step])
|
|
335
|
+
return groups
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _continues_on_failure(step: PlannedStep) -> bool:
|
|
339
|
+
"""True when a failed fault step names ``on_failure: continue``.
|
|
340
|
+
|
|
341
|
+
Continue keeps the drill running after a failing round so the remaining
|
|
342
|
+
faults still get tested; the run is nevertheless reported ``failed``.
|
|
343
|
+
Non-fault steps (wait/check) have no failure policy and always abort.
|
|
344
|
+
"""
|
|
345
|
+
fault = step.fault
|
|
346
|
+
return fault is not None and fault.on_failure == OnFailure.CONTINUE
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _missing_live_pids(fault: PlannedFault, live_pids: dict[str, int]) -> set[str]:
|
|
350
|
+
"""Target node ids that require a live PID but could not be resolved.
|
|
351
|
+
|
|
352
|
+
In container mode each fault target is addressed by a ``node_id:@live-pid``
|
|
353
|
+
placeholder in the undo contract. A target that still carries that placeholder
|
|
354
|
+
after resolution has no usable process address, so injection must not proceed.
|
|
355
|
+
"""
|
|
356
|
+
needed: set[str] = set()
|
|
357
|
+
for op in fault.undo_ops:
|
|
358
|
+
for value in op.args.values():
|
|
359
|
+
if not isinstance(value, str) or _LIVE_PID not in value:
|
|
360
|
+
continue
|
|
361
|
+
node_id = value.split(":", 1)[0]
|
|
362
|
+
if node_id not in live_pids:
|
|
363
|
+
needed.add(node_id)
|
|
364
|
+
return needed
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
class RunEngine:
|
|
368
|
+
"""Sequential plan execution; abort-and-recover on first failing fault."""
|
|
369
|
+
|
|
370
|
+
def __init__(
|
|
371
|
+
self,
|
|
372
|
+
store: Store,
|
|
373
|
+
sink: LeaseSink,
|
|
374
|
+
*,
|
|
375
|
+
client_factory: Callable[[LeaseSink], LeaseClient] = LeaseClient,
|
|
376
|
+
spec_json: str = "{}",
|
|
377
|
+
sleeper: Callable[[float], None] = time.sleep,
|
|
378
|
+
checks: Mapping[str, SteadyStateCheck] | None = None,
|
|
379
|
+
safety: SafetyContext | None = None,
|
|
380
|
+
live_graph: Callable[[], TopologyGraph] | None = None,
|
|
381
|
+
abort_file: Path | None = None,
|
|
382
|
+
resource_manager: ResourceManager | None = None,
|
|
383
|
+
engine: str | None = None,
|
|
384
|
+
on_event: Callable[[Event], None] | None = None,
|
|
385
|
+
bypass: Mapping[tuple[str, str], str] | None = None,
|
|
386
|
+
cancellation: CancellationToken | None = None,
|
|
387
|
+
) -> None:
|
|
388
|
+
self._store = store
|
|
389
|
+
self._sink = sink
|
|
390
|
+
self._client = client_factory(sink)
|
|
391
|
+
self._spec_json = spec_json
|
|
392
|
+
self._sleeper = sleeper
|
|
393
|
+
self._checks = dict(checks or {})
|
|
394
|
+
self._safety = safety
|
|
395
|
+
self._live_graph = live_graph
|
|
396
|
+
self._abort_file = abort_file
|
|
397
|
+
self._abort_mode: str | None = None # "graceful" | "immediate"
|
|
398
|
+
self._resource_manager = resource_manager
|
|
399
|
+
self._engine = engine # podman/docker; pid/ip resolved at execution (ADR-0020)
|
|
400
|
+
self._on_event = on_event # in-process observer; invoked for every journaled event
|
|
401
|
+
# Verified-inert injections: {(fault_id, container): reason}. The engine
|
|
402
|
+
# skips those steps as "bypass due to <reason>" instead of failing the
|
|
403
|
+
# whole run (per-fault fail-safe, mirrors the impact-gate verdicts).
|
|
404
|
+
self._bypass = dict(bypass or {})
|
|
405
|
+
# Live process identities captured during target resolution last run
|
|
406
|
+
# (ADR-M2 Phase 2.4) — the PID-reuse guard compares these boot times
|
|
407
|
+
# against a re-read immediately before mutation.
|
|
408
|
+
self._resolved_process_identities: dict[str, ProcessRuntimeIdentity] = {}
|
|
409
|
+
# Cancellation escalation ladder (ADR-M2 Phase 2.5): a shared token the
|
|
410
|
+
# signal handler escalates grace -> term -> kill and the agent loop
|
|
411
|
+
# reads at every safe point.
|
|
412
|
+
self._cancellation = cancellation or CancellationToken()
|
|
413
|
+
|
|
414
|
+
# -- public -----------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
def execute(self, plan: ExecutionPlan) -> RunResult:
|
|
417
|
+
if self._safety is not None:
|
|
418
|
+
graph = self._live_graph() if self._live_graph else None
|
|
419
|
+
if graph is not None:
|
|
420
|
+
validate_plan(plan, graph, self._safety) # G1+G2, raises SafetyRefusedError
|
|
421
|
+
started = utc_now().timestamp()
|
|
422
|
+
self._open_run(plan)
|
|
423
|
+
self._emit(Event(kind=EventKind.RUN_STARTED, run_id=plan.run_id))
|
|
424
|
+
reports: list[StepReport] = []
|
|
425
|
+
dirty: list[str] = []
|
|
426
|
+
status = "completed"
|
|
427
|
+
with _AbortMatrix(self, plan.run_id):
|
|
428
|
+
for group in _group_by_seq(plan.steps):
|
|
429
|
+
if self._abort_requested():
|
|
430
|
+
status = "aborted"
|
|
431
|
+
break
|
|
432
|
+
if len(group) == 1:
|
|
433
|
+
report, lease_dirty = self._run_step(plan, group[0])
|
|
434
|
+
reports.append(report)
|
|
435
|
+
dirty.extend(lease_dirty)
|
|
436
|
+
if not report.ok:
|
|
437
|
+
status = "failed"
|
|
438
|
+
if not _continues_on_failure(group[0]):
|
|
439
|
+
break # abort_and_recover; later steps cancelled
|
|
440
|
+
else:
|
|
441
|
+
batch_reports, lease_dirty = self._run_parallel(plan, group)
|
|
442
|
+
reports.extend(batch_reports)
|
|
443
|
+
dirty.extend(lease_dirty)
|
|
444
|
+
if not all(r.ok for r in batch_reports):
|
|
445
|
+
status = "failed"
|
|
446
|
+
failed_ids = {r.step_id for r in batch_reports if not r.ok}
|
|
447
|
+
if any(
|
|
448
|
+
not _continues_on_failure(step)
|
|
449
|
+
for step in group
|
|
450
|
+
if step.id in failed_ids
|
|
451
|
+
):
|
|
452
|
+
break # a failing abort_and_recover step; later steps cancelled
|
|
453
|
+
if self._abort_mode == "immediate":
|
|
454
|
+
status = "aborted"
|
|
455
|
+
break
|
|
456
|
+
ended = utc_now().timestamp()
|
|
457
|
+
recovered: tuple[str, ...] = ()
|
|
458
|
+
if dirty or status in ("aborted", "failed"):
|
|
459
|
+
try:
|
|
460
|
+
recovered = self.recover_run(plan.run_id)
|
|
461
|
+
except Exception:
|
|
462
|
+
recovered = ()
|
|
463
|
+
if recovered and status == "aborted":
|
|
464
|
+
dirty = [d for d in dirty if d not in recovered]
|
|
465
|
+
verdict, evaluation = self._criteria_verdict(plan, reports, status)
|
|
466
|
+
collections = self._collect_observability(plan)
|
|
467
|
+
self._close_run(
|
|
468
|
+
plan.run_id,
|
|
469
|
+
status,
|
|
470
|
+
ended,
|
|
471
|
+
verdict=verdict,
|
|
472
|
+
evaluation=evaluation,
|
|
473
|
+
observability=collections,
|
|
474
|
+
)
|
|
475
|
+
resilience: ResilienceReport | None = None
|
|
476
|
+
try:
|
|
477
|
+
live = self._live_graph() if self._live_graph is not None else None
|
|
478
|
+
resilience = build_resilience_report(
|
|
479
|
+
plan,
|
|
480
|
+
reports,
|
|
481
|
+
dirty,
|
|
482
|
+
live,
|
|
483
|
+
self._engine,
|
|
484
|
+
)
|
|
485
|
+
except Exception:
|
|
486
|
+
resilience = None
|
|
487
|
+
result = RunResult(
|
|
488
|
+
run_id=plan.run_id,
|
|
489
|
+
status=status,
|
|
490
|
+
started_at_epoch_s=started,
|
|
491
|
+
ended_at_epoch_s=ended,
|
|
492
|
+
steps=tuple(reports),
|
|
493
|
+
dirty_leases=tuple(dirty),
|
|
494
|
+
verdict=verdict,
|
|
495
|
+
criteria_evaluation=evaluation,
|
|
496
|
+
observability=collections,
|
|
497
|
+
governing_decisions=plan.decision_refs,
|
|
498
|
+
resilience_report=resilience,
|
|
499
|
+
)
|
|
500
|
+
self._store.query(
|
|
501
|
+
"UPDATE runs SET summary_md = ? WHERE id = ?",
|
|
502
|
+
(result.summary_md(), plan.run_id),
|
|
503
|
+
)
|
|
504
|
+
kind = EventKind.RUN_COMPLETED if status == "completed" else EventKind.RUN_FAILED
|
|
505
|
+
self._emit(Event(kind=kind, run_id=plan.run_id))
|
|
506
|
+
return result
|
|
507
|
+
|
|
508
|
+
def recover_run(self, run_id: str) -> tuple[str, ...]:
|
|
509
|
+
"""Compensate any non-terminal leases a crashed run left behind."""
|
|
510
|
+
_recoverable = {
|
|
511
|
+
LeaseState.PENDING,
|
|
512
|
+
LeaseState.ACTIVE,
|
|
513
|
+
LeaseState.ORPHANED,
|
|
514
|
+
LeaseState.RELEASING,
|
|
515
|
+
}
|
|
516
|
+
recovered: list[str] = []
|
|
517
|
+
for lease in self._sink.active_leases():
|
|
518
|
+
if lease.run_id != run_id:
|
|
519
|
+
continue
|
|
520
|
+
if lease.state not in _recoverable:
|
|
521
|
+
# Terminal (released/expired) or dirty (terminal-pending-ack):
|
|
522
|
+
# no valid transition exists, so nothing to auto-recover.
|
|
523
|
+
continue
|
|
524
|
+
try:
|
|
525
|
+
if lease.state in (LeaseState.PENDING, LeaseState.ACTIVE):
|
|
526
|
+
releasing = self._client.mark_orphaned(lease.id, notes="engine recovery pass")
|
|
527
|
+
_ = releasing
|
|
528
|
+
self._client.mark_releasing(lease.id)
|
|
529
|
+
elif lease.state is LeaseState.ORPHANED:
|
|
530
|
+
# Already recorded as orphaned: resume the compensation
|
|
531
|
+
# by entering RELEASING for the confirm step.
|
|
532
|
+
self._client.mark_releasing(lease.id)
|
|
533
|
+
# RELEASING already: jump straight to confirmation.
|
|
534
|
+
final = self._client.confirm_release(lease.id, mechanism="watchdog")
|
|
535
|
+
recovered.append(final.id)
|
|
536
|
+
except Exception as exc:
|
|
537
|
+
self._client.mark_dirty(lease.id, notes=f"recovery failed: {exc}")
|
|
538
|
+
return tuple(recovered)
|
|
539
|
+
|
|
540
|
+
# -- internals --------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
def _abort_requested(self) -> bool:
|
|
543
|
+
"""True when cancellation has been requested at any ladder level.
|
|
544
|
+
|
|
545
|
+
Side effect: asserts the enforcement side of the ladder — TERM sends
|
|
546
|
+
SIGTERM to live payload toolkit processes, KILL sends SIGKILL.
|
|
547
|
+
"""
|
|
548
|
+
level = self._cancellation.level
|
|
549
|
+
if level == CancellationLevel.NONE:
|
|
550
|
+
# Not cancelled via the token; fall back to the legacy abort file.
|
|
551
|
+
if self._abort_file is not None and self._abort_file.exists():
|
|
552
|
+
self._cancellation.request(CancellationLevel.KILL)
|
|
553
|
+
level = CancellationLevel.KILL
|
|
554
|
+
else:
|
|
555
|
+
return False
|
|
556
|
+
self._sync_abort_mode(level)
|
|
557
|
+
self._enforce_ladder(level)
|
|
558
|
+
return True
|
|
559
|
+
|
|
560
|
+
def _sync_abort_mode(self, level: CancellationLevel) -> None:
|
|
561
|
+
"""Map the ladder level back onto the legacy ``_abort_mode`` string."""
|
|
562
|
+
self._abort_mode = {
|
|
563
|
+
CancellationLevel.GRACE: "graceful",
|
|
564
|
+
CancellationLevel.TERM: "term",
|
|
565
|
+
CancellationLevel.KILL: "immediate",
|
|
566
|
+
}[level]
|
|
567
|
+
|
|
568
|
+
def _enforce_ladder(self, level: CancellationLevel) -> None:
|
|
569
|
+
"""Signal live payload toolkit processes per the ladder rung.
|
|
570
|
+
|
|
571
|
+
``grace`` does nothing here: the cooperative boundary (next step /
|
|
572
|
+
fault edge) is the safe abort. ``term`` sends SIGTERM to every live
|
|
573
|
+
payload process tracked by active leases, then re-asserts; ``kill``
|
|
574
|
+
sends SIGKILL. Signals are best-effort: a payload that already exited
|
|
575
|
+
is simply skipped.
|
|
576
|
+
"""
|
|
577
|
+
if level == CancellationLevel.GRACE:
|
|
578
|
+
return
|
|
579
|
+
try:
|
|
580
|
+
leases = self._client.active_leases()
|
|
581
|
+
except Exception:
|
|
582
|
+
return
|
|
583
|
+
for lease in leases:
|
|
584
|
+
for pid, cont, engine in self._live_payload_pids(lease):
|
|
585
|
+
self._send_payload_signal(pid, level, cont, engine)
|
|
586
|
+
|
|
587
|
+
def _live_payload_pids(self, lease: FaultLease) -> list[tuple[int, str | None, str | None]]:
|
|
588
|
+
"""Extract ``(pid, cont, engine)`` for live payload targets in a lease."""
|
|
589
|
+
found: list[tuple[int, str | None, str | None]] = []
|
|
590
|
+
for op in lease.undo_ops:
|
|
591
|
+
raw = op.args.get("pid")
|
|
592
|
+
try:
|
|
593
|
+
pid = int(raw) if raw is not None else None
|
|
594
|
+
except (TypeError, ValueError):
|
|
595
|
+
continue
|
|
596
|
+
if pid is not None and (pid > 1 or (op.args.get("cont") and op.args.get("engine"))):
|
|
597
|
+
found.append((pid, op.args.get("cont"), op.args.get("engine")))
|
|
598
|
+
return found
|
|
599
|
+
|
|
600
|
+
def _send_payload_signal(
|
|
601
|
+
self,
|
|
602
|
+
pid: int,
|
|
603
|
+
level: CancellationLevel,
|
|
604
|
+
cont: str | None,
|
|
605
|
+
engine: str | None,
|
|
606
|
+
) -> None:
|
|
607
|
+
"""Deliver SIGTERM (term) or SIGKILL (kill) to a payload process.
|
|
608
|
+
|
|
609
|
+
Container-addressed payloads are signaled via ``<engine> kill`` so the
|
|
610
|
+
signal reaches the container's main process across the runtime
|
|
611
|
+
boundary (ADR-0020); otherwise a plain host ``os.kill``.
|
|
612
|
+
"""
|
|
613
|
+
sig = signal.SIGKILL if level == CancellationLevel.KILL else signal.SIGTERM
|
|
614
|
+
if cont and engine:
|
|
615
|
+
with contextlib.suppress(Exception):
|
|
616
|
+
import subprocess
|
|
617
|
+
|
|
618
|
+
subprocess.run(
|
|
619
|
+
[
|
|
620
|
+
engine,
|
|
621
|
+
"kill",
|
|
622
|
+
"--signal",
|
|
623
|
+
"SIGKILL" if sig == signal.SIGKILL else "TERM",
|
|
624
|
+
cont,
|
|
625
|
+
],
|
|
626
|
+
capture_output=True,
|
|
627
|
+
timeout=5,
|
|
628
|
+
check=False,
|
|
629
|
+
)
|
|
630
|
+
return
|
|
631
|
+
with contextlib.suppress(ProcessLookupError, PermissionError, OSError):
|
|
632
|
+
os.kill(pid, sig)
|
|
633
|
+
|
|
634
|
+
def _sleep_interruptible(self, seconds: float) -> None:
|
|
635
|
+
"""Sleep honoring the cancellation ladder: any rung cuts sleeps short."""
|
|
636
|
+
deadline = time.monotonic() + seconds
|
|
637
|
+
while True:
|
|
638
|
+
remaining = deadline - time.monotonic()
|
|
639
|
+
if remaining <= 0 or self._abort_requested():
|
|
640
|
+
return
|
|
641
|
+
chunk = min(0.1, remaining)
|
|
642
|
+
self._sleeper(chunk)
|
|
643
|
+
|
|
644
|
+
def _run_step(self, plan: ExecutionPlan, step: PlannedStep) -> tuple[StepReport, list[str]]:
|
|
645
|
+
action_type = getattr(step.raw_action, "type", "unknown")
|
|
646
|
+
self._insert_step(plan.run_id, step)
|
|
647
|
+
self._emit(
|
|
648
|
+
Event(
|
|
649
|
+
kind=EventKind.STEP_STARTED,
|
|
650
|
+
run_id=plan.run_id,
|
|
651
|
+
detail={"step": step.id},
|
|
652
|
+
)
|
|
653
|
+
)
|
|
654
|
+
try:
|
|
655
|
+
if step.fault is not None:
|
|
656
|
+
report, dirty = self._execute_fault(plan, step)
|
|
657
|
+
elif action_type == "wait":
|
|
658
|
+
duration = float(getattr(step.raw_action, "duration", 0.0) or 0.0)
|
|
659
|
+
self._sleeper(duration)
|
|
660
|
+
report = StepReport(step.id, True, f"waited {duration:.1f}s")
|
|
661
|
+
dirty = []
|
|
662
|
+
elif action_type == "check":
|
|
663
|
+
report, dirty = self._execute_check(step), []
|
|
664
|
+
elif action_type == "check_http":
|
|
665
|
+
report, dirty = self._execute_check_http(step), []
|
|
666
|
+
elif action_type == "check_spec":
|
|
667
|
+
report, dirty = self._execute_check_spec(step), []
|
|
668
|
+
elif action_type in ("start_load", "stop_load", "notify"):
|
|
669
|
+
report = StepReport(
|
|
670
|
+
step.id, True, f"{action_type} acknowledged (no backend wired yet)"
|
|
671
|
+
)
|
|
672
|
+
dirty = []
|
|
673
|
+
else:
|
|
674
|
+
report = StepReport(step.id, False, f"unplannable action {action_type!r}")
|
|
675
|
+
dirty = []
|
|
676
|
+
except Exception as exc:
|
|
677
|
+
report = StepReport(step.id, False, f"{type(exc).__name__}: {exc}")
|
|
678
|
+
dirty = []
|
|
679
|
+
self._finish_step(step, ok=report.ok, status=_step_db_status(report))
|
|
680
|
+
self._emit(
|
|
681
|
+
Event(
|
|
682
|
+
kind=(
|
|
683
|
+
EventKind.STEP_SKIPPED
|
|
684
|
+
if (report.bypassed or not report.ok)
|
|
685
|
+
else EventKind.STEP_FINISHED
|
|
686
|
+
),
|
|
687
|
+
run_id=plan.run_id,
|
|
688
|
+
detail={"step": step.id, "detail": report.detail},
|
|
689
|
+
)
|
|
690
|
+
)
|
|
691
|
+
return report, dirty
|
|
692
|
+
|
|
693
|
+
def _run_parallel(
|
|
694
|
+
self, plan: ExecutionPlan, steps: list[PlannedStep]
|
|
695
|
+
) -> tuple[list[StepReport], list[str]]:
|
|
696
|
+
"""Run several same-``seq`` fault steps concurrently.
|
|
697
|
+
|
|
698
|
+
Fault injection and duration sleeps overlap across threads; all durable
|
|
699
|
+
writes funnel through the single ``Store`` connection, which is
|
|
700
|
+
internally serialized, so no executor-side locking is required here.
|
|
701
|
+
"""
|
|
702
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed # noqa: PLC0415
|
|
703
|
+
|
|
704
|
+
reports: list[StepReport] = []
|
|
705
|
+
dirty: list[str] = []
|
|
706
|
+
with ThreadPoolExecutor(max_workers=len(steps)) as pool:
|
|
707
|
+
futures = [pool.submit(self._run_step, plan, step) for step in steps]
|
|
708
|
+
for future in as_completed(futures):
|
|
709
|
+
report, lease_dirty = future.result()
|
|
710
|
+
reports.append(report)
|
|
711
|
+
dirty.extend(lease_dirty)
|
|
712
|
+
return reports, dirty
|
|
713
|
+
|
|
714
|
+
def _execute_fault( # noqa: PLR0915, PLR0912 (converging inject/monitor/recover pipeline)
|
|
715
|
+
self, plan: ExecutionPlan, step: PlannedStep
|
|
716
|
+
) -> tuple[StepReport, list[str]]:
|
|
717
|
+
fault = step.fault
|
|
718
|
+
assert fault is not None # planner contract
|
|
719
|
+
targets: frozenset[str] = frozenset().union(*(t.node_ids for t in fault.targets))
|
|
720
|
+
if self._safety is not None and self._live_graph is not None:
|
|
721
|
+
# G3: re-check resolved targets against *live* topology seconds before injection.
|
|
722
|
+
pre_exec_assertion(
|
|
723
|
+
[(t.selector, t.node_ids) for t in fault.targets], self._live_graph()
|
|
724
|
+
)
|
|
725
|
+
# Resolve fresh PIDs at execution time (ADR-0020): the PID baked into the plan is
|
|
726
|
+
# a placeholder, never older than the injection syscall. Substitute the live value
|
|
727
|
+
# into the undo contract + verify probes before the lease forms.
|
|
728
|
+
live_targets = self._resolve_live_targets(fault)
|
|
729
|
+
bypass_reasons = {
|
|
730
|
+
cont: why
|
|
731
|
+
for (fid, cont), why in self._bypass.items()
|
|
732
|
+
if fid == fault.fault_id
|
|
733
|
+
and cont in {container for _pid, container in live_targets.values()}
|
|
734
|
+
}
|
|
735
|
+
if bypass_reasons:
|
|
736
|
+
# Fail-safe per fault: the impact gate proved this injection cannot
|
|
737
|
+
# take effect (missing tooling/capability), so skip it with an
|
|
738
|
+
# explicit reason instead of failing (or perturbing) the run.
|
|
739
|
+
note = "; ".join(f"{c}: {why}" for c, why in sorted(bypass_reasons.items()))
|
|
740
|
+
return (
|
|
741
|
+
StepReport(step.id, True, f"bypass due to {note}", status="bypassed"),
|
|
742
|
+
[],
|
|
743
|
+
)
|
|
744
|
+
# ADR-M2-3: live inspection wins over a stale journal. Re-resolve the
|
|
745
|
+
# target's current RuntimeIdentity and compare it to the planned one;
|
|
746
|
+
# a mismatch (container recreated under the same name) is TARGET_DRIFT —
|
|
747
|
+
# safe-abort this fault with no mutation.
|
|
748
|
+
drift = self._detect_target_drift(step, fault, live_targets)
|
|
749
|
+
if drift is not None:
|
|
750
|
+
return (
|
|
751
|
+
StepReport(
|
|
752
|
+
step.id,
|
|
753
|
+
False,
|
|
754
|
+
drift,
|
|
755
|
+
status="target_drift",
|
|
756
|
+
),
|
|
757
|
+
[],
|
|
758
|
+
)
|
|
759
|
+
live_pids = {node_id: pid for node_id, (pid, _cont) in live_targets.items()}
|
|
760
|
+
if self._live_graph is not None:
|
|
761
|
+
# Container mode: a target whose container PID cannot be resolved has no
|
|
762
|
+
# usable fallback, so fail the step *before* a lease forms instead of
|
|
763
|
+
# injecting against a stale placeholder and stranding a dirty lease.
|
|
764
|
+
missing = _missing_live_pids(fault, live_pids)
|
|
765
|
+
if missing:
|
|
766
|
+
return (
|
|
767
|
+
StepReport(
|
|
768
|
+
step.id,
|
|
769
|
+
False,
|
|
770
|
+
"cannot resolve live pid for " + ", ".join(sorted(missing)),
|
|
771
|
+
),
|
|
772
|
+
[],
|
|
773
|
+
)
|
|
774
|
+
undo_ops, verify_probes = _substitute_pids(
|
|
775
|
+
fault.undo_ops,
|
|
776
|
+
fault.verify_probes,
|
|
777
|
+
live_pids,
|
|
778
|
+
engine=self._engine,
|
|
779
|
+
live_targets=live_targets,
|
|
780
|
+
)
|
|
781
|
+
ttl = max(float(fault.duration) + 60.0, 120.0)
|
|
782
|
+
lease = self._client.acquire(
|
|
783
|
+
run_id=plan.run_id,
|
|
784
|
+
fault_id=fault.fault_id,
|
|
785
|
+
targets=targets,
|
|
786
|
+
undo_ops=tuple(op.model_dump(mode="json") for op in undo_ops),
|
|
787
|
+
verify_probes=tuple(p.model_dump(mode="json") for p in verify_probes),
|
|
788
|
+
ttl_seconds=ttl,
|
|
789
|
+
runtime_identity=(
|
|
790
|
+
fault.runtime_identity.key() if fault.runtime_identity is not None else None
|
|
791
|
+
),
|
|
792
|
+
)
|
|
793
|
+
self._record_invocation(plan, step, fault, lease.id)
|
|
794
|
+
|
|
795
|
+
self._client.activate(lease.id)
|
|
796
|
+
self._emit(
|
|
797
|
+
Event(
|
|
798
|
+
kind=EventKind.FAULT_INJECTED,
|
|
799
|
+
run_id=plan.run_id,
|
|
800
|
+
detail={"fault": fault.fault_id, "lease": lease.id},
|
|
801
|
+
)
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
# Resource ownership tracking (ADR-0015)
|
|
805
|
+
tracked_resource = None
|
|
806
|
+
if self._resource_manager is not None:
|
|
807
|
+
from mayhem.domain.leases import UndoOp, VerifyProbe # noqa: PLC0415
|
|
808
|
+
from mayhem.domain.resources import ResourceType # noqa: PLC0415
|
|
809
|
+
|
|
810
|
+
tracked_resource = self._resource_manager.register(
|
|
811
|
+
resource_type=ResourceType.GENERIC, # specific type inferred from fault_id
|
|
812
|
+
run_id=plan.run_id,
|
|
813
|
+
step_id=step.id,
|
|
814
|
+
fault_id=fault.fault_id,
|
|
815
|
+
target_identity=lease.runtime_identity or "|".join(sorted(lease.targets)),
|
|
816
|
+
cleanup_op=UndoOp(op="lease.compensate", args={"lease_id": lease.id}),
|
|
817
|
+
verify_probe=VerifyProbe(
|
|
818
|
+
probe="lease.verify",
|
|
819
|
+
args={"lease_id": lease.id},
|
|
820
|
+
expect_present=False,
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
# Phase 2.7: one-inflight-writer — reject if another lease holds
|
|
825
|
+
# an active mutation on the same target.
|
|
826
|
+
conflict_reason = self._resource_manager.check_no_inflight_writer(
|
|
827
|
+
lease.runtime_identity or "|".join(sorted(lease.targets)),
|
|
828
|
+
exclude_run_id=plan.run_id,
|
|
829
|
+
)
|
|
830
|
+
if conflict_reason is not None:
|
|
831
|
+
self._client.mark_releasing(lease.id)
|
|
832
|
+
self._client.confirm_release(lease.id, mechanism="resource_conflict")
|
|
833
|
+
self._emit(
|
|
834
|
+
Event(
|
|
835
|
+
kind=EventKind.FAULT_FAILED,
|
|
836
|
+
run_id=plan.run_id,
|
|
837
|
+
detail={
|
|
838
|
+
"fault": fault.fault_id,
|
|
839
|
+
"lease": lease.id,
|
|
840
|
+
"status": "resource_conflict",
|
|
841
|
+
"reason": conflict_reason,
|
|
842
|
+
},
|
|
843
|
+
),
|
|
844
|
+
)
|
|
845
|
+
# Transition tracked resource to DIRTY (cleanup failed)
|
|
846
|
+
if tracked_resource is not None:
|
|
847
|
+
self._resource_manager.start_recovery(tracked_resource.id)
|
|
848
|
+
self._resource_manager.mark_recovered(tracked_resource.id, False)
|
|
849
|
+
return (
|
|
850
|
+
StepReport(
|
|
851
|
+
step.id,
|
|
852
|
+
False,
|
|
853
|
+
f"{conflict_reason} (safe-aborted, no mutation)",
|
|
854
|
+
"resource_conflict",
|
|
855
|
+
),
|
|
856
|
+
[],
|
|
857
|
+
)
|
|
858
|
+
|
|
859
|
+
executor = executor_for(fault.fault_id)
|
|
860
|
+
# ADR-M2 Phase 2.3: revalidate the execution-time capability at the
|
|
861
|
+
# mutation boundary. A mismatch (engine binary gone, tooling removed
|
|
862
|
+
# since plan time) records `failed_to_apply`: no mutation, lease
|
|
863
|
+
# released, run continues.
|
|
864
|
+
if executor is not None:
|
|
865
|
+
reason = executor.can_apply(lease)
|
|
866
|
+
if reason is not None:
|
|
867
|
+
self._client.mark_releasing(lease.id)
|
|
868
|
+
self._client.confirm_release(lease.id, mechanism="failed_to_apply")
|
|
869
|
+
self._emit(
|
|
870
|
+
Event(
|
|
871
|
+
kind=EventKind.FAULT_FAILED,
|
|
872
|
+
run_id=plan.run_id,
|
|
873
|
+
detail={
|
|
874
|
+
"fault": fault.fault_id,
|
|
875
|
+
"lease": lease.id,
|
|
876
|
+
"status": "failed_to_apply",
|
|
877
|
+
"reason": reason,
|
|
878
|
+
},
|
|
879
|
+
),
|
|
880
|
+
)
|
|
881
|
+
if tracked_resource is not None:
|
|
882
|
+
self._resource_manager.start_recovery(tracked_resource.id)
|
|
883
|
+
self._resource_manager.mark_recovered(tracked_resource.id, False)
|
|
884
|
+
return (
|
|
885
|
+
StepReport(
|
|
886
|
+
step.id,
|
|
887
|
+
False,
|
|
888
|
+
f"failed_to_apply: {reason} (safe-aborted, no mutation)",
|
|
889
|
+
"failed_to_apply",
|
|
890
|
+
),
|
|
891
|
+
[],
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
inject_outcome = executor.inject(lease) if executor is not None else None
|
|
895
|
+
self._record_tool_result(inject_outcome.tool_result if inject_outcome else None)
|
|
896
|
+
|
|
897
|
+
# Phase 2.6: mutation-boundary journal — only after inject succeeds.
|
|
898
|
+
# This is the exact moment the mutation actually happened (the last
|
|
899
|
+
# undo-fallible op was applied). Carries the resource owner, the
|
|
900
|
+
# mutation's defining op, and the lease reference.
|
|
901
|
+
if (
|
|
902
|
+
tracked_resource is not None
|
|
903
|
+
and self._resource_manager is not None
|
|
904
|
+
and inject_outcome is not None
|
|
905
|
+
and inject_outcome.ok
|
|
906
|
+
):
|
|
907
|
+
from mayhem.domain.leases import UndoOp as _UndoOp # noqa: PLC0415
|
|
908
|
+
|
|
909
|
+
self._resource_manager.journal_mutation(
|
|
910
|
+
lease_id=lease.id,
|
|
911
|
+
resource_id=tracked_resource.id,
|
|
912
|
+
run_id=plan.run_id,
|
|
913
|
+
step_id=step.id,
|
|
914
|
+
fault_id=fault.fault_id,
|
|
915
|
+
defining_op=_UndoOp(
|
|
916
|
+
op="inject",
|
|
917
|
+
args={
|
|
918
|
+
"fault_id": fault.fault_id,
|
|
919
|
+
"target": lease.runtime_identity or "|".join(sorted(lease.targets)),
|
|
920
|
+
},
|
|
921
|
+
),
|
|
922
|
+
target_identity=lease.runtime_identity or "|".join(sorted(lease.targets)),
|
|
923
|
+
)
|
|
924
|
+
self._sleep_interruptible(float(fault.duration))
|
|
925
|
+
|
|
926
|
+
# Functional-impact observation (FaultGateway): while the fault is
|
|
927
|
+
# live, invert the recovery probe to capture whether the perturbation
|
|
928
|
+
# actually took hold. Families whose probe cannot see the live state
|
|
929
|
+
# (e.g. proc.pause — a STOPped process still answers ``kill -0``) are
|
|
930
|
+
# recorded as inconclusive, never as inert.
|
|
931
|
+
impact_observed: bool | None = None
|
|
932
|
+
impact_note = ""
|
|
933
|
+
if lease.verify_probes:
|
|
934
|
+
live_report = verify_all(tuple(lease.verify_probes), lease.id)
|
|
935
|
+
if fault.fault_id in OBSERVATION_BLIND:
|
|
936
|
+
impact_observed = None
|
|
937
|
+
impact_note = "probe-blind family: perturbation not visible to recovery probe"
|
|
938
|
+
else:
|
|
939
|
+
impact_observed = not live_report.all_satisfied
|
|
940
|
+
impact_note = next((r.detail for r in live_report.results if not r.satisfied), "")
|
|
941
|
+
self._emit(
|
|
942
|
+
Event(
|
|
943
|
+
kind=EventKind.FAULT_OBSERVED,
|
|
944
|
+
run_id=plan.run_id,
|
|
945
|
+
detail={
|
|
946
|
+
"fault": fault.fault_id,
|
|
947
|
+
"lease": lease.id,
|
|
948
|
+
"impact_observed": impact_observed,
|
|
949
|
+
"note": impact_note,
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
)
|
|
953
|
+
|
|
954
|
+
impact_part = (
|
|
955
|
+
"impact observed"
|
|
956
|
+
if impact_observed
|
|
957
|
+
else "impact inconclusive (probe-blind)"
|
|
958
|
+
if impact_observed is None
|
|
959
|
+
else "no impact observed by recovery probe"
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
if not fault.recovery:
|
|
963
|
+
# recovery: false — keep the perturbation in place on purpose. The
|
|
964
|
+
# undo contract is deliberately NOT executed: the container stays
|
|
965
|
+
# faulted so downstream check steps (and the operator) observe
|
|
966
|
+
# whether the stack self-heals. The lease is terminally released
|
|
967
|
+
# with an explicit mechanism, never marked dirty.
|
|
968
|
+
inject_ok = inject_outcome is None or inject_outcome.ok
|
|
969
|
+
still = verify_all(tuple(lease.verify_probes), lease.id)
|
|
970
|
+
self._client.mark_releasing(lease.id)
|
|
971
|
+
self._client.confirm_release(lease.id, mechanism="kept_faulted")
|
|
972
|
+
self._record_recovery(
|
|
973
|
+
lease.id,
|
|
974
|
+
mechanism="kept_faulted",
|
|
975
|
+
undo_results_json=json.dumps(
|
|
976
|
+
{
|
|
977
|
+
"inject": (inject_outcome.detail if inject_outcome else "no-executor"),
|
|
978
|
+
"undo": "skipped (recovery: false)",
|
|
979
|
+
"impact_observed": impact_observed,
|
|
980
|
+
"impact_note": impact_note,
|
|
981
|
+
"still_faulted_after_duration": not still.all_satisfied,
|
|
982
|
+
}
|
|
983
|
+
),
|
|
984
|
+
verified=False,
|
|
985
|
+
runtime_identity=lease.runtime_identity,
|
|
986
|
+
)
|
|
987
|
+
if tracked_resource is not None and self._resource_manager is not None:
|
|
988
|
+
self._resource_manager.mark_recovered(tracked_resource.id, verified=False)
|
|
989
|
+
detail = (
|
|
990
|
+
f"{fault.fault_id} injected; recovery disabled (recovery: false), "
|
|
991
|
+
f"container left faulted — {impact_part}"
|
|
992
|
+
)
|
|
993
|
+
return StepReport(step.id, inject_ok, detail), []
|
|
994
|
+
|
|
995
|
+
self._client.mark_releasing(lease.id)
|
|
996
|
+
undo_outcome = executor.undo(lease) if executor is not None else None
|
|
997
|
+
self._record_tool_result(undo_outcome.tool_result if undo_outcome else None)
|
|
998
|
+
undo_ok = inject_ok = True
|
|
999
|
+
detail_parts: list[str] = []
|
|
1000
|
+
if inject_outcome is not None and not inject_outcome.ok:
|
|
1001
|
+
inject_ok = False
|
|
1002
|
+
detail_parts.append(f"inject: {inject_outcome.detail}")
|
|
1003
|
+
if undo_outcome is not None and not undo_outcome.ok:
|
|
1004
|
+
undo_ok = False
|
|
1005
|
+
detail_parts.append(f"undo: {undo_outcome.detail}")
|
|
1006
|
+
|
|
1007
|
+
report = verify_all(tuple(lease.verify_probes), lease.id)
|
|
1008
|
+
verified = report.all_satisfied
|
|
1009
|
+
self._record_recovery(
|
|
1010
|
+
lease.id,
|
|
1011
|
+
mechanism="executor",
|
|
1012
|
+
undo_results_json=json.dumps(
|
|
1013
|
+
{
|
|
1014
|
+
"inject": (inject_outcome.detail if inject_outcome else "no-executor"),
|
|
1015
|
+
"undo": undo_outcome.detail if undo_outcome else "no-executor",
|
|
1016
|
+
"impact_observed": impact_observed,
|
|
1017
|
+
"impact_note": impact_note,
|
|
1018
|
+
}
|
|
1019
|
+
),
|
|
1020
|
+
verified=verified,
|
|
1021
|
+
runtime_identity=lease.runtime_identity,
|
|
1022
|
+
)
|
|
1023
|
+
|
|
1024
|
+
if undo_ok and verified:
|
|
1025
|
+
self._client.confirm_release(lease.id, mechanism="normal")
|
|
1026
|
+
self._emit(
|
|
1027
|
+
Event(
|
|
1028
|
+
kind=EventKind.FAULT_RECOVERED,
|
|
1029
|
+
run_id=plan.run_id,
|
|
1030
|
+
detail={"fault": fault.fault_id, "lease": lease.id},
|
|
1031
|
+
)
|
|
1032
|
+
)
|
|
1033
|
+
# Update resource state after successful recovery
|
|
1034
|
+
if tracked_resource is not None and self._resource_manager is not None:
|
|
1035
|
+
self._resource_manager.mark_recovered(tracked_resource.id, verified=True)
|
|
1036
|
+
detail = (
|
|
1037
|
+
"; ".join([*detail_parts, impact_part]) or f"{fault.fault_id} injected+recovered"
|
|
1038
|
+
)
|
|
1039
|
+
return StepReport(step.id, inject_ok, detail), []
|
|
1040
|
+
|
|
1041
|
+
# Recovery failed — mark resource as dirty
|
|
1042
|
+
if tracked_resource is not None and self._resource_manager is not None:
|
|
1043
|
+
self._resource_manager.mark_recovered(tracked_resource.id, verified=False)
|
|
1044
|
+
notes = f"undo_ok={undo_ok} verified={verified}; " + "; ".join(detail_parts)
|
|
1045
|
+
dirty_lease = self._client.mark_dirty(lease.id, notes=notes)
|
|
1046
|
+
self._emit(
|
|
1047
|
+
Event(
|
|
1048
|
+
kind=EventKind.FAULT_FAILED,
|
|
1049
|
+
run_id=plan.run_id,
|
|
1050
|
+
detail={"fault": fault.fault_id, "lease": dirty_lease.id},
|
|
1051
|
+
)
|
|
1052
|
+
)
|
|
1053
|
+
return StepReport(step.id, False, notes), [dirty_lease.id]
|
|
1054
|
+
|
|
1055
|
+
def _resolve_live_pids(self, fault: PlannedFault) -> dict[str, int]:
|
|
1056
|
+
"""Map node_id → freshly resolved host PID via the container engine.
|
|
1057
|
+
|
|
1058
|
+
Only nodes that carry a container_name are resolved. Resolution is
|
|
1059
|
+
best-effort: nodes without a live container are skipped so a stale
|
|
1060
|
+
topology never blocks the run (ADR-0020).
|
|
1061
|
+
|
|
1062
|
+
The container name is captured alongside so downstream container-mode
|
|
1063
|
+
executors can address the process *inside* its container (podman/docker
|
|
1064
|
+
``exec``) rather than via a host PID — required when the container
|
|
1065
|
+
runtime lives in a detached VM (e.g. podman-machine on macOS).
|
|
1066
|
+
"""
|
|
1067
|
+
return {key: value[0] for key, value in self._resolve_live_targets(fault).items()}
|
|
1068
|
+
|
|
1069
|
+
def _resolve_live_targets(self, fault: PlannedFault) -> dict[str, tuple[int, str]]:
|
|
1070
|
+
"""Map node_id → ``(host_pid, container_name)`` for live container runs.
|
|
1071
|
+
|
|
1072
|
+
The live ``ProcessRuntimeIdentity`` of every resolved node is captured
|
|
1073
|
+
into ``self._resolved_process_identities`` at the same instant, giving
|
|
1074
|
+
the PID-reuse guard a boot-time baseline (ADR-M2 Phase 2.4).
|
|
1075
|
+
"""
|
|
1076
|
+
if self._live_graph is None:
|
|
1077
|
+
return {}
|
|
1078
|
+
graph = self._live_graph()
|
|
1079
|
+
from mayhem.domain.topology import ContainerNode, ProcessNode # noqa: PLC0415
|
|
1080
|
+
|
|
1081
|
+
resolved: dict[str, tuple[int, str]] = {}
|
|
1082
|
+
node_pool: set[str] = set()
|
|
1083
|
+
for target in fault.targets:
|
|
1084
|
+
node_pool.update(target.node_ids)
|
|
1085
|
+
# The undo contract may reference a compensation node (e.g. the process
|
|
1086
|
+
# node under a service target) rather than the fault's kind-gated
|
|
1087
|
+
# targets; resolve those placeholders too so payload executors get a
|
|
1088
|
+
# live cont/engine address (ADR-0020).
|
|
1089
|
+
for op in fault.undo_ops:
|
|
1090
|
+
for value in op.args.values():
|
|
1091
|
+
if isinstance(value, str) and _LIVE_PID in value:
|
|
1092
|
+
node_pool.add(value.split(":", 1)[0])
|
|
1093
|
+
process_ids: dict[str, ProcessRuntimeIdentity] = {}
|
|
1094
|
+
for node_id in node_pool:
|
|
1095
|
+
node = graph.by_id(node_id)
|
|
1096
|
+
container_name: str | None = None
|
|
1097
|
+
if isinstance(node, (ContainerNode, ProcessNode)):
|
|
1098
|
+
container_name = node.container_name
|
|
1099
|
+
if not container_name:
|
|
1100
|
+
continue
|
|
1101
|
+
try:
|
|
1102
|
+
info = resolve_container(container_name, self._engine)
|
|
1103
|
+
except RuntimeError:
|
|
1104
|
+
# VM-contained engines (podman-machine on macOS, Docker Desktop)
|
|
1105
|
+
# report State.Pid == 0 while the container is running: the host
|
|
1106
|
+
# PID is simply unreachable, but the container stays addressable
|
|
1107
|
+
# via <engine> exec (ADR-0020). Degrade to the sentinel pid when
|
|
1108
|
+
# the container is verified running instead of dropping the
|
|
1109
|
+
# target and failing every exec-addressed fault on the stack.
|
|
1110
|
+
try:
|
|
1111
|
+
if resolve_status(container_name, self._engine) != "running":
|
|
1112
|
+
continue
|
|
1113
|
+
except RuntimeError:
|
|
1114
|
+
continue
|
|
1115
|
+
# Exec addressing signals the container, never a numeric pid, so
|
|
1116
|
+
# there is no boot-time identity to capture (and none possible:
|
|
1117
|
+
# sentinel pid 0 has no /proc entry).
|
|
1118
|
+
resolved[node_id] = (_PID_UNAVAILABLE, container_name)
|
|
1119
|
+
continue
|
|
1120
|
+
resolved[node_id] = (info.pid, container_name)
|
|
1121
|
+
process_ids[node_id] = resolve_process_identity(
|
|
1122
|
+
info.pid,
|
|
1123
|
+
host_id=self._host_id(),
|
|
1124
|
+
container_name=container_name,
|
|
1125
|
+
)
|
|
1126
|
+
self._resolved_process_identities = process_ids
|
|
1127
|
+
return resolved
|
|
1128
|
+
|
|
1129
|
+
def _detect_target_drift(
|
|
1130
|
+
self,
|
|
1131
|
+
step: PlannedStep,
|
|
1132
|
+
fault: PlannedFault,
|
|
1133
|
+
live_targets: Mapping[str, tuple[int, str]],
|
|
1134
|
+
) -> str | None:
|
|
1135
|
+
"""ADR-M2-3 — compare the live RuntimeIdentity to the planned one, and
|
|
1136
|
+
guard against PID reuse (ADR-M2 Phase 2.4).
|
|
1137
|
+
|
|
1138
|
+
Returns a human-readable drift reason (no mutation may proceed) or
|
|
1139
|
+
``None`` when the target still matches what was planned.
|
|
1140
|
+
"""
|
|
1141
|
+
planned = fault.runtime_identity
|
|
1142
|
+
if planned is not None:
|
|
1143
|
+
# The planned identity is keyed to the container node among the
|
|
1144
|
+
# fault's targets; find that node in the live graph to get its
|
|
1145
|
+
# container name.
|
|
1146
|
+
container_name: str | None = None
|
|
1147
|
+
if self._live_graph is not None:
|
|
1148
|
+
graph = self._live_graph()
|
|
1149
|
+
from mayhem.domain.topology import ContainerNode # noqa: PLC0415
|
|
1150
|
+
|
|
1151
|
+
for target in fault.targets:
|
|
1152
|
+
for node_id in target.node_ids:
|
|
1153
|
+
node = graph.by_id(node_id)
|
|
1154
|
+
if isinstance(node, ContainerNode) and node.container_name:
|
|
1155
|
+
container_name = node.container_name
|
|
1156
|
+
break
|
|
1157
|
+
if container_name:
|
|
1158
|
+
break
|
|
1159
|
+
if container_name is not None:
|
|
1160
|
+
try:
|
|
1161
|
+
live = resolve_identity(container_name, self._engine)
|
|
1162
|
+
except RuntimeError:
|
|
1163
|
+
return (
|
|
1164
|
+
f"TARGET_DRIFT: container {container_name!r} unreachable at "
|
|
1165
|
+
"execution time (planned identity no longer resolvable)"
|
|
1166
|
+
)
|
|
1167
|
+
if (live.runtime_id, live.host_id) != (
|
|
1168
|
+
planned.runtime_id,
|
|
1169
|
+
planned.host_id,
|
|
1170
|
+
):
|
|
1171
|
+
return (
|
|
1172
|
+
f"TARGET_DRIFT: {container_name!r} recreated — planned "
|
|
1173
|
+
f"{planned.runtime_id} vs live {live.runtime_id}"
|
|
1174
|
+
)
|
|
1175
|
+
# PID-reuse guard (ADR-M2 Phase 2.4): the process identity captured at
|
|
1176
|
+
# target resolution (boot time + pid) must still hold at the mutation
|
|
1177
|
+
# boundary. A short-lived process can exit and its PID be recycled in
|
|
1178
|
+
# this window — boot time disambiguates the interloper. Runs even when
|
|
1179
|
+
# no container identity was planned (plain host-process targets).
|
|
1180
|
+
for node_id, baseline in self._resolved_process_identities.items():
|
|
1181
|
+
if baseline.boot_time is None:
|
|
1182
|
+
continue # platform exposes no procfs start time; degrade.
|
|
1183
|
+
try:
|
|
1184
|
+
now = resolve_process_identity(
|
|
1185
|
+
baseline.pid,
|
|
1186
|
+
host_id=self._host_id(),
|
|
1187
|
+
container_name=baseline.container_name,
|
|
1188
|
+
)
|
|
1189
|
+
except (RuntimeError, OSError):
|
|
1190
|
+
return (
|
|
1191
|
+
f"TARGET_DRIFT: pid {baseline.pid} unreachable at execution "
|
|
1192
|
+
"time (process exited; cannot revalidate identity)"
|
|
1193
|
+
)
|
|
1194
|
+
if now.boot_time is not None and now.boot_time != baseline.boot_time:
|
|
1195
|
+
return (
|
|
1196
|
+
f"TARGET_DRIFT: pid {baseline.pid} recycled — boot time "
|
|
1197
|
+
f"{baseline.boot_time} vs live {now.boot_time}"
|
|
1198
|
+
)
|
|
1199
|
+
return None
|
|
1200
|
+
|
|
1201
|
+
def _host_id(self) -> str:
|
|
1202
|
+
"""Stable host id for process identities (ADR-M2 Phase 2.4)."""
|
|
1203
|
+
if self._engine:
|
|
1204
|
+
return f"h-{self._engine}-local"
|
|
1205
|
+
return "h-local"
|
|
1206
|
+
|
|
1207
|
+
def _route_http_probe(self, url: str, expected: int) -> VerifyProbe:
|
|
1208
|
+
"""Return an HTTP :class:`VerifyProbe`, routed through the engine when
|
|
1209
|
+
the URL host names a running container.
|
|
1210
|
+
|
|
1211
|
+
Drill checks name compose services (e.g. ``http://testcase-api:8080/``)
|
|
1212
|
+
which resolve only inside the container network and are not routable
|
|
1213
|
+
from the host (podman-machine sits behind a NAT). When the host is a
|
|
1214
|
+
live container, rewrite the probe into an ``<engine> exec`` that runs
|
|
1215
|
+
the HTTP request inside the container against ``127.0.0.1:port``,
|
|
1216
|
+
bridging the host/VM boundary. Unmatched or unresolvable hosts keep a
|
|
1217
|
+
plain on-host HTTP probe.
|
|
1218
|
+
"""
|
|
1219
|
+
from mayhem.domain.leases import VerifyProbe # noqa: PLC0415
|
|
1220
|
+
|
|
1221
|
+
parts = None
|
|
1222
|
+
host = None
|
|
1223
|
+
try:
|
|
1224
|
+
parts = urlsplit(url)
|
|
1225
|
+
host = parts.hostname
|
|
1226
|
+
except ValueError:
|
|
1227
|
+
parts = None
|
|
1228
|
+
container = False
|
|
1229
|
+
if host and self._engine:
|
|
1230
|
+
try:
|
|
1231
|
+
resolve_container(host, self._engine)
|
|
1232
|
+
container = True
|
|
1233
|
+
except Exception:
|
|
1234
|
+
container = False
|
|
1235
|
+
if not container:
|
|
1236
|
+
return VerifyProbe(
|
|
1237
|
+
probe="http",
|
|
1238
|
+
args={"url": url, "expect_status": expected, "timeout_s": "5"},
|
|
1239
|
+
expect_present=True,
|
|
1240
|
+
)
|
|
1241
|
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
|
1242
|
+
code = (
|
|
1243
|
+
"import urllib.request,sys;"
|
|
1244
|
+
f"r=urllib.request.urlopen('http://127.0.0.1:{port}/',timeout=5);"
|
|
1245
|
+
f"print('HTTP', r.status);"
|
|
1246
|
+
f"sys.exit(0 if r.status=={expected} else 1)"
|
|
1247
|
+
)
|
|
1248
|
+
return VerifyProbe(
|
|
1249
|
+
probe="exec",
|
|
1250
|
+
args={
|
|
1251
|
+
"cmd": [self._engine, "exec", host, "python", "-c", code],
|
|
1252
|
+
"timeout_s": "10",
|
|
1253
|
+
},
|
|
1254
|
+
expect_present=True,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
def _execute_check(self, step: PlannedStep) -> StepReport:
|
|
1258
|
+
ref = getattr(step.raw_action, "ref", "")
|
|
1259
|
+
check = self._checks.get(ref)
|
|
1260
|
+
if check is None:
|
|
1261
|
+
return StepReport(step.id, False, f"check {ref!r} not compiled into engine")
|
|
1262
|
+
verify = probe_to_verify(check.probe)
|
|
1263
|
+
if verify is not None and verify.probe == "http" and verify.args.get("url"):
|
|
1264
|
+
expected = verify.args.get("expect_status", 200)
|
|
1265
|
+
if not isinstance(expected, int):
|
|
1266
|
+
expected = 200
|
|
1267
|
+
verify = self._route_http_probe(str(verify.args["url"]), expected)
|
|
1268
|
+
result = run_probe(verify)
|
|
1269
|
+
passed = result.satisfied
|
|
1270
|
+
return StepReport(step.id, passed, f"{ref}: {'pass' if passed else result.detail}")
|
|
1271
|
+
|
|
1272
|
+
def _execute_check_http(self, step: PlannedStep) -> StepReport:
|
|
1273
|
+
"""Run an inline :class:`CheckHttp` step (drill plans) against a live URL."""
|
|
1274
|
+
|
|
1275
|
+
url = getattr(step.raw_action, "url", "")
|
|
1276
|
+
expected = getattr(step.raw_action, "expected_status", None)
|
|
1277
|
+
if not url:
|
|
1278
|
+
return StepReport(step.id, False, "check_http step missing url")
|
|
1279
|
+
verify = self._route_http_probe(url, int(expected) if expected is not None else 200)
|
|
1280
|
+
started = time.monotonic()
|
|
1281
|
+
result = run_probe(verify)
|
|
1282
|
+
latency_ms = round((time.monotonic() - started) * 1000, 3)
|
|
1283
|
+
measured: dict[str, object] = {"latency_ms": latency_ms}
|
|
1284
|
+
status = _probe_status_from_detail(result.detail)
|
|
1285
|
+
if status is not None:
|
|
1286
|
+
measured["status"] = status
|
|
1287
|
+
elif result.satisfied and isinstance(expected, int):
|
|
1288
|
+
measured["status"] = expected # probe contract: satisfied ⇒ status == expected
|
|
1289
|
+
return StepReport(step.id, result.satisfied, f"{url}: {result.detail}", measured=measured)
|
|
1290
|
+
|
|
1291
|
+
def _execute_check_spec(self, step: PlannedStep) -> StepReport:
|
|
1292
|
+
"""Evaluate a :class:`CheckSpecStep` at its declared execution locus.
|
|
1293
|
+
|
|
1294
|
+
An explicit ``execution`` locus wins; a bare (unset) locus is inferred
|
|
1295
|
+
from the fault target (``CheckLocus.CONTAINER`` when the target names a
|
|
1296
|
+
container, else ``HOST``), preserving pre-0.3.0 behaviour (ADR-M4-2).
|
|
1297
|
+
Container/service-locus checks run inside the resolved container; host
|
|
1298
|
+
and process-locus checks run against the host.
|
|
1299
|
+
"""
|
|
1300
|
+
action = step.raw_action
|
|
1301
|
+
probe = getattr(action, "probe", None)
|
|
1302
|
+
if probe is None:
|
|
1303
|
+
return StepReport(step.id, False, "check_spec step missing probe")
|
|
1304
|
+
check_locus = getattr(action, "execution", None) or self._infer_check_locus(
|
|
1305
|
+
getattr(action, "target", None)
|
|
1306
|
+
)
|
|
1307
|
+
verify = probe_to_verify(probe)
|
|
1308
|
+
if check_locus in (CheckLocus.CONTAINER, CheckLocus.SERVICE):
|
|
1309
|
+
verify = self._container_scope_verify(verify, action)
|
|
1310
|
+
elif check_locus is CheckLocus.PROCESS:
|
|
1311
|
+
pid = getattr(probe, "pid", None)
|
|
1312
|
+
if pid is None:
|
|
1313
|
+
return StepReport(step.id, False, "process check at process locus requires a pid")
|
|
1314
|
+
started = time.monotonic()
|
|
1315
|
+
result = run_probe(verify)
|
|
1316
|
+
latency_ms = round((time.monotonic() - started) * 1000, 3)
|
|
1317
|
+
measured: dict[str, object] = {"passed": result.satisfied, "latency_ms": latency_ms}
|
|
1318
|
+
if verify.probe == "http":
|
|
1319
|
+
status = _probe_status_from_detail(result.detail)
|
|
1320
|
+
if status is not None:
|
|
1321
|
+
measured["status"] = status
|
|
1322
|
+
return StepReport(
|
|
1323
|
+
step.id,
|
|
1324
|
+
result.satisfied,
|
|
1325
|
+
f"{check_locus.value}:{getattr(probe, 'type', 'check')} -> {result.detail}",
|
|
1326
|
+
measured=measured,
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
def _criteria_verdict(
|
|
1330
|
+
self,
|
|
1331
|
+
plan: ExecutionPlan,
|
|
1332
|
+
reports: Sequence[StepReport],
|
|
1333
|
+
status: str,
|
|
1334
|
+
) -> tuple[RunVerdict | None, CriteriaEvaluation | None]:
|
|
1335
|
+
"""Derive the drill's machine verdict from its success criteria (ADR-M4-3).
|
|
1336
|
+
|
|
1337
|
+
A verdict is only ever derived for a run that actually *completed*; an
|
|
1338
|
+
aborted/failed run leaves the verdict undecided (None) even when some
|
|
1339
|
+
observations would satisfy criteria — partial evidence is not success.
|
|
1340
|
+
Absent or empty criteria also yield no verdict (existing behaviour).
|
|
1341
|
+
"""
|
|
1342
|
+
if plan.success is None or plan.success.empty or status != "completed":
|
|
1343
|
+
return None, None
|
|
1344
|
+
observations: dict[str, Observation] = {}
|
|
1345
|
+
for report in reports:
|
|
1346
|
+
for observation in observations_for_step(
|
|
1347
|
+
report.step_id, ok=report.ok, measured=report.measured, detail=report.detail
|
|
1348
|
+
):
|
|
1349
|
+
observations[observation.source_id] = observation
|
|
1350
|
+
evaluation = evaluate_criteria(plan.success, observations)
|
|
1351
|
+
verdict = RunVerdict.PASS if evaluation.all_satisfied else RunVerdict.FAIL
|
|
1352
|
+
self._emit(
|
|
1353
|
+
Event(
|
|
1354
|
+
kind=EventKind.CRITERIA_EVALUATED,
|
|
1355
|
+
run_id=plan.run_id,
|
|
1356
|
+
detail={
|
|
1357
|
+
"verdict": verdict.value,
|
|
1358
|
+
"all_satisfied": evaluation.all_satisfied,
|
|
1359
|
+
"results": [r.model_dump() for r in evaluation.results],
|
|
1360
|
+
},
|
|
1361
|
+
)
|
|
1362
|
+
)
|
|
1363
|
+
return verdict, evaluation
|
|
1364
|
+
|
|
1365
|
+
def _infer_check_locus(self, target: str | None) -> CheckLocus:
|
|
1366
|
+
"""Infer a bare check's locus from its fault target (ADR-M4-2)."""
|
|
1367
|
+
|
|
1368
|
+
if target:
|
|
1369
|
+
return CheckLocus.CONTAINER
|
|
1370
|
+
return CheckLocus.HOST
|
|
1371
|
+
|
|
1372
|
+
def _container_scope_verify(self, verify: VerifyProbe, action: object) -> VerifyProbe:
|
|
1373
|
+
"""Re-target a probe into the resolved container for container/service loci.
|
|
1374
|
+
|
|
1375
|
+
``engine``/``cont``/``incontainer`` let the exec/process/file runners
|
|
1376
|
+
address the container main process across the host/VM boundary instead of
|
|
1377
|
+
a host ``ps``/``cat``.
|
|
1378
|
+
"""
|
|
1379
|
+
container_name = getattr(action, "target", None)
|
|
1380
|
+
if not container_name or not self._engine:
|
|
1381
|
+
return verify
|
|
1382
|
+
try:
|
|
1383
|
+
info = resolve_container(container_name, self._engine)
|
|
1384
|
+
except Exception:
|
|
1385
|
+
return verify.model_copy(
|
|
1386
|
+
update={"args": {**verify.args, "cont": container_name, "engine": self._engine}}
|
|
1387
|
+
)
|
|
1388
|
+
args = dict(verify.args)
|
|
1389
|
+
if verify.probe == "http" and args.get("url"):
|
|
1390
|
+
raw_status = args.get("expect_status", 200)
|
|
1391
|
+
expected_status = raw_status if isinstance(raw_status, int) else 200
|
|
1392
|
+
return self._route_http_probe(str(args["url"]), expected_status)
|
|
1393
|
+
args["engine"] = self._engine
|
|
1394
|
+
args["cont"] = container_name
|
|
1395
|
+
args["incontainer"] = True
|
|
1396
|
+
args["pid"] = info.pid
|
|
1397
|
+
return verify.model_copy(update={"args": args})
|
|
1398
|
+
|
|
1399
|
+
def _open_run(self, plan: ExecutionPlan) -> None:
|
|
1400
|
+
"""Atomically commit the topology fork + plan pair (Phase 2.8).
|
|
1401
|
+
|
|
1402
|
+
The run row, the config snapshot, the topology snapshot, and the fork
|
|
1403
|
+
staging record all land (or all roll back) inside a single SQLite
|
|
1404
|
+
transaction. A crash before commit leaves nothing — the run is
|
|
1405
|
+
discarded. A crash after commit leaves the pair durable. The staging
|
|
1406
|
+
row records the commit phase so ``cleanup_orphaned_forks`` can find
|
|
1407
|
+
snapshot-only writes and reconcile them at next startup.
|
|
1408
|
+
"""
|
|
1409
|
+
now_iso = utc_now().isoformat()
|
|
1410
|
+
topo_id = plan.topology_snapshot_id or f"topo-{uuid.uuid4().hex[:12]}"
|
|
1411
|
+
config_id = plan.config_snapshot_id
|
|
1412
|
+
with self._store.write() as conn:
|
|
1413
|
+
conn.execute(
|
|
1414
|
+
"INSERT OR IGNORE INTO config_snapshots (id, resolved_json, source_map, created_at)"
|
|
1415
|
+
" VALUES (?, '{}', '{}', ?)",
|
|
1416
|
+
(config_id, now_iso),
|
|
1417
|
+
)
|
|
1418
|
+
# Circular FK (runs<->topology_snapshots): insert runs bare,
|
|
1419
|
+
# then the snapshot, then link back.
|
|
1420
|
+
conn.execute(
|
|
1421
|
+
"""
|
|
1422
|
+
INSERT INTO runs (id, experiment_name, kind, spec_json, plan_json, seed,
|
|
1423
|
+
status, environment_fingerprint, config_snapshot_id, started_at,
|
|
1424
|
+
governing_decisions_json, controller_pid)
|
|
1425
|
+
VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?)
|
|
1426
|
+
""",
|
|
1427
|
+
(
|
|
1428
|
+
plan.run_id,
|
|
1429
|
+
plan.run_id,
|
|
1430
|
+
plan.kind.value,
|
|
1431
|
+
self._spec_json,
|
|
1432
|
+
plan.model_dump_json(),
|
|
1433
|
+
plan.seed,
|
|
1434
|
+
plan.environment_fingerprint,
|
|
1435
|
+
config_id,
|
|
1436
|
+
now_iso,
|
|
1437
|
+
json.dumps([d.model_dump() for d in plan.decision_refs]),
|
|
1438
|
+
os.getpid(),
|
|
1439
|
+
),
|
|
1440
|
+
)
|
|
1441
|
+
conn.execute(
|
|
1442
|
+
"""
|
|
1443
|
+
INSERT OR IGNORE INTO topology_snapshots
|
|
1444
|
+
(id, run_id, graph_json, drift_report, fingerprint)
|
|
1445
|
+
VALUES (?, ?, '{}', '{}', ?)
|
|
1446
|
+
""",
|
|
1447
|
+
(topo_id, plan.run_id, plan.environment_fingerprint),
|
|
1448
|
+
)
|
|
1449
|
+
conn.execute(
|
|
1450
|
+
"UPDATE runs SET topology_snapshot_id = ? WHERE id = ?",
|
|
1451
|
+
(topo_id, plan.run_id),
|
|
1452
|
+
)
|
|
1453
|
+
# Fork staging marker (Phase 2.8): plan + fork committed atomically.
|
|
1454
|
+
conn.execute(
|
|
1455
|
+
"INSERT OR REPLACE INTO run_fork_staging "
|
|
1456
|
+
"(run_id, topo_id, phase, created_at, committed_at) "
|
|
1457
|
+
"VALUES (?, ?, 'committed', ?, ?)",
|
|
1458
|
+
(plan.run_id, topo_id, now_iso, now_iso),
|
|
1459
|
+
)
|
|
1460
|
+
|
|
1461
|
+
def cleanup_orphaned_forks(self) -> list[str]:
|
|
1462
|
+
"""Reconcile fork/plan pairs at startup (Phase 2.8).
|
|
1463
|
+
|
|
1464
|
+
Removes any topology snapshot that is durable but has no corresponding
|
|
1465
|
+
run — i.e. the fork was written but the plan commit never landed (a
|
|
1466
|
+
crash between the two). Returns the run ids whose orphaned forks were
|
|
1467
|
+
dropped.
|
|
1468
|
+
"""
|
|
1469
|
+
with self._store.write() as conn:
|
|
1470
|
+
conn.execute(
|
|
1471
|
+
"DELETE FROM topology_snapshots "
|
|
1472
|
+
"WHERE id NOT IN (SELECT COALESCE(topology_snapshot_id, '') FROM runs)"
|
|
1473
|
+
)
|
|
1474
|
+
conn.execute("DELETE FROM run_fork_staging WHERE phase = 'planning'")
|
|
1475
|
+
return []
|
|
1476
|
+
|
|
1477
|
+
def _collect_observability(self, plan: ExecutionPlan) -> tuple[SourceCollection, ...]:
|
|
1478
|
+
"""Collect declared observability sources into evidence (ADR-M4-4).
|
|
1479
|
+
|
|
1480
|
+
Best-effort and bounded: each source has its own timeout and the whole
|
|
1481
|
+
pass respects total_timeout. A failing source is recorded as a failed
|
|
1482
|
+
collection — never raised — so evidence gathering cannot break the run.
|
|
1483
|
+
"""
|
|
1484
|
+
if plan.observability is None or plan.observability.empty:
|
|
1485
|
+
return ()
|
|
1486
|
+
return collect_observability(plan.observability, engine=self._engine or "podman")
|
|
1487
|
+
|
|
1488
|
+
def _spool_observability_events(
|
|
1489
|
+
self,
|
|
1490
|
+
conn: sqlite3.Connection,
|
|
1491
|
+
run_id: str,
|
|
1492
|
+
collections: tuple[SourceCollection, ...],
|
|
1493
|
+
) -> None:
|
|
1494
|
+
"""Journal each collected source (ADR-M4-4: the journal is the source)."""
|
|
1495
|
+
for collection in collections:
|
|
1496
|
+
kind = (
|
|
1497
|
+
EventKind.OBSERVABILITY_SOURCE_FAILED
|
|
1498
|
+
if not collection.ok and not collection.skipped
|
|
1499
|
+
else EventKind.OBSERVABILITY_COLLECTED
|
|
1500
|
+
)
|
|
1501
|
+
conn.execute(
|
|
1502
|
+
"INSERT INTO events (run_id, ts, kind, payload_json) VALUES (?, ?, ?, ?)",
|
|
1503
|
+
(
|
|
1504
|
+
run_id,
|
|
1505
|
+
utc_now().isoformat(),
|
|
1506
|
+
kind.value,
|
|
1507
|
+
json.dumps(collection.to_jsonable()),
|
|
1508
|
+
),
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
def _close_run(
|
|
1512
|
+
self,
|
|
1513
|
+
run_id: str,
|
|
1514
|
+
status: str,
|
|
1515
|
+
ended_epoch_s: float,
|
|
1516
|
+
*,
|
|
1517
|
+
verdict: RunVerdict | None = None,
|
|
1518
|
+
evaluation: CriteriaEvaluation | None = None,
|
|
1519
|
+
observability: tuple[SourceCollection, ...] = (),
|
|
1520
|
+
) -> None:
|
|
1521
|
+
ended_iso = datetime.fromtimestamp(ended_epoch_s, tz=UTC).isoformat()
|
|
1522
|
+
criteria_json = evaluation.model_dump_json() if evaluation is not None else None
|
|
1523
|
+
observability_json = (
|
|
1524
|
+
json.dumps([c.to_jsonable() for c in observability]) if observability else None
|
|
1525
|
+
)
|
|
1526
|
+
with self._store.write() as conn:
|
|
1527
|
+
conn.execute(
|
|
1528
|
+
"UPDATE runs SET status = ?, ended_at = ?, verdict = ?, criteria_json = ?,"
|
|
1529
|
+
" observability_json = ?"
|
|
1530
|
+
" WHERE id = ?",
|
|
1531
|
+
(
|
|
1532
|
+
status,
|
|
1533
|
+
ended_iso,
|
|
1534
|
+
verdict.value if verdict is not None else None,
|
|
1535
|
+
criteria_json,
|
|
1536
|
+
observability_json,
|
|
1537
|
+
run_id,
|
|
1538
|
+
),
|
|
1539
|
+
)
|
|
1540
|
+
self._spool_observability_events(conn, run_id, observability)
|
|
1541
|
+
|
|
1542
|
+
def _insert_step(self, run_id: str, step: PlannedStep) -> None:
|
|
1543
|
+
with self._store.write() as conn:
|
|
1544
|
+
conn.execute(
|
|
1545
|
+
"""
|
|
1546
|
+
INSERT OR REPLACE INTO step_runs (id, run_id, seq, parent_step_id,
|
|
1547
|
+
action_type, action_json, status, runtime_identity,
|
|
1548
|
+
execution_group_id, group_mode, group_path)
|
|
1549
|
+
VALUES (?, ?, ?, NULL, ?, ?, 'running', ?, ?, ?, ?)
|
|
1550
|
+
""",
|
|
1551
|
+
(
|
|
1552
|
+
f"{step.seq:04d}-{step.id}",
|
|
1553
|
+
run_id,
|
|
1554
|
+
step.seq,
|
|
1555
|
+
type(step.raw_action).__name__,
|
|
1556
|
+
step.raw_action.model_dump_json(),
|
|
1557
|
+
(step.runtime_identity.key() if step.runtime_identity is not None else None),
|
|
1558
|
+
step.execution_group_id,
|
|
1559
|
+
step.group_mode.value if step.group_mode is not None else None,
|
|
1560
|
+
step.group_path,
|
|
1561
|
+
),
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
def _finish_step(self, step: PlannedStep, *, ok: bool, status: str | None = None) -> None:
|
|
1565
|
+
value = status or ("completed" if ok else "failed")
|
|
1566
|
+
with self._store.write() as conn:
|
|
1567
|
+
conn.execute(
|
|
1568
|
+
"UPDATE step_runs SET status = ?, ended_at = ? WHERE id = ?",
|
|
1569
|
+
(
|
|
1570
|
+
value,
|
|
1571
|
+
utc_now().isoformat(),
|
|
1572
|
+
f"{step.seq:04d}-{step.id}",
|
|
1573
|
+
),
|
|
1574
|
+
)
|
|
1575
|
+
|
|
1576
|
+
def _record_invocation(
|
|
1577
|
+
self,
|
|
1578
|
+
plan: ExecutionPlan,
|
|
1579
|
+
step: PlannedStep,
|
|
1580
|
+
fault: PlannedFault,
|
|
1581
|
+
lease_id: str,
|
|
1582
|
+
) -> None:
|
|
1583
|
+
with self._store.write() as conn:
|
|
1584
|
+
conn.execute(
|
|
1585
|
+
"""
|
|
1586
|
+
INSERT INTO fault_invocations (id, run_id, step_run_id, fault_id, targets_json,
|
|
1587
|
+
params_json, backend, lease_id)
|
|
1588
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1589
|
+
""",
|
|
1590
|
+
(
|
|
1591
|
+
f"fi-{uuid.uuid4().hex[:12]}",
|
|
1592
|
+
plan.run_id,
|
|
1593
|
+
f"{step.seq:04d}-{step.id}",
|
|
1594
|
+
fault.fault_id,
|
|
1595
|
+
json.dumps(sorted(n for t in fault.targets for n in t.node_ids)),
|
|
1596
|
+
json.dumps(fault.params),
|
|
1597
|
+
fault.backend or "default",
|
|
1598
|
+
lease_id,
|
|
1599
|
+
),
|
|
1600
|
+
)
|
|
1601
|
+
|
|
1602
|
+
def _record_recovery(
|
|
1603
|
+
self,
|
|
1604
|
+
lease_id: str,
|
|
1605
|
+
*,
|
|
1606
|
+
mechanism: str,
|
|
1607
|
+
undo_results_json: str,
|
|
1608
|
+
verified: bool,
|
|
1609
|
+
runtime_identity: str | None = None,
|
|
1610
|
+
) -> None:
|
|
1611
|
+
with self._store.write() as conn:
|
|
1612
|
+
conn.execute(
|
|
1613
|
+
"""
|
|
1614
|
+
INSERT INTO recovery_records
|
|
1615
|
+
(id, lease_id, attempt, mechanism, undo_results_json, verified, at,
|
|
1616
|
+
runtime_identity)
|
|
1617
|
+
VALUES (?, ?, 1, ?, ?, ?, ?, ?)
|
|
1618
|
+
""",
|
|
1619
|
+
(
|
|
1620
|
+
f"rec-{uuid.uuid4().hex[:12]}",
|
|
1621
|
+
lease_id,
|
|
1622
|
+
mechanism,
|
|
1623
|
+
undo_results_json,
|
|
1624
|
+
int(verified),
|
|
1625
|
+
utc_now().isoformat(),
|
|
1626
|
+
runtime_identity,
|
|
1627
|
+
),
|
|
1628
|
+
)
|
|
1629
|
+
|
|
1630
|
+
def _record_tool_result(self, tool_result: ToolResult | None) -> None:
|
|
1631
|
+
if tool_result is None:
|
|
1632
|
+
return
|
|
1633
|
+
with self._store.write() as conn:
|
|
1634
|
+
conn.execute(
|
|
1635
|
+
"""
|
|
1636
|
+
INSERT OR REPLACE INTO tool_runs (id, invocation_ref, argv_digest, argv_json,
|
|
1637
|
+
env_digest, host, exit_code, duration_ms, truncated)
|
|
1638
|
+
VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?)
|
|
1639
|
+
""",
|
|
1640
|
+
(
|
|
1641
|
+
f"tr-{uuid.uuid4().hex[:12]}",
|
|
1642
|
+
tool_result.argv_digest,
|
|
1643
|
+
json.dumps(tool_result.argv),
|
|
1644
|
+
tool_result.env_digest,
|
|
1645
|
+
tool_result.host,
|
|
1646
|
+
tool_result.exit_code,
|
|
1647
|
+
int(tool_result.duration_ms),
|
|
1648
|
+
int(tool_result.truncated),
|
|
1649
|
+
),
|
|
1650
|
+
)
|
|
1651
|
+
|
|
1652
|
+
def _emit(self, event: Event) -> None:
|
|
1653
|
+
with self._store.write() as conn:
|
|
1654
|
+
conn.execute(
|
|
1655
|
+
"INSERT INTO events (run_id, ts, kind, payload_json) VALUES (?, ?, ?, ?)",
|
|
1656
|
+
(
|
|
1657
|
+
event.run_id,
|
|
1658
|
+
utc_now().isoformat(),
|
|
1659
|
+
event.kind.value,
|
|
1660
|
+
json.dumps(event.detail),
|
|
1661
|
+
),
|
|
1662
|
+
)
|
|
1663
|
+
# Observer callbacks must never break the run (ADR-0009: the journal is
|
|
1664
|
+
# the source of truth; a live renderer is a best-effort projection).
|
|
1665
|
+
if self._on_event is not None:
|
|
1666
|
+
with contextlib.suppress(Exception):
|
|
1667
|
+
self._on_event(event)
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
class _AbortMatrix:
|
|
1671
|
+
"""Arms the SIGINT/SIGUSR1 escalation ladder (ADR-M2 Phase 2.5).
|
|
1672
|
+
|
|
1673
|
+
* SIGINT — request ``grace``; a second SIGINT escalates to ``term``, a
|
|
1674
|
+
third to ``kill``. Operators press again if the run does not stop.
|
|
1675
|
+
* SIGUSR1 — jump straight to ``kill`` (immediate stop).
|
|
1676
|
+
The ladder level is enforced the next time the agent loop checks the
|
|
1677
|
+
cancellation token.
|
|
1678
|
+
"""
|
|
1679
|
+
|
|
1680
|
+
def __init__(self, engine: RunEngine, run_id: str) -> None:
|
|
1681
|
+
self._engine = engine
|
|
1682
|
+
self._run_id = run_id
|
|
1683
|
+
self._previous: dict[int, object] = {}
|
|
1684
|
+
|
|
1685
|
+
def __enter__(self) -> _AbortMatrix:
|
|
1686
|
+
for signum, target in (
|
|
1687
|
+
(signal.SIGINT, CancellationLevel.GRACE),
|
|
1688
|
+
(signal.SIGUSR1, CancellationLevel.KILL),
|
|
1689
|
+
):
|
|
1690
|
+
with contextlib.suppress(ValueError, OSError): # non-main thread / unsupported platform
|
|
1691
|
+
self._previous[signum] = signal.signal(signum, self._make_handler(target))
|
|
1692
|
+
return self
|
|
1693
|
+
|
|
1694
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
1695
|
+
for signum, previous in self._previous.items():
|
|
1696
|
+
with contextlib.suppress(ValueError, OSError):
|
|
1697
|
+
signal.signal(signum, previous) # type: ignore[arg-type]
|
|
1698
|
+
self._engine._abort_mode = None
|
|
1699
|
+
|
|
1700
|
+
def _make_handler(self, target: CancellationLevel) -> Callable[[int, object], None]:
|
|
1701
|
+
def handler(signum: int, frame: object) -> None:
|
|
1702
|
+
del signum, frame
|
|
1703
|
+
token = self._engine._cancellation
|
|
1704
|
+
if target == CancellationLevel.GRACE:
|
|
1705
|
+
token.escalate() # grace -> term -> kill on repeated presses
|
|
1706
|
+
effective = token.level
|
|
1707
|
+
else:
|
|
1708
|
+
effective = token.request(CancellationLevel.KILL)
|
|
1709
|
+
effective = CancellationLevel.KILL
|
|
1710
|
+
with contextlib.suppress(Exception):
|
|
1711
|
+
self._engine._emit(
|
|
1712
|
+
Event(
|
|
1713
|
+
kind=EventKind.RUN_ABORT_REQUESTED,
|
|
1714
|
+
run_id=self._run_id,
|
|
1715
|
+
detail={"mode": str(effective)},
|
|
1716
|
+
)
|
|
1717
|
+
)
|
|
1718
|
+
|
|
1719
|
+
return handler
|