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,141 @@
|
|
|
1
|
+
"""LeaseClient — the only way an agent touches lease state.
|
|
2
|
+
|
|
3
|
+
Every mutation is a legal state-machine transition (the domain module enforces
|
|
4
|
+
it) followed by an immediate sink write, so a crash between inject and release
|
|
5
|
+
still leaves a durable trail for the janitor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
from mayhem.domain.common import utc_now
|
|
14
|
+
from mayhem.domain.leases import FaultLease, LeaseState
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
|
|
19
|
+
from mayhem.agents.sinks import LeaseSink
|
|
20
|
+
from mayhem.toolkit.fingerprint import interpreter_marker
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _lease_id(sequence: int) -> str:
|
|
24
|
+
return f"l-{sequence:08d}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LeaseConflictError(Exception):
|
|
28
|
+
"""Two agents raced for the same target; the second one loses."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class LeaseClient:
|
|
32
|
+
def __init__(self, sink: LeaseSink, agent_id: str = f"ag-{interpreter_marker()}") -> None:
|
|
33
|
+
self._sink = sink
|
|
34
|
+
self._agent_id = agent_id
|
|
35
|
+
# Continue the counter from the store's high-water mark so lease ids stay
|
|
36
|
+
# unique across runs against a shared DB (the sink is single-writer per
|
|
37
|
+
# run; parallel steps within a run share this client).
|
|
38
|
+
self._sequence = sink.next_sequence()
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def agent_id(self) -> str:
|
|
42
|
+
return self._agent_id
|
|
43
|
+
|
|
44
|
+
def acquire(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
run_id: str,
|
|
48
|
+
fault_id: str,
|
|
49
|
+
targets: frozenset[str] | set[str],
|
|
50
|
+
undo_ops: tuple[dict[str, Any], ...],
|
|
51
|
+
verify_probes: tuple[dict[str, Any], ...] = (),
|
|
52
|
+
ttl_seconds: float = 120.0,
|
|
53
|
+
runtime_identity: str | None = None,
|
|
54
|
+
) -> FaultLease:
|
|
55
|
+
"""Create a PENDING lease; the caller must activate() before injecting."""
|
|
56
|
+
self._sequence += 1
|
|
57
|
+
lease = FaultLease.model_validate(
|
|
58
|
+
{
|
|
59
|
+
"id": _lease_id(self._sequence),
|
|
60
|
+
"run_id": run_id,
|
|
61
|
+
"fault_id": fault_id,
|
|
62
|
+
"owner_agent": self._agent_id,
|
|
63
|
+
"targets": sorted(targets),
|
|
64
|
+
"undo_ops": list(undo_ops),
|
|
65
|
+
"verify_probes": list(verify_probes),
|
|
66
|
+
"ttl_seconds": ttl_seconds,
|
|
67
|
+
"runtime_identity": runtime_identity,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
now = utc_now()
|
|
71
|
+
live: list[FaultLease] = []
|
|
72
|
+
overlap = [x for x in self._sink.active_leases() if x.targets & set(targets)]
|
|
73
|
+
for existing in overlap:
|
|
74
|
+
deadline = existing.created_at.timestamp() + float(existing.ttl_seconds)
|
|
75
|
+
if deadline < now.timestamp():
|
|
76
|
+
# Past TTL is abandoned by definition (ADR-0007 / janitor
|
|
77
|
+
# contract) — expire it durably so a crashed run cannot wedge
|
|
78
|
+
# every later run on the same targets.
|
|
79
|
+
self._expire_quietly(existing, now)
|
|
80
|
+
else:
|
|
81
|
+
live.append(existing)
|
|
82
|
+
if live:
|
|
83
|
+
holders = ", ".join(f"{x.id} ({x.owner_agent})" for x in live)
|
|
84
|
+
raise LeaseConflictError(
|
|
85
|
+
f"targets {sorted(targets)} already leased by {holders} "
|
|
86
|
+
"-- the owner will release them, or run `mayhem janitor` "
|
|
87
|
+
"to reclaim leases whose controller is gone"
|
|
88
|
+
)
|
|
89
|
+
self._sink.save(lease)
|
|
90
|
+
return lease
|
|
91
|
+
|
|
92
|
+
def _expire_quietly(self, lease: FaultLease, now: datetime) -> None:
|
|
93
|
+
"""Best-effort TTL reap; a failed reap just gets retried next acquire."""
|
|
94
|
+
with contextlib.suppress(Exception):
|
|
95
|
+
expired = lease.transition(LeaseState.EXPIRED, mechanism="past-ttl", now=now)
|
|
96
|
+
self._sink.save(expired)
|
|
97
|
+
|
|
98
|
+
def activate(self, lease_id: str) -> FaultLease:
|
|
99
|
+
lease = self._require(lease_id)
|
|
100
|
+
activated = lease.transition(LeaseState.ACTIVE)
|
|
101
|
+
self._sink.save(activated)
|
|
102
|
+
return activated
|
|
103
|
+
|
|
104
|
+
def mark_releasing(self, lease_id: str) -> FaultLease:
|
|
105
|
+
lease = self._require(lease_id)
|
|
106
|
+
releasing = lease.transition(LeaseState.RELEASING)
|
|
107
|
+
self._sink.save(releasing)
|
|
108
|
+
return releasing
|
|
109
|
+
|
|
110
|
+
def confirm_release(self, lease_id: str, *, mechanism: str) -> FaultLease:
|
|
111
|
+
lease = self._require(lease_id)
|
|
112
|
+
released = lease.transition(LeaseState.RELEASED, mechanism=mechanism)
|
|
113
|
+
self._sink.save(released)
|
|
114
|
+
return released
|
|
115
|
+
|
|
116
|
+
def mark_dirty(self, lease_id: str, *, notes: str) -> FaultLease:
|
|
117
|
+
lease = self._require(lease_id)
|
|
118
|
+
dirty = lease.transition(LeaseState.DIRTY, escalation_notes=notes)
|
|
119
|
+
self._sink.save(dirty)
|
|
120
|
+
return dirty
|
|
121
|
+
|
|
122
|
+
def mark_orphaned(self, lease_id: str, *, notes: str | None = None) -> FaultLease:
|
|
123
|
+
lease = self._require(lease_id)
|
|
124
|
+
orphaned = lease.transition(
|
|
125
|
+
LeaseState.ORPHANED,
|
|
126
|
+
escalation_notes=notes or f"owner {lease.owner_agent} missed its heartbeat",
|
|
127
|
+
)
|
|
128
|
+
self._sink.save(orphaned)
|
|
129
|
+
return orphaned
|
|
130
|
+
|
|
131
|
+
def get(self, lease_id: str) -> FaultLease | None:
|
|
132
|
+
return self._sink.load(lease_id)
|
|
133
|
+
|
|
134
|
+
def active_leases(self) -> tuple[FaultLease, ...]:
|
|
135
|
+
return self._sink.active_leases()
|
|
136
|
+
|
|
137
|
+
def _require(self, lease_id: str) -> FaultLease:
|
|
138
|
+
lease = self._sink.load(lease_id)
|
|
139
|
+
if lease is None:
|
|
140
|
+
raise KeyError(f"unknown lease {lease_id!r}")
|
|
141
|
+
return lease
|
mayhem/agents/probes.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Verify probes — proof the world returned to normal (or that it didn't).
|
|
2
|
+
|
|
3
|
+
A probe result is evidence, not a judgement; ``satisfied`` is computed from
|
|
4
|
+
``expect_present`` so negative probes (chain absent) work identically to
|
|
5
|
+
positive ones (service healthy).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.request
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from mayhem.toolkit.tool_runner import run_tool
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from mayhem.domain.leases import VerifyProbe
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ProbeResult:
|
|
27
|
+
probe: str
|
|
28
|
+
satisfied: bool
|
|
29
|
+
detail: str
|
|
30
|
+
|
|
31
|
+
def to_row(self, lease_id: str) -> dict[str, object]:
|
|
32
|
+
return {
|
|
33
|
+
"lease_id": lease_id,
|
|
34
|
+
"probe": self.probe,
|
|
35
|
+
"satisfied": int(self.satisfied),
|
|
36
|
+
"detail": self.detail,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _arg_float(args: dict[str, object], key: str, default: float) -> float:
|
|
41
|
+
raw = args.get(key)
|
|
42
|
+
if not isinstance(raw, (int, float, str)) or isinstance(raw, bool):
|
|
43
|
+
return default
|
|
44
|
+
try:
|
|
45
|
+
return float(raw)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return default
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _arg_str_list(args: dict[str, object], key: str) -> list[str] | None:
|
|
51
|
+
value = args.get(key)
|
|
52
|
+
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
53
|
+
return value
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class VerifyReport:
|
|
59
|
+
results: tuple[ProbeResult, ...]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def all_satisfied(self) -> bool:
|
|
63
|
+
return bool(self.results) and all(r.satisfied for r in self.results)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _run_exec(args: dict[str, object]) -> ProbeResult:
|
|
67
|
+
cmd = _arg_str_list(args, "cmd")
|
|
68
|
+
if cmd is None or not cmd:
|
|
69
|
+
return ProbeResult("exec", False, "exec probe requires cmd: list[str]")
|
|
70
|
+
engine = args.get("engine")
|
|
71
|
+
cont = args.get("cont")
|
|
72
|
+
if engine and cont:
|
|
73
|
+
# Container-addressed presence check. The resolved pid is a pid inside
|
|
74
|
+
# the runtime VM (podman-machine on macOS), which a host ``ps`` cannot
|
|
75
|
+
# see. ``<engine> inspect`` addresses the container main process across
|
|
76
|
+
# the VM boundary, so a nonzero/inspectable container means "present".
|
|
77
|
+
if args.get("incontainer"):
|
|
78
|
+
argv = [str(engine), "exec", str(cont), *cmd]
|
|
79
|
+
result = run_tool(argv, timeout_s=_arg_float(args, "timeout_s", 10.0))
|
|
80
|
+
detail = f"exec {cont} exit={result.exit_code} stderr={result.stderr[:120]!r}"
|
|
81
|
+
return ProbeResult("exec", result.succeeded, detail)
|
|
82
|
+
argv = [str(engine), "inspect", "--format", "{{.State.Pid}}", str(cont)]
|
|
83
|
+
result = run_tool(argv, timeout_s=_arg_float(args, "timeout_s", 10.0))
|
|
84
|
+
pid = result.stdout.strip()
|
|
85
|
+
present = result.succeeded and pid.isdigit() and int(pid) > 0
|
|
86
|
+
return ProbeResult("exec", present, f"inspect {cont} pid={pid!r}")
|
|
87
|
+
result = run_tool(cmd, timeout_s=_arg_float(args, "timeout_s", 10.0))
|
|
88
|
+
detail = f"exit={result.exit_code} stderr={result.stderr[:120]!r}"
|
|
89
|
+
out = (result.stdout or "").strip()
|
|
90
|
+
if out:
|
|
91
|
+
detail = f"{detail} stdout={out[:120]!r}"
|
|
92
|
+
return ProbeResult("exec", result.succeeded, detail)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _run_tcp(args: dict[str, object]) -> ProbeResult:
|
|
96
|
+
host = str(args.get("host", "127.0.0.1"))
|
|
97
|
+
raw_port = args.get("port")
|
|
98
|
+
if not isinstance(raw_port, int):
|
|
99
|
+
return ProbeResult("tcp.open", False, "tcp probe requires port:int")
|
|
100
|
+
timeout = _arg_float(args, "timeout_s", 3.0)
|
|
101
|
+
try:
|
|
102
|
+
with socket.create_connection((host, raw_port), timeout=timeout):
|
|
103
|
+
return ProbeResult("tcp.open", True, f"{host}:{raw_port} accepted")
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
return ProbeResult("tcp.open", False, f"{host}:{raw_port} refused ({exc})")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _run_http(args: dict[str, object]) -> ProbeResult:
|
|
109
|
+
url = str(args.get("url", ""))
|
|
110
|
+
expected_raw = args.get("expect_status", 200)
|
|
111
|
+
if not isinstance(expected_raw, int):
|
|
112
|
+
return ProbeResult("http.status", False, "expect_status must be an int")
|
|
113
|
+
timeout = _arg_float(args, "timeout_s", 5.0)
|
|
114
|
+
if not url.startswith(("http://", "https://")):
|
|
115
|
+
return ProbeResult("http.status", False, f"bad url {url!r}")
|
|
116
|
+
try:
|
|
117
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
118
|
+
status = response.status
|
|
119
|
+
except urllib.error.HTTPError as exc:
|
|
120
|
+
status = exc.code
|
|
121
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
122
|
+
return ProbeResult("http.status", False, f"{url} unreachable ({exc})")
|
|
123
|
+
return ProbeResult("http.status", status == expected_raw, f"{url} -> {status}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _process_state(pid: int) -> str | None:
|
|
127
|
+
"""Kernel state of ``pid`` ('R'/'S'/'T'/'Z'/…), or None when it is gone.
|
|
128
|
+
|
|
129
|
+
Reads ``/proc/<pid>/stat`` on Linux (field 3, after the parenthesised
|
|
130
|
+
comm); falls back to ``ps -o state=`` which is portable (macOS included).
|
|
131
|
+
A process that is gone — reaped, or never there — yields None.
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
content = Path(f"/proc/{pid}/stat").read_text(errors="replace")
|
|
135
|
+
except OSError:
|
|
136
|
+
content = None
|
|
137
|
+
if content:
|
|
138
|
+
fields = content.rsplit(")", 1)
|
|
139
|
+
if len(fields) == 2:
|
|
140
|
+
parts = fields[1].split()
|
|
141
|
+
if parts:
|
|
142
|
+
return parts[0]
|
|
143
|
+
result = run_tool(["ps", "-o", "stat=", "-p", str(pid)], timeout_s=5.0)
|
|
144
|
+
if not result.succeeded:
|
|
145
|
+
return None
|
|
146
|
+
state = (result.stdout or "").strip()
|
|
147
|
+
return state[0] if state else None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _pid_alive(pid: int) -> bool:
|
|
151
|
+
"""True when ``pid`` is a live, non-zombie process.
|
|
152
|
+
|
|
153
|
+
``os.kill(pid, 0)`` still succeeds for a zombie — a terminated child that
|
|
154
|
+
is merely awaiting reap — which would defeat negative process probes.
|
|
155
|
+
Treat zombies (Z/x) as not alive: the target *was* terminated.
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
os.kill(pid, 0)
|
|
159
|
+
except OSError:
|
|
160
|
+
return False
|
|
161
|
+
return _process_state(pid) not in (None, "Z", "X")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _run_process(args: dict[str, object]) -> ProbeResult:
|
|
165
|
+
name = str(args.get("name", ""))
|
|
166
|
+
raw_pid = args.get("pid")
|
|
167
|
+
engine = args.get("engine")
|
|
168
|
+
cont = args.get("cont")
|
|
169
|
+
if raw_pid is not None and not isinstance(raw_pid, bool):
|
|
170
|
+
if isinstance(raw_pid, int):
|
|
171
|
+
pid: int = raw_pid
|
|
172
|
+
elif isinstance(raw_pid, str) and raw_pid.strip().lstrip("-").isdigit():
|
|
173
|
+
pid = int(raw_pid)
|
|
174
|
+
else:
|
|
175
|
+
return ProbeResult("process", False, f"bad pid {raw_pid!r}")
|
|
176
|
+
if engine and cont:
|
|
177
|
+
# Container-addressed: the resolved pid lives inside the runtime
|
|
178
|
+
# VM (podman-machine) which a host ``kill -0`` cannot reach; the
|
|
179
|
+
# container's main process is inspected host-side instead.
|
|
180
|
+
argv = [str(engine), "inspect", "--format", "{{.State.Pid}}", str(cont)]
|
|
181
|
+
result = run_tool(argv, timeout_s=_arg_float(args, "timeout_s", 5.0))
|
|
182
|
+
pid_out = result.stdout.strip()
|
|
183
|
+
present = result.succeeded and pid_out.isdigit() and int(pid_out) > 0
|
|
184
|
+
return ProbeResult("process", present, f"inspect {cont} pid={pid_out!r}")
|
|
185
|
+
alive = _pid_alive(pid)
|
|
186
|
+
return ProbeResult(
|
|
187
|
+
"process", alive, f"pid {pid} {'alive' if alive else 'gone (or zombie)'}"
|
|
188
|
+
)
|
|
189
|
+
if name:
|
|
190
|
+
result = run_tool(["pgrep", "-f", name], timeout_s=_arg_float(args, "timeout_s", 5.0))
|
|
191
|
+
found = result.succeeded and bool((result.stdout or "").strip())
|
|
192
|
+
return ProbeResult("process", found, f"pgrep {name!r} -> {result.stdout!r}")
|
|
193
|
+
return ProbeResult("process", False, "process probe requires name or pid")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _run_metric(args: dict[str, object]) -> ProbeResult: # noqa: PLR0911 (one branch per failure mode)
|
|
197
|
+
endpoint = str(args.get("endpoint", ""))
|
|
198
|
+
query = str(args.get("query", ""))
|
|
199
|
+
if not endpoint:
|
|
200
|
+
return ProbeResult("metric", False, "metric probe requires endpoint")
|
|
201
|
+
try:
|
|
202
|
+
with urllib.request.urlopen(
|
|
203
|
+
endpoint, timeout=_arg_float(args, "timeout_s", 5.0)
|
|
204
|
+
) as response:
|
|
205
|
+
body = response.read(10_000).decode("utf-8", errors="replace")
|
|
206
|
+
except Exception as exc:
|
|
207
|
+
return ProbeResult("metric", False, f"{endpoint} unreachable ({exc})")
|
|
208
|
+
found = (not query) or (query in body)
|
|
209
|
+
if not found:
|
|
210
|
+
return ProbeResult("metric", False, f"metric {query!r} not present")
|
|
211
|
+
threshold_raw = args.get("threshold")
|
|
212
|
+
threshold_num: float | None = None
|
|
213
|
+
if (
|
|
214
|
+
threshold_raw is not None
|
|
215
|
+
and not isinstance(threshold_raw, bool)
|
|
216
|
+
and isinstance(threshold_raw, (int, float, str))
|
|
217
|
+
):
|
|
218
|
+
try:
|
|
219
|
+
threshold_num = float(threshold_raw)
|
|
220
|
+
except ValueError:
|
|
221
|
+
return ProbeResult("metric", False, f"bad threshold {threshold_raw!r}")
|
|
222
|
+
elif threshold_raw is not None and not isinstance(threshold_raw, bool):
|
|
223
|
+
return ProbeResult("metric", False, f"bad threshold {threshold_raw!r}")
|
|
224
|
+
if threshold_num is not None:
|
|
225
|
+
try:
|
|
226
|
+
match = re.search(rf"{re.escape(query)}\s+([-+0-9.eE]+)", body)
|
|
227
|
+
if match is None:
|
|
228
|
+
return ProbeResult("metric", False, f"metric {query!r} lacks a value")
|
|
229
|
+
value = float(match.group(1))
|
|
230
|
+
ok = value >= threshold_num
|
|
231
|
+
return ProbeResult("metric", ok, f"{query}={value} (threshold {threshold_num})")
|
|
232
|
+
except (ValueError, IndexError):
|
|
233
|
+
return ProbeResult("metric", False, f"cannot parse {query!r} value")
|
|
234
|
+
return ProbeResult("metric", True, f"metric {query!r} present")
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _run_file(args: dict[str, object]) -> ProbeResult:
|
|
238
|
+
path = str(args.get("path", ""))
|
|
239
|
+
if not path:
|
|
240
|
+
return ProbeResult("file", False, "file probe requires path")
|
|
241
|
+
p = Path(path)
|
|
242
|
+
if not p.exists():
|
|
243
|
+
return ProbeResult("file", False, f"{path} missing")
|
|
244
|
+
contains = args.get("contains")
|
|
245
|
+
if contains:
|
|
246
|
+
try:
|
|
247
|
+
content = p.read_text(errors="replace")
|
|
248
|
+
except OSError as exc:
|
|
249
|
+
return ProbeResult("file", False, f"{path} unreadable ({exc})")
|
|
250
|
+
if str(contains) not in content:
|
|
251
|
+
return ProbeResult("file", False, f"{path} lacks {contains!r}")
|
|
252
|
+
return ProbeResult("file", True, f"{path} present")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
_HANDLERS = {
|
|
256
|
+
"exec": _run_exec,
|
|
257
|
+
"tcp": _run_tcp,
|
|
258
|
+
"http": _run_http,
|
|
259
|
+
"process": _run_process,
|
|
260
|
+
"metric": _run_metric,
|
|
261
|
+
"file": _run_file,
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def run_probe(probe: VerifyProbe) -> ProbeResult:
|
|
266
|
+
handler = _HANDLERS.get(probe.probe.split(".", 1)[0])
|
|
267
|
+
if handler is None:
|
|
268
|
+
return ProbeResult(probe.probe, False, f"unknown probe kind {probe.probe!r}")
|
|
269
|
+
observed = handler(dict(probe.args))
|
|
270
|
+
# expect_present=True → want the thing to exist → satisfied iff observed.
|
|
271
|
+
# expect_present=False → want absence (e.g. chain gone) → satisfied iff NOT.
|
|
272
|
+
satisfied = observed.satisfied if probe.expect_present else not observed.satisfied
|
|
273
|
+
return ProbeResult(probe.probe, satisfied, observed.detail)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def verify_all(probes: tuple[VerifyProbe, ...], lease_id: str) -> VerifyReport:
|
|
277
|
+
"""Run every probe; never raises — a crashing probe is a failed probe."""
|
|
278
|
+
results = []
|
|
279
|
+
for probe in probes:
|
|
280
|
+
try:
|
|
281
|
+
results.append(run_probe(probe))
|
|
282
|
+
except Exception as exc: # probe crashes are evidence too
|
|
283
|
+
results.append(ProbeResult(probe.probe, False, f"probe crashed: {exc}"))
|
|
284
|
+
return VerifyReport(tuple(results))
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""ndjson JSON-RPC 2.0 framing for controller↔agent communication (ADR-0003).
|
|
2
|
+
|
|
3
|
+
One JSON object per line. Requests carry an id; notifications do not.
|
|
4
|
+
Every params payload carries ``run_id``/``agent_id`` context for correlation —
|
|
5
|
+
enforced here at the frame level so no handler can forget it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from enum import IntEnum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
15
|
+
|
|
16
|
+
PROTOCOL_VERSION = "mayhem/1"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RpcErrorCode(IntEnum):
|
|
20
|
+
PARSE_ERROR = -32700
|
|
21
|
+
INVALID_REQUEST = -32600
|
|
22
|
+
METHOD_NOT_FOUND = -32601
|
|
23
|
+
INVALID_PARAMS = -32602
|
|
24
|
+
INTERNAL_ERROR = -32603
|
|
25
|
+
TASK_FAILED = -32000 # implementation-defined server error range
|
|
26
|
+
SAFETY_REFUSED = -32001
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProtocolError(Exception):
|
|
30
|
+
def __init__(self, code: int, message: str) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.code = code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _Frame(BaseModel):
|
|
36
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
37
|
+
|
|
38
|
+
jsonrpc: str = "2.0"
|
|
39
|
+
|
|
40
|
+
@model_validator(mode="after")
|
|
41
|
+
def _version(self) -> Any:
|
|
42
|
+
if self.jsonrpc != "2.0":
|
|
43
|
+
raise ValueError("jsonrpc must be '2.0'")
|
|
44
|
+
return self
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _require_context(params: dict[str, Any]) -> None:
|
|
48
|
+
missing = [key for key in ("run_id", "agent_id") if key not in params]
|
|
49
|
+
if missing:
|
|
50
|
+
raise ProtocolError(
|
|
51
|
+
RpcErrorCode.INVALID_PARAMS,
|
|
52
|
+
f"params missing correlation context: {', '.join(missing)}",
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RpcRequest(_Frame):
|
|
57
|
+
id: int | str
|
|
58
|
+
method: str
|
|
59
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RpcNotification(_Frame):
|
|
63
|
+
method: str
|
|
64
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RpcResponse(_Frame):
|
|
68
|
+
id: int | str | None
|
|
69
|
+
result: dict[str, Any] | None = None
|
|
70
|
+
error: dict[str, Any] | None = None # {"code": int, "message": str}
|
|
71
|
+
|
|
72
|
+
@model_validator(mode="after")
|
|
73
|
+
def _exactly_one(self) -> Any:
|
|
74
|
+
if (self.result is None) == (self.error is None):
|
|
75
|
+
raise ValueError("response must carry exactly one of result|error")
|
|
76
|
+
return self
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def encode(frame: RpcRequest | RpcNotification | RpcResponse) -> bytes:
|
|
80
|
+
"""Serialize one frame as a single ndjson line (newline included)."""
|
|
81
|
+
return (frame.model_dump_json(exclude_none=True) + "\n").encode("utf-8")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def decode(line: str | bytes) -> RpcRequest | RpcNotification | RpcResponse:
|
|
85
|
+
"""Parse one ndjson line into a typed frame.
|
|
86
|
+
|
|
87
|
+
Raises ProtocolError(PARSE_ERROR) on malformed JSON and
|
|
88
|
+
ProtocolError(INVALID_REQUEST) on structurally invalid frames.
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
raw = json.loads(line)
|
|
92
|
+
except json.JSONDecodeError as exc:
|
|
93
|
+
raise ProtocolError(RpcErrorCode.PARSE_ERROR, f"bad json: {exc.msg}") from exc
|
|
94
|
+
if not isinstance(raw, dict):
|
|
95
|
+
raise ProtocolError(RpcErrorCode.INVALID_REQUEST, "frame must be a JSON object")
|
|
96
|
+
try:
|
|
97
|
+
if "method" in raw:
|
|
98
|
+
if "id" in raw:
|
|
99
|
+
return RpcRequest.model_validate(raw)
|
|
100
|
+
return RpcNotification.model_validate(raw)
|
|
101
|
+
return RpcResponse.model_validate(raw)
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
raise ProtocolError(RpcErrorCode.INVALID_REQUEST, str(exc)) from exc
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def error_response(request_id: int | str | None, code: int, message: str) -> RpcResponse:
|
|
107
|
+
return RpcResponse(id=request_id, error={"code": int(code), "message": message})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def result_response(request_id: int | str | None, result: dict[str, Any]) -> RpcResponse:
|
|
111
|
+
return RpcResponse(id=request_id, result=result)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
METHOD_HANDSHAKE = "handshake"
|
|
115
|
+
METHOD_CAPABILITIES_QUERY = "capabilities.query"
|
|
116
|
+
METHOD_TASK_EXECUTE = "task.execute"
|
|
117
|
+
METHOD_TASK_CANCEL = "task.cancel"
|
|
118
|
+
METHOD_TASK_STATUS = "task.status"
|
|
119
|
+
METHOD_LEASE_EXTEND = "lease.extend"
|
|
120
|
+
METHOD_HEALTH_PING = "health.ping"
|
|
121
|
+
NOTIFICATION_EVENT_EMIT = "event.emit"
|
|
122
|
+
NOTIFICATION_LOG_EMIT = "log.emit"
|
|
123
|
+
KNOWN_METHODS = frozenset(
|
|
124
|
+
{
|
|
125
|
+
METHOD_HANDSHAKE,
|
|
126
|
+
METHOD_CAPABILITIES_QUERY,
|
|
127
|
+
METHOD_TASK_EXECUTE,
|
|
128
|
+
METHOD_TASK_CANCEL,
|
|
129
|
+
METHOD_TASK_STATUS,
|
|
130
|
+
METHOD_LEASE_EXTEND,
|
|
131
|
+
METHOD_HEALTH_PING,
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
KNOWN_NOTIFICATIONS = frozenset({NOTIFICATION_EVENT_EMIT, NOTIFICATION_LOG_EMIT})
|