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,452 @@
|
|
|
1
|
+
"""Topology model: nodes, edges, graph (ADR-0006).
|
|
2
|
+
|
|
3
|
+
Node kinds form a *closed union* — adding Kubernetes later means extending this
|
|
4
|
+
union deliberately (ADR-0013), never duck-typing through it. Container-runtime
|
|
5
|
+
specifics stay out: ``ContainerNode`` is engine-agnostic. Identity lives in
|
|
6
|
+
``RuntimeIdentity``/``RuntimeMetadata`` (ADR-M1-1/M1-2); ``container_name`` and
|
|
7
|
+
``service_name`` are authoring resolver keys, never identity.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import re
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from typing import Annotated, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
18
|
+
from pydantic.networks import IPvAnyAddress
|
|
19
|
+
|
|
20
|
+
from mayhem.domain.errors import InvariantViolationError, TargetResolutionError
|
|
21
|
+
from mayhem.domain.identity import RuntimeIdentity, RuntimeMetadata
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NodeKind(StrEnum):
|
|
25
|
+
SERVICE = "service"
|
|
26
|
+
CONTAINER = "container"
|
|
27
|
+
HOST = "host"
|
|
28
|
+
PROCESS = "process"
|
|
29
|
+
EXTERNAL_DEPENDENCY = "external_dependency"
|
|
30
|
+
POD = "pod"
|
|
31
|
+
K8S_NODE = "k8s_node"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _NodeBase(BaseModel):
|
|
35
|
+
"""Common node shape; all nodes are immutable value objects."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(frozen=True)
|
|
38
|
+
|
|
39
|
+
id: str
|
|
40
|
+
name: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PortBinding(BaseModel):
|
|
44
|
+
"""A single port binding — host side and container side."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(frozen=True)
|
|
47
|
+
|
|
48
|
+
host_port: int
|
|
49
|
+
container_port: int
|
|
50
|
+
host_address: str = "0.0.0.0"
|
|
51
|
+
protocol: str = "tcp" # tcp | udp
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ServiceNode(_NodeBase):
|
|
55
|
+
kind: Literal[NodeKind.SERVICE] = NodeKind.SERVICE
|
|
56
|
+
image: str | None = None
|
|
57
|
+
exposed_ports: tuple[PortBinding, ...] = ()
|
|
58
|
+
container_name: str | None = None # compose ``name:`` field (ADR-0020)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ContainerNode(_NodeBase):
|
|
62
|
+
kind: Literal[NodeKind.CONTAINER] = NodeKind.CONTAINER
|
|
63
|
+
engine: str # "docker" | "podman" — label only, no runtime types here (ADR-0013)
|
|
64
|
+
runtime_identity: RuntimeIdentity # equality key (ADR-M1-1)
|
|
65
|
+
runtime_metadata: RuntimeMetadata | None = None # descriptive, never identity (ADR-M1-2)
|
|
66
|
+
ip_address: IPvAnyAddress | None = None
|
|
67
|
+
# Resolver key only — authoring/DSL lookup by a stable name (ADR-M1-1, ADR-M1-4).
|
|
68
|
+
# Deprecated in identity contexts: equality goes through runtime_identity.
|
|
69
|
+
container_name: str | None = None
|
|
70
|
+
ports: tuple[PortBinding, ...] = ()
|
|
71
|
+
state: str = "unknown" # engine-reported lifecycle state
|
|
72
|
+
image: str | None = None
|
|
73
|
+
networks: tuple[str, ...] = ()
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def identity_key(self) -> str:
|
|
77
|
+
"""Canonical identity key for persisted records."""
|
|
78
|
+
return self.runtime_identity.key()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class HostNode(_NodeBase):
|
|
82
|
+
kind: Literal[NodeKind.HOST] = NodeKind.HOST
|
|
83
|
+
address: IPvAnyAddress | None = None
|
|
84
|
+
transport: Literal["local", "ssh"] = "local"
|
|
85
|
+
ssh_user: str | None = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ProcessNode(_NodeBase):
|
|
89
|
+
kind: Literal[NodeKind.PROCESS] = NodeKind.PROCESS
|
|
90
|
+
pid: int | None = None # resolved lazily at execution time (ADR-0020)
|
|
91
|
+
host_id: str
|
|
92
|
+
cmdline: str = ""
|
|
93
|
+
container_id: str | None = None # short container ID if running inside a container
|
|
94
|
+
container_name: str | None = None # stable identity for PID resolution (ADR-0020)
|
|
95
|
+
exe: str = "" # executable path where discoverable
|
|
96
|
+
user: str = "" # process user where discoverable
|
|
97
|
+
ppid: int | None = None # parent PID where discoverable
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ExternalDependencyNode(_NodeBase):
|
|
101
|
+
kind: Literal[NodeKind.EXTERNAL_DEPENDENCY] = NodeKind.EXTERNAL_DEPENDENCY
|
|
102
|
+
endpoint: str
|
|
103
|
+
protocol: str = "http" # http | tcp | dns | ...
|
|
104
|
+
inferred: bool = False # inferred nodes are never targetable until allowlisted
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class PodNode(_NodeBase):
|
|
108
|
+
"""Kubernetes pod — planning-only model (ADR-M7-2).
|
|
109
|
+
|
|
110
|
+
Represents a pod discovered via the k8s adapter. Execution is gated
|
|
111
|
+
behind ``KubernetesAdapter`` and defaults to ``UNSUPPORTED`` until a
|
|
112
|
+
live-cluster driver is implemented (M8).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
kind: Literal[NodeKind.POD] = NodeKind.POD
|
|
116
|
+
namespace: str = "default"
|
|
117
|
+
node_name: str | None = None # k8s node hosting the pod
|
|
118
|
+
pod_ip: IPvAnyAddress | None = None
|
|
119
|
+
image: str | None = None
|
|
120
|
+
labels: dict[str, str] = Field(default_factory=dict)
|
|
121
|
+
state: str = "unknown"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class K8sNode(_NodeBase):
|
|
125
|
+
"""Kubernetes cluster node — planning-only model (ADR-M7-2).
|
|
126
|
+
|
|
127
|
+
Represents a worker or control-plane node discovered via the k8s adapter.
|
|
128
|
+
Execution is gated behind ``KubernetesAdapter`` and defaults to
|
|
129
|
+
``UNSUPPORTED`` until a live-cluster driver is implemented (M8).
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
kind: Literal[NodeKind.K8S_NODE] = NodeKind.K8S_NODE
|
|
133
|
+
cluster: str = ""
|
|
134
|
+
roles: tuple[str, ...] = () # e.g. ("control-plane",), ("worker",)
|
|
135
|
+
ip_address: IPvAnyAddress | None = None
|
|
136
|
+
capacity_cpu: str = ""
|
|
137
|
+
capacity_memory: str = ""
|
|
138
|
+
state: str = "unknown"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
TopologyNode = (
|
|
142
|
+
ServiceNode
|
|
143
|
+
| ContainerNode
|
|
144
|
+
| HostNode
|
|
145
|
+
| ProcessNode
|
|
146
|
+
| ExternalDependencyNode
|
|
147
|
+
| PodNode
|
|
148
|
+
| K8sNode
|
|
149
|
+
)
|
|
150
|
+
"""Public union of all node kinds (ADR-0013 closed union, extended ADR-M7-2)."""
|
|
151
|
+
|
|
152
|
+
_discriminated_nodes = Annotated[TopologyNode, Field(discriminator="kind")]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --- Network path model (ADR-0019) ---
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class NetworkSegment(BaseModel):
|
|
159
|
+
"""A named segment in the network topology — a subnet, VLAN, or namespace."""
|
|
160
|
+
|
|
161
|
+
model_config = ConfigDict(frozen=True)
|
|
162
|
+
|
|
163
|
+
id: str
|
|
164
|
+
name: str
|
|
165
|
+
cidr: str | None = None # e.g. "10.0.1.0/24"
|
|
166
|
+
node_ids: tuple[str, ...] = () # nodes in this segment
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class NetworkPath(BaseModel):
|
|
170
|
+
"""A directed path between two nodes through the network.
|
|
171
|
+
|
|
172
|
+
Captures hops, expected latency, and any intermediate infrastructure
|
|
173
|
+
(load balancers, firewalls, service meshes) that could be fault targets.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
model_config = ConfigDict(frozen=True)
|
|
177
|
+
|
|
178
|
+
src_node_id: str
|
|
179
|
+
dst_node_id: str
|
|
180
|
+
hop_count: int = 1
|
|
181
|
+
expected_latency_ms: float = 0.0
|
|
182
|
+
intermediaries: tuple[str, ...] = () # node IDs of LBs, firewalls, etc.
|
|
183
|
+
segments: tuple[str, ...] = () # segment IDs this path crosses
|
|
184
|
+
bidirectional: bool = True
|
|
185
|
+
# ADR-M3-7: fault-targetable path detail + fingerprint
|
|
186
|
+
namespace: str | None = None
|
|
187
|
+
interface: str | None = None
|
|
188
|
+
protocol: str = "tcp"
|
|
189
|
+
ports: tuple[int, ...] = ()
|
|
190
|
+
direction: str = "both"
|
|
191
|
+
fingerprint: str = ""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def compute_network_fingerprint(
|
|
195
|
+
*,
|
|
196
|
+
src: str,
|
|
197
|
+
dst: str,
|
|
198
|
+
namespace: str | None = None,
|
|
199
|
+
protocol: str = "tcp",
|
|
200
|
+
fault_type: str = "network",
|
|
201
|
+
) -> str:
|
|
202
|
+
"""Deterministic 16-hex fingerprint for a network fault path (ADR-M3-7).
|
|
203
|
+
|
|
204
|
+
The fingerprint is stable across runs and engine choices, so the planner
|
|
205
|
+
and the resource ledger can correlate a planned fault with the tracked
|
|
206
|
+
resource it mutates.
|
|
207
|
+
"""
|
|
208
|
+
key = f"{src}:{dst}:{namespace or ''}:{protocol}:{fault_type}"
|
|
209
|
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()[:16]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class NetworkTopology(BaseModel):
|
|
213
|
+
"""Collection of network segments and paths — the "network view" of the topology.
|
|
214
|
+
|
|
215
|
+
Used by the planner to determine which network faults are applicable
|
|
216
|
+
to a given source→destination path and which intermediaries can be targeted.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
model_config = ConfigDict(frozen=True)
|
|
220
|
+
|
|
221
|
+
segments: tuple[NetworkSegment, ...] = ()
|
|
222
|
+
paths: tuple[NetworkPath, ...] = ()
|
|
223
|
+
|
|
224
|
+
def paths_for_node(self, node_id: str) -> tuple[NetworkPath, ...]:
|
|
225
|
+
"""All paths originating from or terminating at a node."""
|
|
226
|
+
return tuple(p for p in self.paths if node_id in (p.src_node_id, p.dst_node_id))
|
|
227
|
+
|
|
228
|
+
def segment_for_node(self, node_id: str) -> NetworkSegment | None:
|
|
229
|
+
"""Which segment contains this node, if any."""
|
|
230
|
+
for seg in self.segments:
|
|
231
|
+
if node_id in seg.node_ids:
|
|
232
|
+
return seg
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def find_path(self, src_node_id: str, dst_node_id: str) -> NetworkPath | None:
|
|
236
|
+
"""Find a path between two nodes (directional)."""
|
|
237
|
+
for p in self.paths:
|
|
238
|
+
if p.src_node_id == src_node_id and p.dst_node_id == dst_node_id:
|
|
239
|
+
return p
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
def cross_segment_paths(self) -> tuple[NetworkPath, ...]:
|
|
243
|
+
"""Paths that cross segment boundaries — higher fault surface."""
|
|
244
|
+
return tuple(p for p in self.paths if len(p.segments) > 1)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
class EdgeKind(StrEnum):
|
|
248
|
+
RUNS_ON = "runs_on"
|
|
249
|
+
CONNECTS_VIA = "connects_via"
|
|
250
|
+
DEPENDS_ON = "depends_on"
|
|
251
|
+
EXPOSES = "exposes"
|
|
252
|
+
CONTAINED_IN = "contained_in"
|
|
253
|
+
LISTENS_ON = "listens_on"
|
|
254
|
+
ATTACHED_TO = "attached_to"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class Edge(BaseModel):
|
|
258
|
+
model_config = ConfigDict(frozen=True)
|
|
259
|
+
|
|
260
|
+
src: str
|
|
261
|
+
dst: str
|
|
262
|
+
kind: EdgeKind
|
|
263
|
+
weight: float = 1.0 # depends_on condition strength; >=1.0 means health-gated
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class TargetSelector(BaseModel):
|
|
267
|
+
"""Declarative selector resolved against a :class:`TopologyGraph`.
|
|
268
|
+
|
|
269
|
+
Grammar of ``expr``:
|
|
270
|
+
* ``name`` — exact node name match
|
|
271
|
+
* ``key=value`` — exact field match
|
|
272
|
+
* ``key~=regex`` — regex field match
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
model_config = ConfigDict(frozen=True)
|
|
276
|
+
|
|
277
|
+
kind: NodeKind
|
|
278
|
+
expr: str
|
|
279
|
+
|
|
280
|
+
def matches(self, node: TopologyNode) -> bool:
|
|
281
|
+
if node.kind != self.kind:
|
|
282
|
+
return False
|
|
283
|
+
expr = self.expr.strip()
|
|
284
|
+
if "~=" in expr:
|
|
285
|
+
key, pattern = (part.strip() for part in expr.split("~=", maxsplit=1))
|
|
286
|
+
value = _field_str(node, key)
|
|
287
|
+
return value is not None and re.search(pattern, value) is not None
|
|
288
|
+
if "=" in expr:
|
|
289
|
+
key, expected = (part.strip() for part in expr.split("=", maxsplit=1))
|
|
290
|
+
return _field_str(node, key) == expected
|
|
291
|
+
return node.name == expr
|
|
292
|
+
|
|
293
|
+
def __str__(self) -> str:
|
|
294
|
+
return f"{self.kind.value}:{self.expr}"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _field_str(node: BaseModel, key: str) -> str | None:
|
|
298
|
+
# Authoring resolver keys (ADR-M1-1, ADR-M1-4). `service_name=` and
|
|
299
|
+
# `container_name=` selectors resolve through RuntimeMetadata; they are
|
|
300
|
+
# names of user intent, not node attributes after the identity refactor.
|
|
301
|
+
if key == "service_name" and isinstance(node, ContainerNode):
|
|
302
|
+
meta = node.runtime_metadata
|
|
303
|
+
return meta.service if meta is not None else None
|
|
304
|
+
value = getattr(node, key, None)
|
|
305
|
+
return None if value is None else str(value)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
class TopologyGraph(BaseModel):
|
|
309
|
+
model_config = ConfigDict(frozen=True)
|
|
310
|
+
|
|
311
|
+
nodes: tuple[_discriminated_nodes, ...] = ()
|
|
312
|
+
edges: tuple[Edge, ...] = ()
|
|
313
|
+
|
|
314
|
+
@model_validator(mode="after")
|
|
315
|
+
def _check_integrity(self) -> TopologyGraph:
|
|
316
|
+
ids = [n.id for n in self.nodes]
|
|
317
|
+
duplicates = {nid for nid in ids if ids.count(nid) > 1}
|
|
318
|
+
if duplicates:
|
|
319
|
+
raise InvariantViolationError(
|
|
320
|
+
"topology_unique_node_ids", f"duplicate node ids: {sorted(duplicates)}"
|
|
321
|
+
)
|
|
322
|
+
known = set(ids)
|
|
323
|
+
dangling = [e for e in self.edges if e.src not in known or e.dst not in known]
|
|
324
|
+
if dangling:
|
|
325
|
+
bad = ", ".join(f"{e.kind.value}:{e.src}->{e.dst}" for e in dangling[:5])
|
|
326
|
+
raise InvariantViolationError(
|
|
327
|
+
"topology_edges_reference_known_nodes", f"dangling edges: {bad}"
|
|
328
|
+
)
|
|
329
|
+
return self
|
|
330
|
+
|
|
331
|
+
# -- queries ------------------------------------------------------------------
|
|
332
|
+
def by_id(self, node_id: str) -> TopologyNode | None:
|
|
333
|
+
for node in self.nodes:
|
|
334
|
+
if node.id == node_id:
|
|
335
|
+
return node
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
def of_kind(self, kind: NodeKind) -> list[TopologyNode]:
|
|
339
|
+
return [n for n in self.nodes if n.kind == kind]
|
|
340
|
+
|
|
341
|
+
def resolve(self, selector: TargetSelector) -> tuple[TopologyNode, ...]:
|
|
342
|
+
found = tuple(n for n in self.nodes if selector.matches(n))
|
|
343
|
+
if not found:
|
|
344
|
+
raise TargetResolutionError(str(selector), "no nodes matched")
|
|
345
|
+
return found
|
|
346
|
+
|
|
347
|
+
def connected_processes(self, node_id: str) -> tuple[ProcessNode, ...]:
|
|
348
|
+
"""Walk RUNS_ON edges from *node_id* to find connected ProcessNodes.
|
|
349
|
+
|
|
350
|
+
Follows: service → container → process. Returns every ProcessNode
|
|
351
|
+
reachable within two hops of *node_id* via RUNS_ON edges.
|
|
352
|
+
"""
|
|
353
|
+
by_id = {n.id: n for n in self.nodes}
|
|
354
|
+
child_ids = [e.dst for e in self.edges if e.src == node_id and e.kind is EdgeKind.RUNS_ON]
|
|
355
|
+
# One more hop: process nodes that RUNS_ON the children.
|
|
356
|
+
grandchild_ids = [
|
|
357
|
+
e.dst
|
|
358
|
+
for cid in child_ids
|
|
359
|
+
for e in self.edges
|
|
360
|
+
if e.src == cid and e.kind is EdgeKind.RUNS_ON
|
|
361
|
+
]
|
|
362
|
+
result: list[ProcessNode] = []
|
|
363
|
+
for cid in (*child_ids, *grandchild_ids):
|
|
364
|
+
node = by_id.get(cid)
|
|
365
|
+
if isinstance(node, ProcessNode):
|
|
366
|
+
result.append(node)
|
|
367
|
+
return tuple(result)
|
|
368
|
+
|
|
369
|
+
def container_names(self) -> tuple[str, ...]:
|
|
370
|
+
"""Distinct container identifiers usable with ``--ctr``, sorted.
|
|
371
|
+
|
|
372
|
+
Every ``container_name`` authoring key in the graph is a candidate,
|
|
373
|
+
plus the runtime container names of :class:`ContainerNode` nodes, so
|
|
374
|
+
``--ctr`` accepts either the compose ``container_name:`` value or the
|
|
375
|
+
container's own name as reported by the runtime.
|
|
376
|
+
"""
|
|
377
|
+
keys: set[str] = set()
|
|
378
|
+
for node in self.nodes:
|
|
379
|
+
container_name = getattr(node, "container_name", None)
|
|
380
|
+
if container_name is not None:
|
|
381
|
+
keys.add(str(container_name))
|
|
382
|
+
if node.kind is NodeKind.CONTAINER:
|
|
383
|
+
keys.add(node.name)
|
|
384
|
+
return tuple(sorted(keys))
|
|
385
|
+
|
|
386
|
+
def node_ids_for_container(self, container_name: str) -> frozenset[str]:
|
|
387
|
+
"""Node ids in the subtree named by *container_name* (``--ctr``).
|
|
388
|
+
|
|
389
|
+
A compose service expands to a subtree (service → container →
|
|
390
|
+
process); all members share the ``container_name`` authoring key. A
|
|
391
|
+
``container_name`` match always wins; as a fallback the name of a
|
|
392
|
+
:class:`ContainerNode` is resolved to its authoring key so the whole
|
|
393
|
+
subtree is returned either way.
|
|
394
|
+
"""
|
|
395
|
+
by_key = frozenset(
|
|
396
|
+
node.id
|
|
397
|
+
for node in self.nodes
|
|
398
|
+
if getattr(node, "container_name", None) == container_name
|
|
399
|
+
)
|
|
400
|
+
if by_key:
|
|
401
|
+
return by_key
|
|
402
|
+
for node in self.nodes:
|
|
403
|
+
if node.kind is NodeKind.CONTAINER and node.name == container_name:
|
|
404
|
+
canonical = getattr(node, "container_name", None)
|
|
405
|
+
if canonical is not None:
|
|
406
|
+
return frozenset(
|
|
407
|
+
n.id for n in self.nodes if getattr(n, "container_name", None) == canonical
|
|
408
|
+
)
|
|
409
|
+
return frozenset({node.id})
|
|
410
|
+
return frozenset()
|
|
411
|
+
|
|
412
|
+
def restrict_to(self, container_name: str) -> TopologyGraph:
|
|
413
|
+
"""Return the subgraph scoped to one container subtree (``--ctr``).
|
|
414
|
+
|
|
415
|
+
Keeps the matched service/container/process subtree plus its host
|
|
416
|
+
parents, and only the edges among the kept nodes, so downstream
|
|
417
|
+
planning and synthesis see exactly one container. A miss raises
|
|
418
|
+
:class:`TargetResolutionError` listing the available containers —
|
|
419
|
+
``--ctr`` is a loud guard, never a silent no-op.
|
|
420
|
+
"""
|
|
421
|
+
keep: set[str] = set(self.node_ids_for_container(container_name))
|
|
422
|
+
if not keep:
|
|
423
|
+
available = ", ".join(self.container_names()) or "<none>"
|
|
424
|
+
raise TargetResolutionError(
|
|
425
|
+
container_name,
|
|
426
|
+
f"no container named {container_name!r} in topology (available: {available})",
|
|
427
|
+
)
|
|
428
|
+
by_id = {node.id: node for node in self.nodes}
|
|
429
|
+
for edge in self.edges:
|
|
430
|
+
if edge.src in keep and edge.kind in (EdgeKind.RUNS_ON, EdgeKind.CONTAINED_IN):
|
|
431
|
+
neighbor = by_id.get(edge.dst)
|
|
432
|
+
if neighbor is not None and neighbor.kind is NodeKind.HOST:
|
|
433
|
+
keep.add(edge.dst)
|
|
434
|
+
nodes = tuple(node for node in self.nodes if node.id in keep)
|
|
435
|
+
edges = tuple(edge for edge in self.edges if edge.src in keep and edge.dst in keep)
|
|
436
|
+
return TopologyGraph(nodes=nodes, edges=edges)
|
|
437
|
+
|
|
438
|
+
def dependents_closure(self, node_id: str) -> frozenset[str]:
|
|
439
|
+
"""All node ids that transitively depend on ``node_id`` — blast-radius input."""
|
|
440
|
+
reverse: dict[str, list[str]] = {}
|
|
441
|
+
for edge in self.edges:
|
|
442
|
+
if edge.kind in (EdgeKind.DEPENDS_ON, EdgeKind.CONNECTS_VIA):
|
|
443
|
+
reverse.setdefault(edge.dst, []).append(edge.src)
|
|
444
|
+
seen: set[str] = set()
|
|
445
|
+
frontier = [node_id]
|
|
446
|
+
while frontier:
|
|
447
|
+
current = frontier.pop()
|
|
448
|
+
for upstream in reverse.get(current, []):
|
|
449
|
+
if upstream != node_id and upstream not in seen:
|
|
450
|
+
seen.add(upstream)
|
|
451
|
+
frontier.append(upstream)
|
|
452
|
+
return frozenset(seen)
|
mayhem/infra/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Persistence infrastructure (ADR-0007): SQLite WAL, single writer, migrations."""
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""M5 campaign execution engine (ADR-M5-4, M5 Phase 5.4).
|
|
2
|
+
|
|
3
|
+
Lifecycle: init → iterate (generate/select → gate → run) → stop.
|
|
4
|
+
|
|
5
|
+
Stop on the first of: coverage target reached, budget (max_runs) exhausted,
|
|
6
|
+
deadline passed, or an explicit stop-condition. A ``supervised`` campaign
|
|
7
|
+
never runs a drill until the injected approver approves it; denied candidates
|
|
8
|
+
are skipped and counted, never executed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import TYPE_CHECKING, Protocol
|
|
16
|
+
|
|
17
|
+
from mayhem.domain.candidates import CandidateGate
|
|
18
|
+
from mayhem.domain.coverage import CoverageCell
|
|
19
|
+
from mayhem.domain.m5_campaign import (
|
|
20
|
+
ApproveDecision,
|
|
21
|
+
CampaignMode,
|
|
22
|
+
CampaignProgress,
|
|
23
|
+
M5Campaign,
|
|
24
|
+
StopReason,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
|
|
30
|
+
from mayhem.domain.candidates import CandidateDecision, ExperimentCandidate
|
|
31
|
+
from mayhem.infra.candidate_gates import CandidateGatePipeline
|
|
32
|
+
|
|
33
|
+
# A single executed drill result: the Run that ran, its Outcome, and the
|
|
34
|
+
# coverage cell it explored.
|
|
35
|
+
DrillResult = tuple[str, str, CoverageCell] # (run_id, outcome_id, cell)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CandidateSource(Protocol):
|
|
39
|
+
"""Yields candidates for the campaign to consider (in-order, may exhaust)."""
|
|
40
|
+
|
|
41
|
+
def next_candidate(self) -> ExperimentCandidate | None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Approver(Protocol):
|
|
45
|
+
"""Gate for ``supervised`` mode: must explicitly approve before running."""
|
|
46
|
+
|
|
47
|
+
def approve(self, candidate: ExperimentCandidate) -> bool: ...
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Runner(Protocol):
|
|
51
|
+
"""Executes a candidate to a Run/Outcome pair + the cell it covered."""
|
|
52
|
+
|
|
53
|
+
def run(self, candidate: ExperimentCandidate) -> DrillResult: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class CampaignStopError(Exception):
|
|
58
|
+
"""Raised when the loop hits a stop condition."""
|
|
59
|
+
|
|
60
|
+
reason: StopReason
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class M5CampaignEngine:
|
|
64
|
+
"""Deterministic iterate-until-stop engine for one ADR-M5-4 campaign."""
|
|
65
|
+
|
|
66
|
+
def __init__(
|
|
67
|
+
self,
|
|
68
|
+
campaign: M5Campaign,
|
|
69
|
+
*,
|
|
70
|
+
source: CandidateSource,
|
|
71
|
+
gates: CandidateGatePipeline,
|
|
72
|
+
runner: Runner,
|
|
73
|
+
approver: Approver | None = None,
|
|
74
|
+
coverage_target_fn: Callable[[M5Campaign, CampaignProgress], int] | None = None,
|
|
75
|
+
blast_check: Callable[[ExperimentCandidate, CampaignProgress], str | None] | None = None,
|
|
76
|
+
rationale_fn: Callable[[ExperimentCandidate], str] | None = None,
|
|
77
|
+
now_epoch_s: Callable[[], float] = time.time,
|
|
78
|
+
) -> None:
|
|
79
|
+
self.campaign = campaign
|
|
80
|
+
self._source = source
|
|
81
|
+
self._gates = gates
|
|
82
|
+
self._runner = runner
|
|
83
|
+
self._approver = approver
|
|
84
|
+
self._coverage_target_fn = coverage_target_fn or (lambda c, p: c.coverage_target)
|
|
85
|
+
self._blast_check = blast_check
|
|
86
|
+
self._rationale_fn = rationale_fn
|
|
87
|
+
self._now = now_epoch_s
|
|
88
|
+
self.progress = CampaignProgress()
|
|
89
|
+
self.state_stack: list[StopReason] = []
|
|
90
|
+
self.covered_cells: set[str] = set()
|
|
91
|
+
self._stop_requested = False
|
|
92
|
+
|
|
93
|
+
def request_stop(self) -> None:
|
|
94
|
+
"""Explicitly request a manual stop (surfaces STOP_CONDITION)."""
|
|
95
|
+
self._stop_requested = True
|
|
96
|
+
|
|
97
|
+
# ── Stop-condition primitives ─────────────────────────────────────
|
|
98
|
+
def _budget_exhausted(self) -> bool:
|
|
99
|
+
return self.progress.runs_executed >= self.campaign.max_runs
|
|
100
|
+
|
|
101
|
+
def _deadline_passed(self) -> bool:
|
|
102
|
+
return (
|
|
103
|
+
self.campaign.deadline_epoch_s is not None
|
|
104
|
+
and self._now() > self.campaign.deadline_epoch_s
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _coverage_reached(self) -> bool:
|
|
108
|
+
target = self._coverage_target_fn(self.campaign, self.progress)
|
|
109
|
+
return len(self.covered_cells) >= target
|
|
110
|
+
|
|
111
|
+
def _check_stop(self, *, raise_on_stop: bool = True) -> StopReason | None:
|
|
112
|
+
if self._budget_exhausted():
|
|
113
|
+
reason = StopReason.BUDGET_EXHAUSTED
|
|
114
|
+
elif self._deadline_passed():
|
|
115
|
+
reason = StopReason.DEADLINE_PASSED
|
|
116
|
+
elif self._explicit_stop():
|
|
117
|
+
reason = StopReason.STOP_CONDITION
|
|
118
|
+
elif self._coverage_reached():
|
|
119
|
+
reason = StopReason.COVERAGE_REACHED
|
|
120
|
+
else:
|
|
121
|
+
return None
|
|
122
|
+
if raise_on_stop:
|
|
123
|
+
raise CampaignStopError(reason)
|
|
124
|
+
return reason
|
|
125
|
+
|
|
126
|
+
def _explicit_stop(self) -> bool:
|
|
127
|
+
# The caller triggers an explicit stop by raising the flag through the
|
|
128
|
+
# ``stop_condition`` handle; here a non-empty free-form stop condition
|
|
129
|
+
# with no remaining candidates is surfaced as a manual stop signal.
|
|
130
|
+
return bool(self._stop_requested)
|
|
131
|
+
|
|
132
|
+
# ── Iteration ─────────────────────────────────────────────────────
|
|
133
|
+
def _remember_cell(self, cell: CoverageCell) -> None:
|
|
134
|
+
self.covered_cells.add(cell.key)
|
|
135
|
+
|
|
136
|
+
def _approval_required(self) -> bool:
|
|
137
|
+
return self.campaign.mode is CampaignMode.SUPERVISED
|
|
138
|
+
|
|
139
|
+
def iterate(self) -> CampaignProgress:
|
|
140
|
+
"""Advance one step (one candidate considered).
|
|
141
|
+
|
|
142
|
+
Returns the progress snapshot after the step. Raises ``CampaignStopError``
|
|
143
|
+
when a stop condition is reached.
|
|
144
|
+
"""
|
|
145
|
+
self._check_stop()
|
|
146
|
+
candidate = self._source.next_candidate()
|
|
147
|
+
if candidate is None:
|
|
148
|
+
# No more candidates to try — treat as budget-exhausted equivalent.
|
|
149
|
+
self.state_stack.append(StopReason.BUDGET_EXHAUSTED)
|
|
150
|
+
raise CampaignStopError(StopReason.BUDGET_EXHAUSTED)
|
|
151
|
+
|
|
152
|
+
decision: CandidateDecision = self._gates.gate(candidate)
|
|
153
|
+
if decision.rejected:
|
|
154
|
+
if decision.gate is CandidateGate.RESOURCE_CONFLICT:
|
|
155
|
+
# A violating candidate (in-flight conflict, over blast budget)
|
|
156
|
+
# is a hard bound-abort: never executed out of bounds.
|
|
157
|
+
self.progress.candidates_aborted += 1
|
|
158
|
+
self.progress.record(
|
|
159
|
+
candidate.id,
|
|
160
|
+
ApproveDecision.DENIED,
|
|
161
|
+
rationale=decision.reason or "resource conflict",
|
|
162
|
+
)
|
|
163
|
+
raise CampaignStopError(StopReason.RESOURCE_CONFLICT)
|
|
164
|
+
self.progress.candidates_rejected += 1
|
|
165
|
+
# A rejected candidate is not executed and does not consume budget.
|
|
166
|
+
return self.progress
|
|
167
|
+
|
|
168
|
+
if self._approval_required():
|
|
169
|
+
self.progress.candidates_pending_approval += 1
|
|
170
|
+
rationale = self._rationale_fn(candidate) if self._rationale_fn else ""
|
|
171
|
+
self.progress.record(candidate.id, ApproveDecision.PENDING, rationale=rationale)
|
|
172
|
+
approved = self._approver.approve(candidate) if self._approver is not None else False
|
|
173
|
+
if not approved:
|
|
174
|
+
# denied candidates are skipped and recorded, never run.
|
|
175
|
+
self.progress.candidates_denied += 1
|
|
176
|
+
self.progress.record(candidate.id, ApproveDecision.DENIED, rationale=rationale)
|
|
177
|
+
return self.progress
|
|
178
|
+
self.progress.record(candidate.id, ApproveDecision.APPROVED, rationale=rationale)
|
|
179
|
+
|
|
180
|
+
# Protective bound-check (autonomous): blast-radius / resource cap.
|
|
181
|
+
if self._blast_check is not None:
|
|
182
|
+
blast_reason = self._blast_check(candidate, self.progress)
|
|
183
|
+
if blast_reason is not None:
|
|
184
|
+
self.progress.candidates_aborted += 1
|
|
185
|
+
self.progress.record(
|
|
186
|
+
candidate.id, ApproveDecision.DENIED, rationale=f"bound-abort: {blast_reason}"
|
|
187
|
+
)
|
|
188
|
+
raise CampaignStopError(StopReason.RESOURCE_CONFLICT)
|
|
189
|
+
|
|
190
|
+
_run_id, _outcome_id, cell = self._runner.run(candidate)
|
|
191
|
+
self.progress.runs_executed += 1
|
|
192
|
+
self._remember_cell(cell)
|
|
193
|
+
|
|
194
|
+
# Re-check stop after this run consumed budget and covered a cell.
|
|
195
|
+
self._check_stop()
|
|
196
|
+
return self.progress
|
|
197
|
+
|
|
198
|
+
def run_until_stop(self) -> StopReason:
|
|
199
|
+
"""Drive the loop until a stop condition is met; return it."""
|
|
200
|
+
while True:
|
|
201
|
+
try:
|
|
202
|
+
self.iterate()
|
|
203
|
+
except CampaignStopError as stop:
|
|
204
|
+
self.state_stack.append(stop.reason)
|
|
205
|
+
return stop.reason
|