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/cli/resolver.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""General command-tree prefix resolution (the "unique prefix" contract).
|
|
2
|
+
|
|
3
|
+
Every group in the mayhem CLI is a :class:`PrefixGroup`, so abbreviation works
|
|
4
|
+
at *every* level of the tree, not just the root: ``mayhem e s`` resolves to
|
|
5
|
+
``experiment show`` because ``s`` is unambiguous inside ``experiment``.
|
|
6
|
+
|
|
7
|
+
Resolution rules, applied per level:
|
|
8
|
+
|
|
9
|
+
1. exact name wins;
|
|
10
|
+
2. otherwise collect all commands whose names start with the given prefix;
|
|
11
|
+
3. exactly one candidate -> resolved;
|
|
12
|
+
4. zero candidates -> usage error with close-match suggestions;
|
|
13
|
+
5. multiple candidates -> educational ambiguity error listing every match.
|
|
14
|
+
|
|
15
|
+
Prefix resolution happens strictly before any callback runs, so a shorthand
|
|
16
|
+
invocation can never bypass validation or safety gates — it dispatches to the
|
|
17
|
+
identical handler object.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import difflib
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
PREFIX_HELP = (
|
|
27
|
+
"Commands may be abbreviated to any unique prefix at every level of this "
|
|
28
|
+
"tree (e.g. 'mayhem e v' for 'mayhem experiment validate'). Exact names and "
|
|
29
|
+
"'--help' always work."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CommandResolutionError(click.UsageError):
|
|
34
|
+
"""A command token could not resolve to exactly one command."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, token: str, candidates: tuple[str, ...]) -> None:
|
|
37
|
+
self.token = token
|
|
38
|
+
self.candidates = candidates
|
|
39
|
+
super().__init__(self._render(), ctx=None)
|
|
40
|
+
|
|
41
|
+
def _render(self) -> str:
|
|
42
|
+
if not self.candidates:
|
|
43
|
+
difflib.get_close_matches(self.token, [], n=1)
|
|
44
|
+
return f"No command matches {self.token!r}."
|
|
45
|
+
lines = "\n".join(f" - {name}" for name in self.candidates)
|
|
46
|
+
return (
|
|
47
|
+
f"Command prefix {self.token!r} is ambiguous; it matches "
|
|
48
|
+
f"{len(self.candidates)} commands:\n{lines}\n"
|
|
49
|
+
"Use a longer prefix to select one of them."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PrefixGroup(click.Group):
|
|
54
|
+
"""A Click Group whose subcommands resolve by unique prefix."""
|
|
55
|
+
|
|
56
|
+
def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
|
|
57
|
+
exact = super().get_command(ctx, cmd_name)
|
|
58
|
+
if exact is not None:
|
|
59
|
+
return exact
|
|
60
|
+
candidates = sorted(name for name in self.list_commands(ctx) if name.startswith(cmd_name))
|
|
61
|
+
if len(candidates) == 1:
|
|
62
|
+
return super().get_command(ctx, candidates[0])
|
|
63
|
+
if not candidates:
|
|
64
|
+
raise CommandResolutionError(cmd_name, ())
|
|
65
|
+
raise CommandResolutionError(cmd_name, tuple(candidates))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_group(name: str, help_text: str, **attrs: object) -> PrefixGroup:
|
|
69
|
+
"""Factory so nested groups inherit prefix resolution automatically."""
|
|
70
|
+
attrs.setdefault("help", help_text)
|
|
71
|
+
attrs.setdefault("epilog", PREFIX_HELP)
|
|
72
|
+
return PrefixGroup(name=name, **attrs) # type: ignore[arg-type]
|
mayhem/cli/services.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Application services for the CLI — the only place CLI touches mayhem internals.
|
|
2
|
+
|
|
3
|
+
Handlers stay thin: they translate arguments into service calls and results
|
|
4
|
+
into output. Everything here is UI-framework-agnostic so a future REST/UI
|
|
5
|
+
layer can reuse it verbatim.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
17
|
+
|
|
18
|
+
from mayhem.config import load_config, save_snapshot
|
|
19
|
+
from mayhem.controller.executor import RunEngine, RunResult
|
|
20
|
+
from mayhem.controller.planner import plan_drill, plan_maniac
|
|
21
|
+
from mayhem.controller.safety import SafetyContext, environment_fingerprint
|
|
22
|
+
from mayhem.domain.experiments import BlastRadiusBudget, DrillSpec, ExecutionPlan
|
|
23
|
+
from mayhem.domain.topology import (
|
|
24
|
+
Edge,
|
|
25
|
+
EdgeKind,
|
|
26
|
+
NodeKind,
|
|
27
|
+
ProcessNode,
|
|
28
|
+
TopologyGraph,
|
|
29
|
+
)
|
|
30
|
+
from mayhem.infra.lease_repository import SQLiteLeaseSink
|
|
31
|
+
from mayhem.infra.store import Store
|
|
32
|
+
from mayhem.spec import load_drill
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
|
|
37
|
+
from mayhem.domain.events import Event
|
|
38
|
+
from mayhem.toolkit.registry import CapabilityReport
|
|
39
|
+
|
|
40
|
+
DEFAULT_DB = "mayhem.db"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def open_store(db: str) -> Store:
|
|
44
|
+
return Store.open_migrated(Path(db))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_graph(compose: str | None) -> TopologyGraph:
|
|
48
|
+
"""Build a topology graph from a compose blueprint (ADR-0006).
|
|
49
|
+
|
|
50
|
+
Drill specs are compose-native (Phase 6): the graph is always derived
|
|
51
|
+
from ``docker-compose.yaml``, so the manual ``--process``/``--service``/
|
|
52
|
+
``--host`` overrides are gone. ``compose`` defaults to auto-detect in the
|
|
53
|
+
caller (``_resolve_compose``), so reaching here with ``None`` means no
|
|
54
|
+
blueprint was found.
|
|
55
|
+
"""
|
|
56
|
+
if compose is None:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
"no docker-compose blueprint found — pass --compose <path> "
|
|
59
|
+
"or place a compose file in the working directory"
|
|
60
|
+
)
|
|
61
|
+
from mayhem.cli.app import _STATE
|
|
62
|
+
from mayhem.cli.topology import _resolve_engine
|
|
63
|
+
from mayhem.topology.providers.adapter_registry import best_effort as runtime_best_effort
|
|
64
|
+
from mayhem.topology.providers.compose import ComposeFileProvider
|
|
65
|
+
from mayhem.topology.service import TopologyService
|
|
66
|
+
|
|
67
|
+
engine = _resolve_engine(str(_STATE.get("engine", "")))
|
|
68
|
+
|
|
69
|
+
compose_provider = ComposeFileProvider(compose)
|
|
70
|
+
runtime_provider = runtime_best_effort(engine)
|
|
71
|
+
if runtime_provider is not None:
|
|
72
|
+
runtime_provider.filter_by_compose(
|
|
73
|
+
compose_provider.project_name,
|
|
74
|
+
compose_provider.service_names,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
result = (
|
|
78
|
+
TopologyService()
|
|
79
|
+
.discover(
|
|
80
|
+
[provider for provider in (compose_provider, runtime_provider) if provider is not None]
|
|
81
|
+
)
|
|
82
|
+
.graph
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
existing_process_names = {n.name for n in result.nodes if n.kind == NodeKind.PROCESS}
|
|
86
|
+
extra_nodes: list[Any] = []
|
|
87
|
+
extra_edges: list[Edge] = []
|
|
88
|
+
for svc in result.nodes:
|
|
89
|
+
if svc.kind == NodeKind.SERVICE and svc.name not in existing_process_names:
|
|
90
|
+
proc_id = f"p-{svc.name}"
|
|
91
|
+
extra_nodes.append(ProcessNode(id=proc_id, name=svc.name, pid=0, host_id="h-local"))
|
|
92
|
+
extra_edges.append(Edge(src=svc.id, dst=proc_id, kind=EdgeKind.RUNS_ON))
|
|
93
|
+
if extra_nodes:
|
|
94
|
+
result = TopologyGraph(
|
|
95
|
+
nodes=tuple(result.nodes) + tuple(extra_nodes),
|
|
96
|
+
edges=tuple(result.edges) + tuple(extra_edges),
|
|
97
|
+
)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class Prepared:
|
|
103
|
+
config_snapshot_id: str
|
|
104
|
+
topology_snapshot_id: str
|
|
105
|
+
fingerprint: str
|
|
106
|
+
safety: SafetyContext
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def prepare(
|
|
110
|
+
*,
|
|
111
|
+
config_path: str | None,
|
|
112
|
+
profile: str | None,
|
|
113
|
+
allow_critical: bool,
|
|
114
|
+
store: Store,
|
|
115
|
+
graph: TopologyGraph,
|
|
116
|
+
compose: str | None,
|
|
117
|
+
spec_path: str | None = None,
|
|
118
|
+
) -> Prepared:
|
|
119
|
+
cfg, sources = load_config(
|
|
120
|
+
config_path=config_path,
|
|
121
|
+
profile=profile,
|
|
122
|
+
environ={"MAYHEM_LOG_LEVEL": "INFO"},
|
|
123
|
+
skip_default_file_if_spec=spec_path,
|
|
124
|
+
)
|
|
125
|
+
cfg_snapshot_id = save_snapshot(store, cfg, sources)
|
|
126
|
+
|
|
127
|
+
fingerprint = environment_fingerprint(
|
|
128
|
+
host_names=[n.name for n in graph.of_kind(NodeKind.HOST)],
|
|
129
|
+
compose_digest=_compose_digest(compose),
|
|
130
|
+
profile=profile,
|
|
131
|
+
)
|
|
132
|
+
topo_snapshot_id = "topo-" + fingerprint[:12]
|
|
133
|
+
with store.write() as conn:
|
|
134
|
+
conn.execute(
|
|
135
|
+
"INSERT OR IGNORE INTO topology_snapshots (id, run_id, graph_json, drift_report,"
|
|
136
|
+
" fingerprint) VALUES (?, NULL, ?, '{}', ?)",
|
|
137
|
+
(topo_snapshot_id, graph.model_dump_json(), fingerprint),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
budget = cfg.blast_radius or BlastRadiusBudget()
|
|
141
|
+
return Prepared(
|
|
142
|
+
config_snapshot_id=cfg_snapshot_id,
|
|
143
|
+
topology_snapshot_id=topo_snapshot_id,
|
|
144
|
+
fingerprint=fingerprint,
|
|
145
|
+
safety=SafetyContext(
|
|
146
|
+
policy=cfg.policy,
|
|
147
|
+
budget=budget,
|
|
148
|
+
fingerprint=fingerprint,
|
|
149
|
+
allow_critical_cli=allow_critical,
|
|
150
|
+
),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class CompiledPlan:
|
|
156
|
+
run_id: str
|
|
157
|
+
plan: ExecutionPlan
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def plan_from_spec(
|
|
161
|
+
spec_path: str,
|
|
162
|
+
graph: TopologyGraph,
|
|
163
|
+
*,
|
|
164
|
+
prepared: Prepared,
|
|
165
|
+
engine: str = "podman",
|
|
166
|
+
) -> CompiledPlan:
|
|
167
|
+
"""Compile a drill spec into a frozen :class:`ExecutionPlan`.
|
|
168
|
+
|
|
169
|
+
The drill spec is validated (schema), then every referenced container is
|
|
170
|
+
required to exist in the topology graph before the plan is compiled
|
|
171
|
+
([ADR-0019]/[ADR-0021]). ``engine`` flows into the plan so PIDs/IPs are
|
|
172
|
+
resolved against the right runtime at execution time ([ADR-0020]).
|
|
173
|
+
"""
|
|
174
|
+
spec = load_drill(spec_path)
|
|
175
|
+
# The run id is `r-<name>-<suffix>`: the readable base keeps the drill
|
|
176
|
+
# identifiable, while the unique suffix lets any number of runs against the
|
|
177
|
+
# same spec be recorded in one persistent DB without colliding on the
|
|
178
|
+
# `runs.id` PRIMARY KEY.
|
|
179
|
+
run_id = f"r-{spec.name}-{uuid.uuid4().hex[:8]}"
|
|
180
|
+
common: dict[str, str] = {
|
|
181
|
+
"config_snapshot_id": prepared.config_snapshot_id,
|
|
182
|
+
"topology_snapshot_id": prepared.topology_snapshot_id,
|
|
183
|
+
"environment_fingerprint": prepared.fingerprint,
|
|
184
|
+
}
|
|
185
|
+
plan = plan_drill(
|
|
186
|
+
run_id,
|
|
187
|
+
spec,
|
|
188
|
+
graph,
|
|
189
|
+
**common,
|
|
190
|
+
engine=engine,
|
|
191
|
+
spec_dir=str(Path(spec_path).parent),
|
|
192
|
+
)
|
|
193
|
+
return CompiledPlan(run_id=run_id, plan=plan)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def plan_maniac_from_spec(
|
|
197
|
+
spec_path: str,
|
|
198
|
+
graph: TopologyGraph,
|
|
199
|
+
*,
|
|
200
|
+
prepared: Prepared,
|
|
201
|
+
engine: str = "podman",
|
|
202
|
+
config_path: str | None = None,
|
|
203
|
+
profile: str | None = None,
|
|
204
|
+
steps: int | None = None,
|
|
205
|
+
spec: DrillSpec | None = None,
|
|
206
|
+
) -> CompiledPlan:
|
|
207
|
+
"""Compile a drill spec into a random maniac plan (ADR-M5-1).
|
|
208
|
+
|
|
209
|
+
Behaves like :func:`plan_from_spec` (schema validation, topology
|
|
210
|
+
resolution) but replaces the authored execution with ``run_level`` random
|
|
211
|
+
rounds. The maniac settings come from the spec's own ``config.maniac``
|
|
212
|
+
block when present, otherwise from the layered ``mayhem.yaml`` config
|
|
213
|
+
(``maniac:`` key); the spec wins when both exist (ADR-M5-1). ``steps``
|
|
214
|
+
(CLI ``-s/--steps``) overrides the round count on top of either source.
|
|
215
|
+
|
|
216
|
+
``spec`` supplies an already-built spec instead of loading ``spec_path``
|
|
217
|
+
— the ``mayhem maniac -c compose`` no-spec mode synthesizes its config
|
|
218
|
+
from the topology (:func:`mayhem.controller.planner.synthesize_maniac_spec`)
|
|
219
|
+
and has no file to load. ``spec_path`` then names the cwd for relative
|
|
220
|
+
artifacts (load-script embedding) and is not read.
|
|
221
|
+
"""
|
|
222
|
+
document = spec if spec is not None else load_drill(spec_path)
|
|
223
|
+
maniac = document.config.maniac
|
|
224
|
+
if maniac is None:
|
|
225
|
+
cfg, _sources = load_config(
|
|
226
|
+
config_path=config_path,
|
|
227
|
+
profile=profile,
|
|
228
|
+
environ={},
|
|
229
|
+
skip_default_file_if_spec=None if spec is not None else spec_path,
|
|
230
|
+
)
|
|
231
|
+
maniac = cfg.maniac
|
|
232
|
+
if steps is not None:
|
|
233
|
+
maniac = maniac.model_copy(update={"run_level": steps})
|
|
234
|
+
run_id = f"r-{document.name}-{uuid.uuid4().hex[:8]}"
|
|
235
|
+
common: dict[str, str] = {
|
|
236
|
+
"config_snapshot_id": prepared.config_snapshot_id,
|
|
237
|
+
"topology_snapshot_id": prepared.topology_snapshot_id,
|
|
238
|
+
"environment_fingerprint": prepared.fingerprint,
|
|
239
|
+
}
|
|
240
|
+
plan = plan_maniac(
|
|
241
|
+
run_id,
|
|
242
|
+
document,
|
|
243
|
+
graph,
|
|
244
|
+
**common,
|
|
245
|
+
engine=engine,
|
|
246
|
+
spec_dir=str(Path(spec_path).parent if spec_path else Path.cwd()),
|
|
247
|
+
maniac=maniac,
|
|
248
|
+
)
|
|
249
|
+
return CompiledPlan(run_id=run_id, plan=plan)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def engine_for(
|
|
253
|
+
store: Store,
|
|
254
|
+
engine: str = "podman",
|
|
255
|
+
*,
|
|
256
|
+
live_graph: Callable[[], TopologyGraph] | None = None,
|
|
257
|
+
on_event: Callable[[Event], None] | None = None,
|
|
258
|
+
bypass: dict[tuple[str, str], str] | None = None,
|
|
259
|
+
) -> RunEngine:
|
|
260
|
+
return RunEngine(
|
|
261
|
+
store,
|
|
262
|
+
SQLiteLeaseSink(store),
|
|
263
|
+
engine=engine,
|
|
264
|
+
live_graph=live_graph,
|
|
265
|
+
on_event=on_event,
|
|
266
|
+
bypass=bypass,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def recent_runs(store: Store, limit: int) -> list[dict[str, Any]]:
|
|
271
|
+
rows = store.query(
|
|
272
|
+
"SELECT id, kind, status, started_at FROM runs ORDER BY started_at DESC LIMIT ?",
|
|
273
|
+
(limit,),
|
|
274
|
+
)
|
|
275
|
+
return [dict(row) for row in rows]
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def run_detail(store: Store, run_id: str) -> dict[str, Any] | None:
|
|
279
|
+
rows = store.query("SELECT * FROM runs WHERE id = ?", (run_id,))
|
|
280
|
+
return dict(rows[0]) if rows else None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def run_journal(store: Store, run_id: str) -> dict[str, Any]:
|
|
284
|
+
steps = [
|
|
285
|
+
dict(row)
|
|
286
|
+
for row in store.query(
|
|
287
|
+
"SELECT seq, id, action_type, status, started_at, ended_at, error"
|
|
288
|
+
" FROM step_runs WHERE run_id = ? ORDER BY seq",
|
|
289
|
+
(run_id,),
|
|
290
|
+
)
|
|
291
|
+
]
|
|
292
|
+
events = [
|
|
293
|
+
dict(row)
|
|
294
|
+
for row in store.query(
|
|
295
|
+
"SELECT ts, kind, payload_json FROM events WHERE run_id = ? ORDER BY ts",
|
|
296
|
+
(run_id,),
|
|
297
|
+
)
|
|
298
|
+
]
|
|
299
|
+
leases = [
|
|
300
|
+
dict(row)
|
|
301
|
+
for row in store.query(
|
|
302
|
+
"SELECT id, state, fault_id, release_mechanism FROM fault_leases"
|
|
303
|
+
" WHERE run_id = ? ORDER BY created_epoch_s",
|
|
304
|
+
(run_id,),
|
|
305
|
+
)
|
|
306
|
+
]
|
|
307
|
+
return {"steps": steps, "events": events, "leases": leases}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def effective_config(config_path: str | None, profile: str | None) -> tuple[Any, dict[str, str]]:
|
|
311
|
+
return load_config(config_path=config_path, profile=profile, environ={})
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def probe_capabilities(host: str) -> CapabilityReport:
|
|
315
|
+
from mayhem.toolkit.registry import default_registry
|
|
316
|
+
|
|
317
|
+
return default_registry().probe(host=host)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class CampaignExecutionError(Exception):
|
|
321
|
+
"""Raised when a campaign abort-campaign policy hits an unexpected error."""
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class ObservationSink(Protocol):
|
|
325
|
+
"""Anything able to append rows to the observations table."""
|
|
326
|
+
|
|
327
|
+
def save_observation(
|
|
328
|
+
self,
|
|
329
|
+
kind: str,
|
|
330
|
+
*,
|
|
331
|
+
run_id: str = "",
|
|
332
|
+
source: str = "",
|
|
333
|
+
data: dict[str, object] | None = None,
|
|
334
|
+
) -> None: ...
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@dataclass
|
|
338
|
+
class CampaignRunEntry:
|
|
339
|
+
"""Outcome of one experiment spec executed within a campaign."""
|
|
340
|
+
|
|
341
|
+
spec_path: str
|
|
342
|
+
run_id: str
|
|
343
|
+
status: str # completed | failed
|
|
344
|
+
verdict: str = ""
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass
|
|
348
|
+
class CampaignRunResult:
|
|
349
|
+
"""Aggregate result of executing a campaign's experiment sequence."""
|
|
350
|
+
|
|
351
|
+
campaign_id: str
|
|
352
|
+
status: str # completed | failed | aborted
|
|
353
|
+
runs: list[CampaignRunEntry] = field(default_factory=list)
|
|
354
|
+
|
|
355
|
+
def summary_md(self) -> str:
|
|
356
|
+
lines = [f"# Campaign {self.campaign_id}", "", f"**status**: {self.status}"]
|
|
357
|
+
lines.append(f"**runs**: {len(self.runs)}")
|
|
358
|
+
for entry in self.runs:
|
|
359
|
+
mark = "ok" if entry.status == "completed" else "FAIL"
|
|
360
|
+
lines.append(f"- [{mark}] {entry.spec_path} → {entry.run_id}")
|
|
361
|
+
return "\n".join(lines)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def run_campaign_sequence(
|
|
365
|
+
*,
|
|
366
|
+
campaign_id: str,
|
|
367
|
+
experiments: list[str],
|
|
368
|
+
policy: dict[str, Any],
|
|
369
|
+
window: dict[str, Any],
|
|
370
|
+
store: ObservationSink,
|
|
371
|
+
run_one: Callable[[str], RunResult],
|
|
372
|
+
now_fn: Callable[[], float] = time.time,
|
|
373
|
+
) -> CampaignRunResult:
|
|
374
|
+
"""Execute one spec at a time in order, honoring campaign policy and window.
|
|
375
|
+
|
|
376
|
+
``run_one(spec_path)`` is the injectable per-spec executor, returning a
|
|
377
|
+
:class:`RunResult` or raising on failure. The on_failure policy is
|
|
378
|
+
``abort_campaign`` (stop at first failure), ``skip_and_continue``, or
|
|
379
|
+
``retry_then_abort`` (one bounded retry, then abort). ``window.max_duration_s``
|
|
380
|
+
is a hard deadline; ``window.cooldown_between_experiments_s`` is slept
|
|
381
|
+
between specs.
|
|
382
|
+
"""
|
|
383
|
+
on_failure = policy.get("on_experiment_failure", "abort_campaign")
|
|
384
|
+
max_duration_s = float(window.get("max_duration_s") or 3600.0)
|
|
385
|
+
cooldown_s = max(0.0, float(window.get("cooldown_between_experiments_s") or 0.0))
|
|
386
|
+
|
|
387
|
+
started = now_fn()
|
|
388
|
+
result = CampaignRunResult(campaign_id=campaign_id, status="completed")
|
|
389
|
+
for i, spec_path in enumerate(experiments):
|
|
390
|
+
elapsed = now_fn() - started
|
|
391
|
+
if elapsed >= max_duration_s:
|
|
392
|
+
result.status = "aborted"
|
|
393
|
+
store.save_observation(
|
|
394
|
+
"campaign_stop",
|
|
395
|
+
source=campaign_id,
|
|
396
|
+
data={"reason": "deadline_passed", "elapsed_s": elapsed},
|
|
397
|
+
)
|
|
398
|
+
break
|
|
399
|
+
|
|
400
|
+
def _attempt(path: str) -> RunResult | None:
|
|
401
|
+
try:
|
|
402
|
+
return run_one(path)
|
|
403
|
+
except Exception as exc:
|
|
404
|
+
if on_failure == "abort_campaign":
|
|
405
|
+
raise CampaignExecutionError(path, exc) from exc
|
|
406
|
+
return None
|
|
407
|
+
|
|
408
|
+
def _record(ran: RunResult, _sp: str = spec_path, _i: int = i) -> None:
|
|
409
|
+
result.runs.append(
|
|
410
|
+
CampaignRunEntry(
|
|
411
|
+
spec_path=_sp,
|
|
412
|
+
run_id=ran.run_id,
|
|
413
|
+
status=ran.status,
|
|
414
|
+
verdict=ran.verdict.value if ran.verdict else "",
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
store.save_observation(
|
|
418
|
+
"campaign_run",
|
|
419
|
+
run_id=ran.run_id,
|
|
420
|
+
source=campaign_id,
|
|
421
|
+
data={"spec": _sp, "status": ran.status, "index": _i},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
ran = _attempt(spec_path)
|
|
425
|
+
if ran is not None:
|
|
426
|
+
_record(ran)
|
|
427
|
+
|
|
428
|
+
if ran is not None and ran.status == "completed":
|
|
429
|
+
pass # continue; cooldown after non-final specs below
|
|
430
|
+
elif ran is not None: # failed / aborted
|
|
431
|
+
if on_failure == "skip_and_continue":
|
|
432
|
+
continue
|
|
433
|
+
if on_failure == "retry_then_abort":
|
|
434
|
+
retry = _attempt(spec_path)
|
|
435
|
+
if retry is not None and retry.status == "completed":
|
|
436
|
+
_record(retry)
|
|
437
|
+
continue
|
|
438
|
+
result.status = "failed"
|
|
439
|
+
break
|
|
440
|
+
elif on_failure != "skip_and_continue":
|
|
441
|
+
result.status = "failed"
|
|
442
|
+
break
|
|
443
|
+
|
|
444
|
+
if i < len(experiments) - 1 and cooldown_s > 0:
|
|
445
|
+
time.sleep(cooldown_s)
|
|
446
|
+
|
|
447
|
+
if result.status == "completed":
|
|
448
|
+
store.save_observation(
|
|
449
|
+
"campaign_done",
|
|
450
|
+
source=campaign_id,
|
|
451
|
+
data={"runs": len(result.runs)},
|
|
452
|
+
)
|
|
453
|
+
return result
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _compose_digest(graph_source: str | None) -> str:
|
|
457
|
+
if graph_source is None:
|
|
458
|
+
return "no-compose"
|
|
459
|
+
return hashlib.sha256(Path(graph_source).read_bytes()).hexdigest()
|
mayhem/cli/style.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""TTY-aware ANSI styling shared by every CLI command.
|
|
2
|
+
|
|
3
|
+
Wrapping user-facing lines through these helpers keeps log streams scannable:
|
|
4
|
+
green for success, cyan for informational, yellow for warnings and bypasses,
|
|
5
|
+
orange for failures and red-zone states. Color auto-cancels when the stream is
|
|
6
|
+
not a terminal (pipes, CI, test capture) or ``NO_COLOR`` is set, so rendered —
|
|
7
|
+
and captured — output stays plain.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from typing import IO
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
# Orange ≈ ANSI 38;2;255;159;26 — click has no 16-color orange.
|
|
19
|
+
ORANGE: tuple[int, int, int] = (255, 159, 26)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _stream(*, err: bool) -> IO[str]:
|
|
23
|
+
return sys.stderr if err else sys.stdout
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _use_color(stream: IO[str]) -> bool:
|
|
27
|
+
if os.environ.get("NO_COLOR"):
|
|
28
|
+
return False
|
|
29
|
+
return bool(getattr(stream, "isatty", lambda: False)())
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _style(text: str, fg: str | tuple[int, int, int], *, err: bool, bold: bool) -> str:
|
|
33
|
+
if not _use_color(_stream(err=err)):
|
|
34
|
+
return text
|
|
35
|
+
return click.style(text, fg=fg, bold=bold)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def green(text: str, *, err: bool = False, bold: bool = False) -> str:
|
|
39
|
+
"""Success and healthy-state marks."""
|
|
40
|
+
return _style(text, "green", err=err, bold=bold)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cyan(text: str, *, err: bool = False, bold: bool = False) -> str:
|
|
44
|
+
"""Informational lines, timestamps, and step identifiers."""
|
|
45
|
+
return _style(text, "cyan", err=err, bold=bold)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def yellow(text: str, *, err: bool = False, bold: bool = False) -> str:
|
|
49
|
+
"""Warnings and bypassed (skip-with-reason) outcomes."""
|
|
50
|
+
return _style(text, "yellow", err=err, bold=bold)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def orange(text: str, *, err: bool = False, bold: bool = False) -> str:
|
|
54
|
+
"""Failures and anything in the red zone."""
|
|
55
|
+
return _style(text, ORANGE, err=err, bold=bold)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def ts(text: str, *, err: bool = False, bold: bool = False) -> str:
|
|
59
|
+
"""Timestamps and wall-clock — bright-cyan, visible on dark terminals."""
|
|
60
|
+
return _style(text, "bright_cyan", err=err, bold=bold)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ok(text: str, *, err: bool = False, bold: bool = True) -> str:
|
|
64
|
+
"""``[ok]`` marks and green confirmations."""
|
|
65
|
+
return _style(text, "green", err=err, bold=bold)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def warn(text: str, *, err: bool = True, bold: bool = True) -> str:
|
|
69
|
+
"""``warning:`` prefix — yellow, bold."""
|
|
70
|
+
return _style(text, "yellow", err=err, bold=bold)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def info(text: str, *, err: bool = False, bold: bool = False) -> str:
|
|
74
|
+
"""``info:`` prefix — cyan."""
|
|
75
|
+
return _style(text, "cyan", err=err, bold=bold)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def danger(text: str, *, err: bool = True, bold: bool = True) -> str:
|
|
79
|
+
"""``error:`` / red-zone prefix — orange, bold."""
|
|
80
|
+
return _style(text, ORANGE, err=err, bold=bold)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def state(text: str) -> str:
|
|
84
|
+
"""Color a run/step status value by what it means.
|
|
85
|
+
|
|
86
|
+
Call on already-padded text (``state(f"{status:<10}")``) so the escape
|
|
87
|
+
codes never disturb column alignment.
|
|
88
|
+
"""
|
|
89
|
+
mapping = {
|
|
90
|
+
"completed": green,
|
|
91
|
+
"running": cyan,
|
|
92
|
+
"pending": cyan,
|
|
93
|
+
"created": cyan,
|
|
94
|
+
"planning": cyan,
|
|
95
|
+
"validated": cyan,
|
|
96
|
+
"recovering": yellow,
|
|
97
|
+
"skipped": yellow,
|
|
98
|
+
"cancelled": yellow,
|
|
99
|
+
"bypassed": yellow,
|
|
100
|
+
}
|
|
101
|
+
return mapping.get(text, orange)(text)
|
mayhem/cli/toolkit.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""``toolkit`` group: fault catalog and capability probing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from mayhem.cli.resolver import make_group
|
|
10
|
+
from mayhem.domain.catalog import all_definitions
|
|
11
|
+
|
|
12
|
+
toolkit = make_group("toolkit", "Inspect the fault catalog and local tool capabilities.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@toolkit.command("faults")
|
|
16
|
+
def faults() -> None:
|
|
17
|
+
"""List the fault catalog with risk and compensatability."""
|
|
18
|
+
for definition in sorted(all_definitions(), key=lambda d: d.id):
|
|
19
|
+
undoable = "yes" if definition.reversible else "no"
|
|
20
|
+
click.echo(f"{definition.id:<24} risk={definition.risk.value:<6} undo={undoable}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@toolkit.command("list")
|
|
24
|
+
@click.option("--host", default="local", show_default=True, help="Host to probe.")
|
|
25
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the CapabilityReport as JSON.")
|
|
26
|
+
def list_tools(host: str, as_json: bool) -> None:
|
|
27
|
+
"""Probe declared tool manifests on the host and report capabilities."""
|
|
28
|
+
from mayhem.cli.services import probe_capabilities
|
|
29
|
+
from mayhem.toolkit.registry import default_registry
|
|
30
|
+
|
|
31
|
+
report = probe_capabilities(host=host)
|
|
32
|
+
if as_json:
|
|
33
|
+
click.echo(json.dumps(report.model_dump(mode="json"), indent=2))
|
|
34
|
+
return
|
|
35
|
+
seen = {probed.manifest.tool for probed in report.tools}
|
|
36
|
+
for probed in report.tools:
|
|
37
|
+
caps = ",".join(probed.manifest.provides)
|
|
38
|
+
click.echo(f"{probed.manifest.tool:<16} ok {probed.version:<12} [{caps}]")
|
|
39
|
+
for manifest in default_registry().manifests:
|
|
40
|
+
if manifest.tool not in seen:
|
|
41
|
+
click.echo(f"{manifest.tool:<16} MISSING probe failed or version undetectable")
|