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.
Files changed (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1 @@
1
+ """``mayhem-agent`` — the agent-side entrypoint (ADR-0003)."""
mayhem/agent/cli.py ADDED
@@ -0,0 +1,36 @@
1
+ """``mayhem-agent serve`` — ndjson JSON-RPC session over stdio (ADR-0003).
2
+
3
+ The agent never binds sockets. The controller spawns this process, locally or
4
+ over ``ssh <host> -- mayhem-agent serve``, and speaks the protocol on stdin.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from mayhem.agents.executors import NoopExecutor, ProcPauseExecutor
12
+ from mayhem.agents.server import serve_sync
13
+
14
+ app = typer.Typer(help="mayhem fault-injection agent.")
15
+
16
+
17
+ def _builtin_executors() -> tuple[ProcPauseExecutor, NoopExecutor]:
18
+ return (ProcPauseExecutor(), NoopExecutor())
19
+
20
+
21
+ @app.callback()
22
+ def _root() -> None:
23
+ """mayhem-agent: controller-spawned fault agent."""
24
+
25
+
26
+ @app.command()
27
+ def serve(
28
+ roles: str = typer.Option("proc,fs", "--roles", help="Comma-separated role names."),
29
+ ) -> None:
30
+ """Serve one JSON-RPC session on stdin/stdout until EOF."""
31
+ role_tuple = tuple(r.strip() for r in roles.split(",") if r.strip())
32
+ serve_sync(roles=role_tuple, executors=_builtin_executors())
33
+
34
+
35
+ if __name__ == "__main__":
36
+ app()
@@ -0,0 +1 @@
1
+ """Agent-side SDK (Phase 2): fault executors, lease client, probes."""
@@ -0,0 +1,106 @@
1
+ """Agent capability constraints and security model (ADR-0017).
2
+
3
+ Every agent declares what it can do via ``AgentCapabilities``. The controller
4
+ enforces these at task dispatch time — an agent cannot inject a fault it
5
+ hasn't declared capability for, and cannot touch resources outside its scope.
6
+
7
+ This is the *compile-time* agent security boundary. Runtime isolation
8
+ (sandboxing, process-level containment) is a separate concern.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from enum import StrEnum
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+
18
+ class CapabilityKind(StrEnum):
19
+ """What an agent is allowed to do."""
20
+
21
+ FAULT_INJECT = "fault_inject" # inject a specific fault type
22
+ FAULT_UNDO = "fault_undo" # undo/clean up a fault type
23
+ PROBE_RUN = "probe_run" # execute observation probes
24
+ RESOURCE_ACCESS = "resource_access" # touch tracked resources
25
+ NETWORK_ACCESS = "network_access" # make outbound network calls
26
+ SHELL_EXEC = "shell_exec" # execute shell commands on hosts
27
+
28
+
29
+ class AgentCapabilities(BaseModel):
30
+ """The set of capabilities an agent declares at registration time.
31
+
32
+ The controller validates task dispatch against these capabilities.
33
+ Unknown capabilities are ignored (fail-closed for unrecognized kinds).
34
+ """
35
+
36
+ model_config = ConfigDict(frozen=True)
37
+
38
+ allowed_faults: tuple[str, ...] = ()
39
+ allowed_resources: tuple[str, ...] = ()
40
+ capabilities: tuple[CapabilityKind, ...] = ()
41
+
42
+ def can_inject(self, fault_id: str) -> bool:
43
+ """Can this agent inject the given fault type?"""
44
+ if CapabilityKind.FAULT_INJECT not in self.capabilities:
45
+ return False
46
+ if not self.allowed_faults:
47
+ return True # empty allowlist = all faults allowed
48
+ return fault_id in self.allowed_faults or any(
49
+ fault_id.startswith(prefix) for prefix in self.allowed_faults
50
+ )
51
+
52
+ def can_undo(self, fault_id: str) -> bool:
53
+ """Can this agent undo the given fault type?"""
54
+ if CapabilityKind.FAULT_UNDO not in self.capabilities:
55
+ return False
56
+ if not self.allowed_faults:
57
+ return True
58
+ return fault_id in self.allowed_faults or any(
59
+ fault_id.startswith(prefix) for prefix in self.allowed_faults
60
+ )
61
+
62
+ def can_access_resource(self, resource_type: str) -> bool:
63
+ """Can this agent touch the given resource type?"""
64
+ if CapabilityKind.RESOURCE_ACCESS not in self.capabilities:
65
+ return False
66
+ if not self.allowed_resources:
67
+ return True
68
+ return resource_type in self.allowed_resources
69
+
70
+ def can_use_network(self) -> bool:
71
+ return CapabilityKind.NETWORK_ACCESS in self.capabilities
72
+
73
+ def can_exec_shell(self) -> bool:
74
+ return CapabilityKind.SHELL_EXEC in self.capabilities
75
+
76
+
77
+ class AgentIdentity(BaseModel):
78
+ """Immutable identity for an agent, set at registration."""
79
+
80
+ model_config = ConfigDict(frozen=True)
81
+
82
+ agent_id: str
83
+ hostname: str = ""
84
+ pid: int | None = None
85
+ started_at: str = ""
86
+ capabilities: AgentCapabilities = Field(default_factory=AgentCapabilities)
87
+
88
+ def validate_fault_dispatch(self, fault_id: str, *, is_undo: bool = False) -> None:
89
+ """Raise if this agent cannot handle the given fault.
90
+
91
+ Uses a domain-level error rather than a generic exception so callers
92
+ can catch it uniformly.
93
+ """
94
+ from mayhem.domain.errors import InvariantViolationError # noqa: PLC0415
95
+
96
+ action = "undo" if is_undo else "inject"
97
+ if is_undo and not self.capabilities.can_undo(fault_id):
98
+ raise InvariantViolationError(
99
+ "agent.capability.denied",
100
+ f"agent '{self.agent_id}' not authorized to {action} '{fault_id}'",
101
+ )
102
+ if not is_undo and not self.capabilities.can_inject(fault_id):
103
+ raise InvariantViolationError(
104
+ "agent.capability.denied",
105
+ f"agent '{self.agent_id}' not authorized to {action} '{fault_id}'",
106
+ )
@@ -0,0 +1,430 @@
1
+ """Fault executors — the only code allowed to perturb the system.
2
+
3
+ An Executor owns inject + undo for a family of fault ids. Undo must be
4
+ idempotent and must never raise: if undo fails, the executor reports DIRTY and
5
+ the controller escalates; it does not retry blindly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import signal
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING
16
+
17
+ from mayhem.toolkit.tool_runner import ToolError, ToolResult, run_tool
18
+
19
+ if TYPE_CHECKING:
20
+ from mayhem.domain.leases import FaultLease
21
+
22
+ os_kill = os.kill
23
+
24
+
25
+ def read_boot_time(pid: int) -> int | None:
26
+ """Read a process's start time (``/proc/<pid>/stat`` field 22).
27
+
28
+ Used by the PID-reuse guard (ADR-M2 Phase 2.4 / ADR-M6-2): a PID alone is
29
+ not an identity because the kernel recycles PIDs after exit. The boot time
30
+ in clock ticks since boot disambiguates a recycled PID from the original
31
+ target. Returns ``None`` on platforms without procfs or when the process
32
+ is gone — the guard then degrades to pid-only signalling.
33
+ """
34
+ try:
35
+ raw = Path(f"/proc/{pid}/stat").read_text()
36
+ fields = raw.split(")", maxsplit=1)[1].split()
37
+ return int(fields[19]) if len(fields) > 19 else None
38
+ except (OSError, ValueError, IndexError):
39
+ return None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class StepOutcome:
44
+ step: str # "inject" | "undo"
45
+ ok: bool
46
+ detail: str
47
+ tool_result: ToolResult | None = None
48
+
49
+
50
+ class FaultExecutor:
51
+ """Base class: subclasses declare which prefixes they own."""
52
+
53
+ prefixes: tuple[str, ...] = ()
54
+
55
+ def supports(self, fault_id: str) -> bool:
56
+ return fault_id.split(".", 1)[0] in self.prefixes
57
+
58
+ def capable_faults(self) -> tuple[str, ...]:
59
+ """Fault families handled here; advertised via capabilities.query."""
60
+ return self.prefixes
61
+
62
+ def can_apply(self, lease: FaultLease) -> str | None:
63
+ """Execution-time capability revalidation (ADR-M2 Phase 2.3).
64
+
65
+ Runs immediately before the mutation boundary inside ``inject``. Return
66
+ ``None`` when the capability still holds; return a human-readable reason
67
+ when it does not — the executor then records ``failed_to_apply`` and
68
+ never mutates. The default assumes the capability holds; subclasses
69
+ revalidate what they actually need.
70
+ """
71
+ return None
72
+
73
+ def inject(self, lease: FaultLease) -> StepOutcome: # pragma: no cover
74
+ raise NotImplementedError
75
+
76
+ def undo(self, lease: FaultLease) -> StepOutcome: # pragma: no cover
77
+ raise NotImplementedError
78
+
79
+
80
+ class ProcPauseExecutor(FaultExecutor):
81
+ """Process signal faults against a live PID.
82
+
83
+ ``proc.pause`` SIGSTOP, fully reversible with SIGCONT.
84
+ ``process.stop`` SIGTERM (graceful shutdown); pid is gone — undo no-ops.
85
+ ``process.kill`` SIGKILL; pid is gone — undo no-ops.
86
+
87
+ All three are guarded by the PID-reuse boot_time check (ADR-M2 Phase 2.4).
88
+ """
89
+
90
+ prefixes = ("proc", "process")
91
+
92
+ def __init__(self) -> None:
93
+ self._paused_pids: set[int] = set()
94
+
95
+ def _signal_for(self, fault_id: str) -> int | None:
96
+ """Map a fault id to its signal, or ``None`` for a non-signal fault."""
97
+ family = fault_id.split(".", 1)[0]
98
+ if family == "proc" and fault_id == "proc.pause":
99
+ return signal.SIGSTOP
100
+ if family == "process":
101
+ if fault_id.endswith("kill"):
102
+ return signal.SIGKILL
103
+ if fault_id.endswith("stop"):
104
+ return signal.SIGTERM
105
+ return None
106
+
107
+ def can_apply(self, lease: FaultLease) -> str | None:
108
+ """Revalidate the container-mode signal capability (ADR-M2 Phase 2.3).
109
+
110
+ A container-addressed ``proc.pause`` needs the engine binary on PATH to
111
+ deliver ``SIGSTOP`` inside the container's pid namespace. If the binary
112
+ vanished between plan time and the mutation boundary, this fault cannot
113
+ be applied — report ``failed_to_apply`` instead of mutating.
114
+ """
115
+ import shutil # noqa: PLC0415
116
+
117
+ _pid, cont, engine, _boot = self._signal_spec(lease)
118
+ if cont is None or engine is None:
119
+ return None # host-mode signal needs no engine binary
120
+ if shutil.which(engine) is None:
121
+ return f"capability lost: container engine {engine!r} no longer on PATH"
122
+ return None
123
+
124
+ def _signal_spec(
125
+ self, lease: FaultLease
126
+ ) -> tuple[int | None, str | None, str | None, int | None]:
127
+ """Return (pid, container, engine, boot_time) carried by the first usable undo op.
128
+
129
+ ``pid`` is the process to signal (``.State.Pid`` resolved at execution
130
+ time, valid both on the host and inside the owning container's pid
131
+ namespace). ``boot_time`` is the process start time (``/proc/<pid>/stat``
132
+ field 22) recorded when the target's identity was resolved; it feeds the
133
+ PID-reuse guard so a recycled PID is never signalled. When the op was
134
+ annotated for container mode, ``container`` and ``engine`` (podman/docker)
135
+ are present so the signal can be delivered *inside* the container via
136
+ ``engine exec <cont> kill`` — required when the container runtime lives in
137
+ a detached VM (podman-machine on macOS) where a host ``os.kill`` cannot
138
+ reach the container pid namespace.
139
+ """
140
+ for op in lease.undo_ops:
141
+ raw = op.args.get("pid")
142
+ try:
143
+ pid = int(raw) if raw is not None else None
144
+ except (TypeError, ValueError):
145
+ continue
146
+ if pid is not None:
147
+ boot_raw = op.args.get("boot_time")
148
+ try:
149
+ boot = int(boot_raw) if boot_raw is not None else None
150
+ except (TypeError, ValueError):
151
+ boot = None
152
+ return pid, op.args.get("cont"), op.args.get("engine"), boot
153
+ return None, None, None, None
154
+
155
+ def inject(self, lease: FaultLease) -> StepOutcome:
156
+ sig = self._signal_for(lease.fault_id)
157
+ if sig is None:
158
+ return StepOutcome("inject", False, f"unplannable signal fault {lease.fault_id}")
159
+ pid, cont, engine, _boot = self._signal_spec(lease)
160
+ # Host mode needs a numeric pid; container/exec mode (ADR-0020) signals
161
+ # via ``<engine> kill --signal <cont>`` and works even when the host PID
162
+ # is a sentinel 0 (VM-contained engines like podman-machine on macOS).
163
+ if cont and engine:
164
+ pid = pid if pid is not None else 0
165
+ elif pid is None or pid <= 1:
166
+ return StepOutcome("inject", False, f"lease {lease.id} carries no usable pid")
167
+ outcome = self._signal(pid, sig, cont, engine, _boot, "inject")
168
+ if outcome.ok:
169
+ self._paused_pids.add(pid)
170
+ return outcome
171
+
172
+ def undo(self, lease: FaultLease) -> StepOutcome:
173
+ pid, cont, engine, boot = self._signal_spec(lease)
174
+ if self._signal_for(lease.fault_id) not in (signal.SIGSTOP, signal.SIGCONT):
175
+ # process.stop / process.kill terminate the pid; there is nothing to
176
+ # resume. Report idempotent success so the lease releases cleanly.
177
+ self._paused_pids.discard(pid or 0)
178
+ return StepOutcome("undo", True, "process already terminated; no resume to perform")
179
+ if pid is None:
180
+ return StepOutcome("undo", True, "nothing to resume")
181
+ outcome = self._signal(pid, signal.SIGCONT, cont, engine, boot, "undo")
182
+ self._paused_pids.discard(pid)
183
+ if outcome.ok:
184
+ return StepOutcome("undo", True, f"SIGCONT delivered to {pid}")
185
+ if "already gone" in outcome.detail:
186
+ return StepOutcome("undo", True, f"pid {pid} already gone; treated as resumed")
187
+ return outcome
188
+
189
+ def _signal(
190
+ self,
191
+ pid: int,
192
+ sig: int,
193
+ cont: str | None,
194
+ engine: str | None,
195
+ boot: int | None,
196
+ op: str,
197
+ ) -> StepOutcome:
198
+ """Deliver ``sig`` to the target, inside the container when annotated.
199
+
200
+ Container mode uses ``engine kill --signal <SIG> <cont>`` so the signal is
201
+ delivered to the container's main process from the runtime side. This is
202
+ namespace-agnostic: unlike ``engine exec ... kill <pid>`` it does not
203
+ require the resolved pid (``.State.Pid``, a VM/host pid) to be addressable
204
+ inside the container's pid namespace. Host mode falls back to ``os.kill``,
205
+ guarded by the PID-reuse check (ADR-M2 Phase 2.4): when a boot time was
206
+ recorded at identity-resolution time, we re-read the current boot time
207
+ and refuse to signal on mismatch (the PID was recycled by an unrelated
208
+ process) — reported as ``target_drift`` rather than mutating the wrong
209
+ process.
210
+ """
211
+ if cont and engine:
212
+ signame = signal.Signals(sig).name # e.g. SIGSTOP / SIGCONT
213
+ argv = [engine, "kill", "--signal", signame, cont]
214
+ try:
215
+ result = run_tool(argv, timeout_s=30)
216
+ if result.succeeded:
217
+ return StepOutcome(op, True, f"{signame} sent to {cont}")
218
+ return StepOutcome(
219
+ op,
220
+ False,
221
+ f"{signame} failed for {cont}: {result.stderr.strip()[:120]}",
222
+ )
223
+ except ToolError as exc:
224
+ return StepOutcome(op, False, f"engine kill failed for {cont}: {exc}")
225
+ drift = read_boot_time(pid)
226
+ if boot is not None and drift is not None and drift != boot:
227
+ return StepOutcome(
228
+ op,
229
+ False,
230
+ f"pid-reuse guard: pid {pid} boot_time {drift} != expected {boot}; "
231
+ "refusing to signal recycled PID",
232
+ tool_result=None,
233
+ )
234
+ try:
235
+ os_kill(pid, sig)
236
+ except ProcessLookupError:
237
+ return StepOutcome(op, False, f"pid {pid} already gone")
238
+ except PermissionError as exc:
239
+ return StepOutcome(op, False, f"cannot signal pid {pid}: {exc}")
240
+ return StepOutcome(op, True, f"signal delivered to {pid}")
241
+
242
+
243
+ class PayloadExecutor(FaultExecutor):
244
+ """mem/cpu/fs/fd/load/fuzz: real in-container effects via ``engine exec -d``.
245
+
246
+ Inject launches ``<engine> exec -d <cont> python -c <payload>``: a detached
247
+ process inside the target container's pid + network namespaces that produces
248
+ the fault's observable effect (memory balloon, CPU spin, fs fill, fd
249
+ exhaustion, request flood, malformed HTTP abuse). Undo runs a small kill
250
+ payload against the same container that SIGKILLs the recorded pid and
251
+ removes the payload's marker files, so the effect is fully reversible and
252
+ idempotent. Runs are driven entirely by the lease's ``payload.undo`` op,
253
+ whose ``pid`` placeholder resolves to ``cont``/``engine`` at execution time.
254
+ """
255
+
256
+ prefixes = ("mem", "cpu", "fs", "fd", "load", "fuzz")
257
+
258
+ _KILL = (
259
+ "import os, signal, glob, sys\n"
260
+ "mp = sys.argv[1]\n"
261
+ "try:\n"
262
+ " p = int(open(mp).read().strip())\n"
263
+ " os.kill(p, signal.SIGKILL)\n"
264
+ "except Exception:\n"
265
+ " pass\n"
266
+ "for f in glob.glob(mp + '.*') + [mp]:\n"
267
+ " try:\n"
268
+ " os.unlink(f)\n"
269
+ " except OSError:\n"
270
+ " pass\n"
271
+ )
272
+
273
+ def _spec(self, lease: FaultLease) -> tuple[str, str, str, str] | None:
274
+ """Return ``(engine, cont, payload, marker)`` from the undo op."""
275
+ for op in lease.undo_ops:
276
+ engine, cont = op.args.get("engine"), op.args.get("cont")
277
+ payload, marker = op.args.get("payload"), op.args.get("marker")
278
+ if engine and cont and payload and marker:
279
+ return str(engine), str(cont), str(payload), str(marker)
280
+ return None
281
+
282
+ def inject(self, lease: FaultLease) -> StepOutcome:
283
+ spec = self._spec(lease)
284
+ if spec is None:
285
+ return StepOutcome(
286
+ "inject", False, f"lease {lease.id} lacks payload spec (no cont/engine)"
287
+ )
288
+ engine, cont, payload, _marker = spec
289
+ argv = [engine, "exec", "-d", cont, "python", "-c", payload]
290
+ try:
291
+ result = run_tool(argv, timeout_s=30)
292
+ except ToolError as exc:
293
+ return StepOutcome("inject", False, f"payload launch failed: {exc}")
294
+ detail = f"{lease.fault_id} payload launched in {cont}"
295
+ if result.stderr:
296
+ detail += f": {result.stderr.strip()[:120]}"
297
+ return StepOutcome("inject", result.succeeded, detail, result)
298
+
299
+ def undo(self, lease: FaultLease) -> StepOutcome:
300
+ spec = self._spec(lease)
301
+ if spec is None:
302
+ return StepOutcome(
303
+ "undo", False, f"lease {lease.id} lacks payload spec (no cont/engine)"
304
+ )
305
+ engine, cont, _payload, marker = spec
306
+ argv = [engine, "exec", cont, "python", "-c", self._KILL, marker]
307
+ try:
308
+ result = run_tool(argv, timeout_s=30)
309
+ except ToolError as exc:
310
+ return StepOutcome("undo", False, f"payload cleanup failed: {exc}")
311
+ return StepOutcome(
312
+ "undo", result.succeeded, f"payload for {lease.fault_id} terminated", result
313
+ )
314
+
315
+
316
+ class NoopExecutor(FaultExecutor):
317
+ """Step-outcome-only executor for placeholder steps with no effect.
318
+
319
+ Kept for emitters/callers that produce steps without real faults; real
320
+ payload families are handled by :class:`PayloadExecutor`.
321
+ """
322
+
323
+ prefixes: tuple[str, ...] = ()
324
+
325
+ def inject(self, lease: FaultLease) -> StepOutcome:
326
+ return StepOutcome("inject", True, f"no-op injection recorded for {lease.fault_id}")
327
+
328
+ def undo(self, lease: FaultLease) -> StepOutcome:
329
+ return StepOutcome("undo", True, "noop has nothing to undo")
330
+
331
+
332
+ class ToolExecutor(FaultExecutor):
333
+ """Generic executor driven by argv pairs declared on the lease.
334
+
335
+ Expects one undo_op whose args carry ``inject_argv`` / ``undo_argv`` as
336
+ JSON-encoded ``list[str]`` (UndoOp.args is str→str by domain contract).
337
+ Keeps exotic faults declarative without new executor classes.
338
+ """
339
+
340
+ prefixes = (
341
+ "net",
342
+ "disk",
343
+ "container",
344
+ "node",
345
+ "http",
346
+ "db",
347
+ "dns",
348
+ "tls",
349
+ "clock",
350
+ "dependency",
351
+ )
352
+
353
+ def _argv_for(self, lease: FaultLease, key: str) -> list[str]:
354
+ for op in lease.undo_ops:
355
+ raw = op.args.get(key)
356
+ if isinstance(raw, str):
357
+ try:
358
+ decoded = json.loads(raw)
359
+ except json.JSONDecodeError:
360
+ continue
361
+ if (
362
+ isinstance(decoded, list)
363
+ and decoded
364
+ and all(isinstance(item, str) for item in decoded)
365
+ ):
366
+ # Container-addressed faults embed @engine/@cont tokens that the
367
+ # live substitute resolves (ADR-0020); leave them untouched when
368
+ # the op carries no live address so a stale plan fails loudly.
369
+ engine = op.args.get("engine")
370
+ cont = op.args.get("cont")
371
+ if engine and cont:
372
+ return [
373
+ item.replace("@engine", engine).replace("@cont", cont)
374
+ for item in decoded
375
+ ]
376
+ return decoded
377
+ return []
378
+
379
+ def inject(self, lease: FaultLease) -> StepOutcome:
380
+ argv = self._argv_for(lease, "inject_argv")
381
+ if not argv:
382
+ return StepOutcome("inject", False, f"lease {lease.id} lacks inject_argv")
383
+ result = run_tool(argv)
384
+ return StepOutcome("inject", result.succeeded, result.stderr[:200], result)
385
+
386
+ def undo(self, lease: FaultLease) -> StepOutcome:
387
+ argv = self._argv_for(lease, "undo_argv")
388
+ if not argv:
389
+ return StepOutcome("undo", False, f"lease {lease.id} lacks undo_argv")
390
+ result = run_tool(argv)
391
+ return StepOutcome("undo", result.succeeded, result.stderr[:200], result)
392
+
393
+
394
+ # cpu.throttle applies a container-engine CPU share (``update --cpus``), which
395
+ # is argv-pair work; it is not a burner payload, so it must not be claimed by
396
+ # the prefix-based PayloadExecutor. Registered faults bypass prefix matching.
397
+ _FAULT_EXECUTOR_OVERRIDES: dict[str, FaultExecutor] = {}
398
+
399
+
400
+ def _register_fault_executor(fault_id: str, executor: FaultExecutor) -> None:
401
+ _FAULT_EXECUTOR_OVERRIDES[fault_id] = executor
402
+
403
+
404
+ EXECUTORS: tuple[FaultExecutor, ...] = (
405
+ ProcPauseExecutor(),
406
+ PayloadExecutor(),
407
+ NoopExecutor(),
408
+ ToolExecutor(),
409
+ )
410
+
411
+ _register_fault_executor("cpu.throttle", EXECUTORS[-1])
412
+ # fs.read_only remounts the filesystem in-place; it is argv-pair work, not a
413
+ # burner payload, so it must bypass the prefix-based PayloadExecutor (prefix
414
+ # ``fs``). process.crash_loop drives a container-engine restart cadence, which
415
+ # is also argv-pair work; the process-prefix ProcPauseExecutor only handles
416
+ # SIGSTOP/SIGTERM/SIGKILL, so it is bypassed the same way.
417
+ _register_fault_executor("fs.read_only", EXECUTORS[-1])
418
+ _register_fault_executor("process.crash_loop", EXECUTORS[-1])
419
+
420
+
421
+ def executor_for(fault_id: str) -> FaultExecutor | None:
422
+ """Explicit fault-level override wins; otherwise first registered executor
423
+ claiming this fault's prefix."""
424
+ override = _FAULT_EXECUTOR_OVERRIDES.get(fault_id)
425
+ if override is not None:
426
+ return override
427
+ for executor in EXECUTORS:
428
+ if executor.supports(fault_id):
429
+ return executor
430
+ return None