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
mayhem/agents/server.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Agent-side JSON-RPC server loop over ndjson stdio (ADR-0003).
|
|
2
|
+
|
|
3
|
+
The agent never listens on sockets: the controller spawns it (locally or over
|
|
4
|
+
``ssh … mayhem-agent serve``) and drives the session through its stdin/stdout.
|
|
5
|
+
stderr is reserved for logs, never protocol frames.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from mayhem.agents import protocol as rpc
|
|
16
|
+
from mayhem.agents.watchdog import DEFAULT_SWEEP_INTERVAL_S, AgentWatchdog
|
|
17
|
+
from mayhem.domain.leases import FaultLease
|
|
18
|
+
from mayhem.toolkit.fingerprint import interpreter_marker
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from mayhem.agents.executors import FaultExecutor
|
|
22
|
+
|
|
23
|
+
AGENT_VERSION = "0.1.0.dev0"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TaskRecord:
|
|
27
|
+
def __init__(self, task_id: str, params: dict[str, Any]) -> None:
|
|
28
|
+
self.task_id = task_id
|
|
29
|
+
self.params = params
|
|
30
|
+
self.state = "queued"
|
|
31
|
+
self.detail = ""
|
|
32
|
+
self.handle: asyncio.Task[dict[str, Any]] | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AgentServer:
|
|
36
|
+
"""Serves one controller session. One instance per spawned process."""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
roles: tuple[str, ...],
|
|
41
|
+
executors: tuple[FaultExecutor, ...] = (),
|
|
42
|
+
agent_id: str | None = None,
|
|
43
|
+
watchdog_interval_s: float = DEFAULT_SWEEP_INTERVAL_S,
|
|
44
|
+
) -> None:
|
|
45
|
+
self.agent_id = agent_id or f"ag-{interpreter_marker()}"
|
|
46
|
+
self.roles = roles
|
|
47
|
+
self._executors = executors
|
|
48
|
+
self._tasks: dict[str, TaskRecord] = {}
|
|
49
|
+
self._task_counter = 0
|
|
50
|
+
self.watchdog = AgentWatchdog(interval_s=watchdog_interval_s)
|
|
51
|
+
|
|
52
|
+
# -- method dispatch -----------------------------------------------------
|
|
53
|
+
|
|
54
|
+
async def handle(self, request: rpc.RpcRequest) -> rpc.RpcResponse:
|
|
55
|
+
try:
|
|
56
|
+
rpc._require_context(request.params)
|
|
57
|
+
except rpc.ProtocolError as exc:
|
|
58
|
+
return rpc.error_response(request.id, exc.code, str(exc))
|
|
59
|
+
handler = {
|
|
60
|
+
rpc.METHOD_HANDSHAKE: self._on_handshake,
|
|
61
|
+
rpc.METHOD_CAPABILITIES_QUERY: self._on_capabilities,
|
|
62
|
+
rpc.METHOD_TASK_EXECUTE: self._on_task_execute,
|
|
63
|
+
rpc.METHOD_TASK_CANCEL: self._on_task_cancel,
|
|
64
|
+
rpc.METHOD_TASK_STATUS: self._on_task_status,
|
|
65
|
+
rpc.METHOD_HEALTH_PING: self._on_health_ping,
|
|
66
|
+
}.get(request.method)
|
|
67
|
+
if handler is None:
|
|
68
|
+
return rpc.error_response(
|
|
69
|
+
request.id,
|
|
70
|
+
rpc.RpcErrorCode.METHOD_NOT_FOUND,
|
|
71
|
+
f"no such method: {request.method}",
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
result = await handler(request.params)
|
|
75
|
+
except Exception as exc: # the wire must never see a traceback crash
|
|
76
|
+
return rpc.error_response(
|
|
77
|
+
request.id,
|
|
78
|
+
rpc.RpcErrorCode.INTERNAL_ERROR,
|
|
79
|
+
f"{type(exc).__name__}: {exc}",
|
|
80
|
+
)
|
|
81
|
+
return rpc.result_response(request.id, result)
|
|
82
|
+
|
|
83
|
+
async def _on_handshake(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"agent_id": self.agent_id,
|
|
86
|
+
"protocol": rpc.PROTOCOL_VERSION,
|
|
87
|
+
"agent_version": AGENT_VERSION,
|
|
88
|
+
"roles": list(self.roles),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async def _on_capabilities(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
92
|
+
return {
|
|
93
|
+
"roles": list(self.roles),
|
|
94
|
+
"faults": sorted({f for ex in self._executors for f in ex.capable_faults()}),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async def _on_health_ping(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
98
|
+
return {
|
|
99
|
+
"pong": True,
|
|
100
|
+
"inflight": len(self._tasks),
|
|
101
|
+
"owned_leases": self.watchdog.active_count(),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async def _on_task_execute(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
105
|
+
fault_id = params.get("fault_id")
|
|
106
|
+
if not isinstance(fault_id, str) or not fault_id:
|
|
107
|
+
raise ValueError("task.execute requires fault_id")
|
|
108
|
+
executor = next((ex for ex in self._executors if ex.supports(fault_id)), None)
|
|
109
|
+
if executor is None:
|
|
110
|
+
raise LookupError(f"agent has no executor for fault {fault_id}")
|
|
111
|
+
try:
|
|
112
|
+
lease = (
|
|
113
|
+
params["lease"]
|
|
114
|
+
if isinstance(params["lease"], FaultLease)
|
|
115
|
+
else FaultLease.model_validate(params["lease"])
|
|
116
|
+
)
|
|
117
|
+
except Exception as exc:
|
|
118
|
+
raise ValueError(f"invalid lease payload: {exc}") from exc
|
|
119
|
+
self._task_counter += 1
|
|
120
|
+
task_id = f"t-{self._task_counter:06d}"
|
|
121
|
+
record = TaskRecord(task_id, params)
|
|
122
|
+
record.state = "running"
|
|
123
|
+
self._tasks[task_id] = record
|
|
124
|
+
phase = params.get("phase", "inject")
|
|
125
|
+
try:
|
|
126
|
+
outcome = (
|
|
127
|
+
await asyncio.to_thread(executor.inject, lease)
|
|
128
|
+
if phase == "inject"
|
|
129
|
+
else await asyncio.to_thread(executor.undo, lease)
|
|
130
|
+
)
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
record.state = "failed"
|
|
133
|
+
record.detail = f"{type(exc).__name__}: {exc}"
|
|
134
|
+
raise
|
|
135
|
+
if phase == "inject" and outcome.ok:
|
|
136
|
+
self.watchdog.register(
|
|
137
|
+
lease_id=str(lease.id),
|
|
138
|
+
lease=lease,
|
|
139
|
+
executor=executor,
|
|
140
|
+
ttl_seconds=float(lease.ttl_seconds),
|
|
141
|
+
task_id=task_id,
|
|
142
|
+
)
|
|
143
|
+
record.state = "done"
|
|
144
|
+
record.detail = outcome.detail
|
|
145
|
+
return {
|
|
146
|
+
"task_id": task_id,
|
|
147
|
+
"state": "done",
|
|
148
|
+
"ok": outcome.ok,
|
|
149
|
+
"detail": outcome.detail,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async def _on_task_cancel(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
153
|
+
task_id = params.get("task_id")
|
|
154
|
+
record = self._tasks.get(task_id) if isinstance(task_id, str) else None
|
|
155
|
+
if record is None:
|
|
156
|
+
return {"task_id": task_id, "state": "unknown"}
|
|
157
|
+
if record.handle is not None and not record.handle.done():
|
|
158
|
+
record.handle.cancel()
|
|
159
|
+
record.state = "cancelled"
|
|
160
|
+
return {"task_id": task_id, "state": "cancelled"}
|
|
161
|
+
|
|
162
|
+
async def _on_task_status(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
163
|
+
requested = params.get("task_id")
|
|
164
|
+
records = (
|
|
165
|
+
[r for r in self._tasks.values() if r.task_id == requested]
|
|
166
|
+
if isinstance(requested, str)
|
|
167
|
+
else list(self._tasks.values())
|
|
168
|
+
)
|
|
169
|
+
return {
|
|
170
|
+
"tasks": [
|
|
171
|
+
{"task_id": r.task_id, "state": r.state, "detail": r.detail} for r in records
|
|
172
|
+
],
|
|
173
|
+
"watchdog": self.watchdog.snapshot(),
|
|
174
|
+
"compensated": self.watchdog.history(),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# -- io loop ---------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async def serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
180
|
+
"""Consume requests until EOF; every line gets exactly one response."""
|
|
181
|
+
while True:
|
|
182
|
+
raw = await reader.readline()
|
|
183
|
+
if not raw:
|
|
184
|
+
break
|
|
185
|
+
await self._serve_line(raw.strip(), writer)
|
|
186
|
+
if not writer.is_closing():
|
|
187
|
+
await writer.drain()
|
|
188
|
+
writer.close()
|
|
189
|
+
|
|
190
|
+
async def _serve_line(self, raw: bytes, writer: asyncio.StreamWriter) -> None:
|
|
191
|
+
try:
|
|
192
|
+
frame = rpc.decode(raw)
|
|
193
|
+
except rpc.ProtocolError as exc:
|
|
194
|
+
response = rpc.error_response(None, exc.code, str(exc))
|
|
195
|
+
writer.write(rpc.encode(response))
|
|
196
|
+
await writer.drain()
|
|
197
|
+
return
|
|
198
|
+
if isinstance(frame, rpc.RpcResponse):
|
|
199
|
+
return # agents do not consume responses on their own session
|
|
200
|
+
if isinstance(frame, rpc.RpcNotification):
|
|
201
|
+
return # event.emit / log.emit are accepted and dropped (logged via stderr)
|
|
202
|
+
response = await self.handle(frame)
|
|
203
|
+
writer.write(rpc.encode(response))
|
|
204
|
+
await writer.drain()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def serve_sync(roles: tuple[str, ...], executors: tuple[FaultExecutor, ...]) -> None:
|
|
208
|
+
"""Blocking stdio entrypoint used by ``mayhem-agent serve``.
|
|
209
|
+
|
|
210
|
+
Prefers a fully-async session so the ADR-0005 watchdog keeps sweeping even
|
|
211
|
+
while stdin is idle; falls back to per-line pumping where the platform
|
|
212
|
+
cannot wrap stdio as pipes.
|
|
213
|
+
"""
|
|
214
|
+
server = AgentServer(roles=roles, executors=executors)
|
|
215
|
+
loop = asyncio.new_event_loop()
|
|
216
|
+
try:
|
|
217
|
+
loop.run_until_complete(_serve_async(server))
|
|
218
|
+
except (NotImplementedError, ValueError):
|
|
219
|
+
_serve_blocking(server, loop)
|
|
220
|
+
finally:
|
|
221
|
+
loop.close()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
async def _serve_async(server: AgentServer) -> None:
|
|
225
|
+
loop = asyncio.get_running_loop()
|
|
226
|
+
reader = asyncio.StreamReader()
|
|
227
|
+
await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin)
|
|
228
|
+
transport, protocol = await loop.connect_write_pipe(
|
|
229
|
+
asyncio.streams.FlowControlMixin, sys.stdout
|
|
230
|
+
)
|
|
231
|
+
writer = asyncio.StreamWriter(transport, protocol, None, loop)
|
|
232
|
+
server.watchdog.start()
|
|
233
|
+
try:
|
|
234
|
+
await server.serve(reader, writer)
|
|
235
|
+
finally:
|
|
236
|
+
await server.watchdog.stop()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _serve_blocking(server: AgentServer, loop: asyncio.AbstractEventLoop) -> None:
|
|
240
|
+
"""Per-line fallback: watchdog sweeps piggyback on request processing."""
|
|
241
|
+
while True:
|
|
242
|
+
raw = sys.stdin.readline()
|
|
243
|
+
if not raw:
|
|
244
|
+
break
|
|
245
|
+
line = raw.strip()
|
|
246
|
+
if not line:
|
|
247
|
+
continue
|
|
248
|
+
try:
|
|
249
|
+
frame = rpc.decode(line.encode("utf-8"))
|
|
250
|
+
except rpc.ProtocolError as exc:
|
|
251
|
+
_write_frame(rpc.error_response(None, exc.code, str(exc)))
|
|
252
|
+
continue
|
|
253
|
+
if isinstance(frame, rpc.RpcResponse):
|
|
254
|
+
continue
|
|
255
|
+
if isinstance(frame, rpc.RpcNotification):
|
|
256
|
+
continue # accepted; logs/events surface on stderr
|
|
257
|
+
try:
|
|
258
|
+
response = loop.run_until_complete(
|
|
259
|
+
asyncio.gather(server.handle(frame), server.watchdog.sweep())
|
|
260
|
+
)[0]
|
|
261
|
+
except Exception as exc: # session must survive handler bugs
|
|
262
|
+
response = rpc.error_response(
|
|
263
|
+
frame.id,
|
|
264
|
+
rpc.RpcErrorCode.INTERNAL_ERROR,
|
|
265
|
+
f"{type(exc).__name__}: {exc}",
|
|
266
|
+
)
|
|
267
|
+
_write_frame(response)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _write_frame(response: rpc.RpcResponse) -> None:
|
|
271
|
+
sys.stdout.write(rpc.encode(response).decode("utf-8"))
|
|
272
|
+
sys.stdout.flush()
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def frames_from_jsonl(text: str) -> list[dict[str, Any]]:
|
|
276
|
+
"""Debug helper: pretty-print captured ndjson traffic (`| jq` equivalent)."""
|
|
277
|
+
out: list[dict[str, Any]] = []
|
|
278
|
+
for line in text.splitlines():
|
|
279
|
+
if line.strip():
|
|
280
|
+
out.append(json.loads(line))
|
|
281
|
+
return out
|
mayhem/agents/sinks.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Lease persistence boundary for the agent SDK.
|
|
2
|
+
|
|
3
|
+
Agents never touch SQLite directly (the controller is the single writer,
|
|
4
|
+
ADR-0007); they depend on this protocol. The controller binds a SQLite-backed
|
|
5
|
+
implementation at wiring time; tests use the in-memory fake.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING, Protocol
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from mayhem.domain.leases import FaultLease
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LeaseSink(Protocol):
|
|
17
|
+
def save(self, lease: FaultLease) -> None: ...
|
|
18
|
+
|
|
19
|
+
def load(self, lease_id: str) -> FaultLease | None: ...
|
|
20
|
+
|
|
21
|
+
def active_leases(self) -> tuple[FaultLease, ...]: ...
|
|
22
|
+
|
|
23
|
+
def next_sequence(self) -> int:
|
|
24
|
+
"""Highest ``l-<n>`` sequence already persisted (0 when empty).
|
|
25
|
+
|
|
26
|
+
Lets a fresh run continue the counter instead of restarting from one,
|
|
27
|
+
so repeated runs against a shared DB never reuse a lease id
|
|
28
|
+
(``fault_leases.id`` / ``fault_invocations.lease_id`` are unique).
|
|
29
|
+
"""
|
|
30
|
+
...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class InMemoryLeaseSink:
|
|
34
|
+
"""Thread-hostile by design: the agent event loop is single-threaded."""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._leases: dict[str, FaultLease] = {}
|
|
38
|
+
|
|
39
|
+
def save(self, lease: FaultLease) -> None:
|
|
40
|
+
self._leases[lease.id] = lease
|
|
41
|
+
|
|
42
|
+
def load(self, lease_id: str) -> FaultLease | None:
|
|
43
|
+
return self._leases.get(lease_id)
|
|
44
|
+
|
|
45
|
+
def active_leases(self) -> tuple[FaultLease, ...]:
|
|
46
|
+
return tuple(lease for lease in self._leases.values() if not lease.is_safe_terminal)
|
|
47
|
+
|
|
48
|
+
def next_sequence(self) -> int:
|
|
49
|
+
highest = 0
|
|
50
|
+
for lease_id in self._leases:
|
|
51
|
+
if not lease_id.startswith("l-"):
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
highest = max(highest, int(lease_id[2:]))
|
|
55
|
+
except ValueError:
|
|
56
|
+
continue
|
|
57
|
+
return highest
|
|
58
|
+
|
|
59
|
+
def all_leases(self) -> tuple[FaultLease, ...]:
|
|
60
|
+
return tuple(self._leases.values())
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Controller-side transports for agent sessions (ADR-0003).
|
|
2
|
+
|
|
3
|
+
Both transports spawn the same wire protocol; only the launcher differs.
|
|
4
|
+
- ``LocalStdioTransport`` spawns ``mayhem-agent serve`` as a child process.
|
|
5
|
+
- ``SSHTransport`` runs a persistent ``ssh … -- mayhem-agent serve`` exec channel
|
|
6
|
+
using the system OpenSSH client (no asyncssh dependency).
|
|
7
|
+
Agents never listen on sockets — every session is controller-initiated.
|
|
8
|
+
|
|
9
|
+
Reconnect policy: exponential backoff with jitter. Channel loss does NOT cancel
|
|
10
|
+
in-flight faults; leases survive independently (ADR-0005) and the controller
|
|
11
|
+
re-synchronizes via capabilities.query + task.status after reconnecting.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import random
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
RECONNECT_BASE_DELAY_S = 0.5
|
|
22
|
+
RECONNECT_MAX_DELAY_S = 30.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class SessionSpec:
|
|
27
|
+
"""Everything needed to (re)spawn one agent session."""
|
|
28
|
+
|
|
29
|
+
roles: tuple[str, ...] = ("proc", "fs")
|
|
30
|
+
agent_command: str = "mayhem-agent" # overridable for tests / non-PATH installs
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Transport:
|
|
34
|
+
async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
35
|
+
raise NotImplementedError
|
|
36
|
+
|
|
37
|
+
def describe(self) -> str:
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LocalStdioTransport(Transport):
|
|
42
|
+
def __init__(self, spec: SessionSpec | None = None) -> None:
|
|
43
|
+
self._spec = spec or SessionSpec()
|
|
44
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
45
|
+
|
|
46
|
+
def _argv(self) -> list[str]:
|
|
47
|
+
return [
|
|
48
|
+
self._spec.agent_command,
|
|
49
|
+
"serve",
|
|
50
|
+
"--roles",
|
|
51
|
+
",".join(self._spec.roles),
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
55
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
56
|
+
*self._argv(),
|
|
57
|
+
stdin=asyncio.subprocess.PIPE,
|
|
58
|
+
stdout=asyncio.subprocess.PIPE,
|
|
59
|
+
stderr=None, # agent logs flow to our stderr for local debugging
|
|
60
|
+
)
|
|
61
|
+
assert self._process.stdin is not None and self._process.stdout is not None
|
|
62
|
+
return self._process.stdout, self._process.stdin
|
|
63
|
+
|
|
64
|
+
def describe(self) -> str:
|
|
65
|
+
return f"local:{self._spec.agent_command}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SSHTransport(Transport):
|
|
69
|
+
"""Persistent ``ssh -o ControlMaster=auto -o ControlPersist=600 host -- <agent> serve``."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, target: str, spec: SessionSpec | None = None) -> None:
|
|
72
|
+
self._target = target # user@host or host
|
|
73
|
+
self._spec = spec or SessionSpec()
|
|
74
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
75
|
+
|
|
76
|
+
def argv(self) -> list[str]:
|
|
77
|
+
remote = f"{self._spec.agent_command} serve --roles {','.join(self._spec.roles)}"
|
|
78
|
+
return [
|
|
79
|
+
"ssh",
|
|
80
|
+
"-o",
|
|
81
|
+
"ControlMaster=auto",
|
|
82
|
+
"-o",
|
|
83
|
+
"ControlPersist=600",
|
|
84
|
+
self._target,
|
|
85
|
+
"--",
|
|
86
|
+
remote,
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
async def connect(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]:
|
|
90
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
91
|
+
*self.argv(),
|
|
92
|
+
stdin=asyncio.subprocess.PIPE,
|
|
93
|
+
stdout=asyncio.subprocess.PIPE,
|
|
94
|
+
stderr=None,
|
|
95
|
+
)
|
|
96
|
+
assert self._process.stdin is not None and self._process.stdout is not None
|
|
97
|
+
return self._process.stdout, self._process.stdin
|
|
98
|
+
|
|
99
|
+
def describe(self) -> str:
|
|
100
|
+
return f"ssh:{self._target}"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass
|
|
104
|
+
class ReconnectingSession:
|
|
105
|
+
"""Connect with exponential backoff + jitter until success or max attempts."""
|
|
106
|
+
|
|
107
|
+
transport: Transport
|
|
108
|
+
attempts: int = 8
|
|
109
|
+
base_delay_s: float = RECONNECT_BASE_DELAY_S
|
|
110
|
+
max_delay_s: float = RECONNECT_MAX_DELAY_S
|
|
111
|
+
_rng: random.Random = field(default_factory=random.Random)
|
|
112
|
+
|
|
113
|
+
async def open(self) -> tuple[asyncio.StreamReader, asyncio.StreamWriter, int]:
|
|
114
|
+
delay = self.base_delay_s
|
|
115
|
+
last_error: Exception | None = None
|
|
116
|
+
for attempt in range(1, self.attempts + 1):
|
|
117
|
+
try:
|
|
118
|
+
reader, writer = await self.transport.connect()
|
|
119
|
+
return reader, writer, attempt
|
|
120
|
+
except (OSError, ProcessLookupError) as exc:
|
|
121
|
+
last_error = exc
|
|
122
|
+
jittered = delay * self._rng.uniform(0.5, 1.5)
|
|
123
|
+
await asyncio.sleep(jittered)
|
|
124
|
+
delay = min(delay * 2, self.max_delay_s)
|
|
125
|
+
msg = f"could not reach {self.transport.describe()} after {self.attempts} attempts"
|
|
126
|
+
raise ConnectionError(msg) from last_error
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def session_for(target: str | None, roles: tuple[str, ...], **kwargs: Any) -> Transport:
|
|
130
|
+
"""Factory: ``None`` → local child process; ``user@host`` → SSH exec channel."""
|
|
131
|
+
spec = SessionSpec(roles=roles, **kwargs)
|
|
132
|
+
if target is None:
|
|
133
|
+
return LocalStdioTransport(spec)
|
|
134
|
+
return SSHTransport(target, spec)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Agent-side watchdog (ADR-0005 §3).
|
|
2
|
+
|
|
3
|
+
Each agent expires its own leases past TTL and compensates locally, so safety
|
|
4
|
+
does not depend on the controller ever coming back. Runs inside the agent
|
|
5
|
+
process's event loop; every compensation is reported on stderr and queryable
|
|
6
|
+
via ``task.status`` — never written to stdout (protocol frames only).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from mayhem.agents.executors import FaultExecutor
|
|
21
|
+
|
|
22
|
+
DEFAULT_SWEEP_INTERVAL_S = 1.0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ActiveLease:
|
|
26
|
+
"""A fault this agent has injected and still owns."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
lease: Any,
|
|
32
|
+
executor: FaultExecutor,
|
|
33
|
+
deadline_epoch_s: float,
|
|
34
|
+
task_id: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.lease = lease
|
|
37
|
+
self.executor = executor
|
|
38
|
+
self.deadline_epoch_s = deadline_epoch_s
|
|
39
|
+
self.task_id = task_id
|
|
40
|
+
self.state = "active" # active -> compensating -> expired | dirty
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AgentWatchdog:
|
|
44
|
+
"""Expires owned leases past TTL and undoes them locally."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, *, interval_s: float = DEFAULT_SWEEP_INTERVAL_S) -> None:
|
|
47
|
+
self.interval_s = interval_s
|
|
48
|
+
self._active: dict[str, ActiveLease] = {}
|
|
49
|
+
self._final_states: dict[str, str] = {}
|
|
50
|
+
self._runner: asyncio.Task[None] | None = None
|
|
51
|
+
self.clock: Callable[[], float] = time.time
|
|
52
|
+
|
|
53
|
+
def register(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
lease_id: str,
|
|
57
|
+
lease: Any,
|
|
58
|
+
executor: FaultExecutor,
|
|
59
|
+
ttl_seconds: float,
|
|
60
|
+
now_epoch_s: float | None = None,
|
|
61
|
+
task_id: str | None = None,
|
|
62
|
+
) -> float:
|
|
63
|
+
"""Track an injected fault; returns its absolute expiry deadline."""
|
|
64
|
+
now = time.time() if now_epoch_s is None else now_epoch_s
|
|
65
|
+
entry = ActiveLease(
|
|
66
|
+
lease=lease,
|
|
67
|
+
executor=executor,
|
|
68
|
+
deadline_epoch_s=now + max(ttl_seconds, 0.0),
|
|
69
|
+
task_id=task_id,
|
|
70
|
+
)
|
|
71
|
+
self._active[lease_id] = entry
|
|
72
|
+
return entry.deadline_epoch_s
|
|
73
|
+
|
|
74
|
+
def active_count(self) -> int:
|
|
75
|
+
return len(self._active)
|
|
76
|
+
|
|
77
|
+
async def sweep(self, *, now_epoch_s: float | None = None) -> list[str]:
|
|
78
|
+
"""Undo every lease past its TTL; returns ids compensated this sweep."""
|
|
79
|
+
now = time.time() if now_epoch_s is None else now_epoch_s
|
|
80
|
+
expired = [lid for lid, e in self._active.items() if e.deadline_epoch_s <= now]
|
|
81
|
+
for lease_id in expired:
|
|
82
|
+
entry = self._active.pop(lease_id)
|
|
83
|
+
entry.state = "compensating"
|
|
84
|
+
try:
|
|
85
|
+
outcome = await asyncio.to_thread(entry.executor.undo, entry.lease)
|
|
86
|
+
except Exception as exc:
|
|
87
|
+
entry.state = "dirty"
|
|
88
|
+
print(
|
|
89
|
+
f"[watchdog] undo raised for {lease_id}: {type(exc).__name__}: {exc}",
|
|
90
|
+
file=sys.stderr,
|
|
91
|
+
)
|
|
92
|
+
self._final_states[lease_id] = entry.state
|
|
93
|
+
continue
|
|
94
|
+
entry.state = "expired" if outcome.ok else "dirty"
|
|
95
|
+
self._final_states[lease_id] = entry.state
|
|
96
|
+
print(
|
|
97
|
+
f"[watchdog] {entry.state} {lease_id}: {outcome.detail}",
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
return expired
|
|
101
|
+
|
|
102
|
+
def final_state(self, lease_id: str) -> str | None:
|
|
103
|
+
"""Terminal watchdog state for a compensated lease: expired or dirty."""
|
|
104
|
+
return self._final_states.get(lease_id)
|
|
105
|
+
|
|
106
|
+
def history(self) -> list[dict[str, Any]]:
|
|
107
|
+
"""Leases this agent already self-compensated (expired|dirty)."""
|
|
108
|
+
return [{"lease_id": lid, "state": state} for lid, state in self._final_states.items()]
|
|
109
|
+
|
|
110
|
+
async def run_forever(self) -> None:
|
|
111
|
+
while True:
|
|
112
|
+
await asyncio.sleep(self.interval_s)
|
|
113
|
+
try:
|
|
114
|
+
await self.sweep()
|
|
115
|
+
except Exception as exc: # a broken sweep must not kill the session
|
|
116
|
+
print(f"[watchdog] sweep error: {exc}", file=sys.stderr)
|
|
117
|
+
|
|
118
|
+
def start(self) -> asyncio.Task[None]:
|
|
119
|
+
if self._runner is None or self._runner.done():
|
|
120
|
+
self._runner = asyncio.get_running_loop().create_task(self.run_forever())
|
|
121
|
+
return self._runner
|
|
122
|
+
|
|
123
|
+
async def stop(self) -> None:
|
|
124
|
+
if self._runner is not None:
|
|
125
|
+
self._runner.cancel()
|
|
126
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
127
|
+
await self._runner
|
|
128
|
+
self._runner = None
|
|
129
|
+
|
|
130
|
+
def snapshot(self) -> list[dict[str, Any]]:
|
|
131
|
+
now = self.clock()
|
|
132
|
+
return [
|
|
133
|
+
{
|
|
134
|
+
"lease_id": lid,
|
|
135
|
+
"state": e.state,
|
|
136
|
+
"expires_in_s": round(e.deadline_epoch_s - now, 3),
|
|
137
|
+
"task_id": e.task_id,
|
|
138
|
+
}
|
|
139
|
+
for lid, e in self._active.items()
|
|
140
|
+
]
|
mayhem/cli/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""mayhem CLI package — Click-based, unique-prefix command resolution.
|
|
2
|
+
|
|
3
|
+
Public surface: ``app`` (the root group, for tests and embedding) and
|
|
4
|
+
``main`` (the console-script entry point with exit-code mapping).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mayhem.cli.app import app, main
|
|
10
|
+
|
|
11
|
+
__all__ = ["app", "main"]
|