mayhem-cli 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,805 @@
1
+ """Lifecycle commands: validate -> plan -> run -> observe -> recover."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ import json
7
+ import os
8
+ import threading
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ import click
14
+
15
+ from mayhem.cli import style
16
+ from mayhem.cli.context import DEFAULT_DB, CliContext
17
+ from mayhem.cli.exit_codes import ExitCode
18
+ from mayhem.cli.services import (
19
+ build_graph,
20
+ engine_for,
21
+ open_store,
22
+ plan_from_spec,
23
+ plan_maniac_from_spec,
24
+ prepare,
25
+ recent_runs,
26
+ run_detail,
27
+ run_journal,
28
+ )
29
+ from mayhem.controller.janitor import Janitor
30
+ from mayhem.controller.planner import restrict_plan_to_container, synthesize_maniac_spec
31
+ from mayhem.domain.common import utc_now
32
+ from mayhem.domain.events import Event, EventKind
33
+ from mayhem.infra.lease_repository import SQLiteLeaseSink
34
+
35
+ if TYPE_CHECKING:
36
+ from mayhem.controller.executor import RunResult
37
+ from mayhem.controller.janitor import SweepResult
38
+ from mayhem.domain.experiments import DrillSpec
39
+ from mayhem.domain.topology import TopologyGraph
40
+ from mayhem.infra.store import Store
41
+
42
+
43
+ def _ctx(ctx: click.Context) -> CliContext:
44
+ obj = ctx.obj
45
+ assert isinstance(obj, CliContext)
46
+ return obj
47
+
48
+
49
+ def _pid_alive(pid: int) -> bool:
50
+ """Best-effort local liveness probe (kill(pid, 0))."""
51
+ if pid <= 0:
52
+ return False
53
+ try:
54
+ os.kill(pid, 0)
55
+ return True
56
+ except ProcessLookupError:
57
+ return False
58
+ except PermissionError:
59
+ return True # exists, owned by someone else
60
+
61
+
62
+ def _run_liveness(store: Store, run_id: str) -> bool | None:
63
+ """False when the controller owning ``run_id`` is provably gone.
64
+
65
+ A terminal run (or a 'running' run whose controller pid is dead) cannot
66
+ ever release its leases — the janitor reclaims them before TTL instead of
67
+ leaving the next ``run`` to hit a ``LeaseConflictError``. Unknown runs
68
+ and live controllers return True/None and stay on TTL policy.
69
+ """
70
+ rows = store.query("SELECT status, controller_pid FROM runs WHERE id = ?", (run_id,))
71
+ if not rows:
72
+ return None
73
+ status, pid = rows[0]
74
+ if status in ("completed", "failed", "aborted"):
75
+ return False
76
+ if pid and not _pid_alive(pid):
77
+ return False
78
+ return None
79
+
80
+
81
+ def _run_liveness_resolver(store: Store) -> Callable[[str], bool | None]:
82
+ return lambda run_id: _run_liveness(store, run_id)
83
+
84
+
85
+ def _sweep_before_run(store: Store) -> None:
86
+ """Best-effort sweep so a crashed run's sticky leases do not wedge
87
+ the very next ``run`` (the users' reported pain: janitor 'did nothing'
88
+ because it had to be invoked manually, and within-TTL leases were never
89
+ reclaimed). Owner-gone leases are reclaimed before TTL; anything still
90
+ live is skipped and acquire() re-attempts the reap."""
91
+ sweep: SweepResult = Janitor(SQLiteLeaseSink(store)).sweep(
92
+ run_liveness=_run_liveness_resolver(store)
93
+ )
94
+ for lease_id in sweep.expired:
95
+ click.echo(style.info(f"cleaned stale lease {lease_id} (expired)"))
96
+ for lease_id in sweep.recovered:
97
+ click.echo(style.info(f"recovered orphaned lease {lease_id}"))
98
+
99
+
100
+ def _gate_enabled() -> bool:
101
+ """Impact gate refusal on unless the user explicitly opted out."""
102
+ from mayhem.cli.app import _STATE
103
+
104
+ return _STATE.get("gate", "1") != "0"
105
+
106
+
107
+ def _gate_bypasses(engine_name: str, plan: object, graph: object) -> dict[tuple[str, str], str]:
108
+ """Probe the live containers and mark proved-inert faults for bypass.
109
+
110
+ Fail-safe by contract: a fault whose tooling is *proven absent* in its
111
+ target container is bypassed at execution time (``bypass due to <reason>``)
112
+ and the rest of the run proceeds — the whole run is never aborted because
113
+ one image lacks a binary. Only probed verdicts become bypasses; an
114
+ unreachable runtime is warned about but still attempted.
115
+ """
116
+ from mayhem.agents.impact import (
117
+ bypass_from_verdicts,
118
+ )
119
+ from mayhem.agents.impact import (
120
+ scan_plan_faults as _scan,
121
+ )
122
+ from mayhem.domain.experiments import ExecutionPlan
123
+ from mayhem.domain.topology import TopologyGraph
124
+
125
+ if not isinstance(plan, ExecutionPlan) or not isinstance(graph, TopologyGraph):
126
+ return {}
127
+ if not engine_name:
128
+ click.echo(
129
+ style.warn("warning:") + " no engine configured — skipping pre-run fault gate",
130
+ err=True,
131
+ )
132
+ return {}
133
+ verdicts, _engine_probed = _scan(plan, graph, engine_name)
134
+ bypass = bypass_from_verdicts(verdicts)
135
+ unreachable = [v for v in verdicts if not v.probed and v.container != "?"]
136
+ if bypass:
137
+ n = sum(len(reasons) for reasons in bypass.values())
138
+ click.echo(
139
+ style.info("info:") + f" impact gate — bypassing {n} inert fault injection(s):",
140
+ err=True,
141
+ )
142
+ for (fid, cont), why in sorted(bypass.items()):
143
+ click.echo(
144
+ style.yellow(f" - {fid} → {cont}: bypass due to {why}"),
145
+ err=True,
146
+ )
147
+ _echo_install_hints(engine_name, plan, graph)
148
+ if unreachable:
149
+ click.echo(
150
+ style.warn("warning:")
151
+ + " runtime unreachable for "
152
+ + ", ".join(f"{v.fault_id}@{v.container}" for v in unreachable)
153
+ + " — impact of those faults cannot be gate-checked before the run",
154
+ err=True,
155
+ )
156
+ return bypass
157
+
158
+
159
+ def _echo_install_hints(engine_name: str, plan: object, graph: object) -> None:
160
+ """Per-container install guidance for the bypassed tooling (best effort).
161
+
162
+ Detects each container's package manager from the live probe (apt-get /
163
+ apk / dnf / yum / microdnf / zypper), prints the concrete ``engine exec``
164
+ command that restores the tooling, and points at ``mayhem dependency
165
+ install`` — which runs the same commands automatically. Probe or detection
166
+ hiccups must never fail the run: the whole helper degrades to a no-op.
167
+ """
168
+ from mayhem.agents.impact import dependency_plan as _dep_plan
169
+ from mayhem.agents.impact import host_tooling_gaps as _host_gaps
170
+ from mayhem.domain.experiments import ExecutionPlan
171
+ from mayhem.domain.topology import TopologyGraph
172
+
173
+ if not isinstance(plan, ExecutionPlan) or not isinstance(graph, TopologyGraph):
174
+ return
175
+ try:
176
+ host_gaps = _host_gaps(plan)
177
+ deps = _dep_plan(plan, graph, engine_name)
178
+ except Exception:
179
+ return
180
+ if host_gaps:
181
+ click.echo(
182
+ style.info("info:") + " host tooling missing for bypassed faults:",
183
+ err=True,
184
+ )
185
+ for name in host_gaps:
186
+ click.echo(
187
+ f" {style.yellow('*')} {name}: runs on the drill host, not in a container — "
188
+ "install it on the host (mayhem cannot install host packages)",
189
+ err=True,
190
+ )
191
+ if not deps:
192
+ return
193
+ click.echo(
194
+ style.info("info:") + " install missing tooling to un-bypass those faults:",
195
+ err=True,
196
+ )
197
+ for dp in deps:
198
+ if dp.installable:
199
+ cmd = " && ".join(" ".join(argv) for argv in dp.install_argv())
200
+ click.echo(
201
+ f" {style.yellow('*')} {dp.container}: "
202
+ f"install {', '.join(dp.packages)} via {dp.pm} — {cmd}",
203
+ err=True,
204
+ )
205
+ if dp.manual:
206
+ click.echo(
207
+ f" {style.yellow('*')} {dp.container}: manual tooling — {', '.join(dp.manual)}",
208
+ err=True,
209
+ )
210
+ if dp.caps_missing:
211
+ click.echo(
212
+ f" {style.yellow('*')} {dp.container}: {', '.join(dp.caps_missing)} are runtime "
213
+ "flags, not packages — restart with --cap-add",
214
+ err=True,
215
+ )
216
+ click.echo(
217
+ f" {style.cyan('mayhem dependency install')} applies the above automatically.",
218
+ err=True,
219
+ )
220
+
221
+
222
+ def _resilience_trailer_lines(result: RunResult) -> list[str]:
223
+ """Resilience score + post-run diagnosis lines for the debug trailer."""
224
+ if result.resilience_report is None:
225
+ return []
226
+ return [line for line in result.resilience_report.summary_md().splitlines() if line]
227
+
228
+
229
+ def _debug_progress() -> Callable[[Event], None]:
230
+ """Timestamped, step-by-step live renderer for ``mayhem --debug run``.
231
+
232
+ Hooks the engine's in-process event observer (ADR-0009 journal): each step
233
+ and fault transition is echoed the instant it happens, instead of the
234
+ run summary appearing only at the end. Locked so parallel steps can't
235
+ interleave mid-line.
236
+ """
237
+
238
+ lock = threading.Lock()
239
+
240
+ def _line(event: Event) -> str | None: # noqa: PLR0911 (one return per event kind)
241
+ kind = event.kind
242
+ ts = style.ts(utc_now().strftime("%H:%M:%S"))
243
+ if kind is EventKind.RUN_STARTED:
244
+ return f"{ts} {style.cyan(f'run {event.run_id} started')}"
245
+ if kind is EventKind.STEP_STARTED:
246
+ return f"{ts} -> {style.cyan(str(event.detail.get('step') or ''))}"
247
+ if kind is EventKind.FAULT_INJECTED:
248
+ return (
249
+ f"{ts} {style.cyan('injected')} {event.detail.get('fault')}"
250
+ f" (lease {event.detail.get('lease')})"
251
+ )
252
+ if kind is EventKind.STEP_FINISHED:
253
+ step = event.detail.get("step")
254
+ return f"{ts} {style.ok('[ok]')} {step}: {event.detail.get('detail')}"
255
+ if kind is EventKind.STEP_SKIPPED:
256
+ step = event.detail.get("step")
257
+ detail = str(event.detail.get("detail") or "").strip()
258
+ if detail.startswith("bypass due to"):
259
+ return f"{ts} {style.yellow('[bypass]', bold=True)} {step}: " + style.yellow(
260
+ detail
261
+ )
262
+ return f"{ts} {style.danger('[FAIL]', err=False)} {step}: {detail}"
263
+ return None
264
+
265
+ def on_event(event: Event) -> None:
266
+ line = _line(event)
267
+ if line is None:
268
+ return
269
+ with lock:
270
+ click.echo(line)
271
+
272
+ return on_event
273
+
274
+
275
+ def _compose_option[F: Callable[..., object]](fn: F) -> F:
276
+ """Only topology input for drill commands is the compose blueprint.
277
+
278
+ ``--process``/``--service``/``--host`` were removed in Phase 6 — drill
279
+ specs are compose-native and identify targets by ``container_name``.
280
+ Omit ``--compose`` to auto-detect a compose file in the cwd.
281
+ """
282
+ return click.option(
283
+ "-c",
284
+ "--compose",
285
+ type=str,
286
+ default=None,
287
+ help="docker-compose.yaml blueprint (auto-detected in cwd if omitted).",
288
+ )(fn)
289
+
290
+
291
+ _SPEC_CANDIDATES = ("mayhem.yaml", "mayhem.yml")
292
+
293
+
294
+ def _is_drill_spec_file(path: Path) -> bool:
295
+ """Cheap kind check: does ``path`` name a drill spec (``kind: drill``)?"""
296
+ import yaml
297
+
298
+ try:
299
+ data = yaml.safe_load(path.read_text()) or {}
300
+ except (OSError, yaml.YAMLError):
301
+ return False
302
+ return isinstance(data, dict) and data.get("kind") == "drill"
303
+
304
+
305
+ def _resolve_spec(explicit: str | None, config_path: str | None = None) -> str:
306
+ """Resolve the drill spec file from user input.
307
+
308
+ Accepts three forms, in order of precedence:
309
+
310
+ 1. An explicit positional path — use it directly (error if missing).
311
+ 2. ``--config`` — when it names a drill spec itself (``kind: drill``),
312
+ the config flag doubles as the spec path, so
313
+ ``mayhem --config drill.yaml maniac`` works from any directory.
314
+ 3. Auto-detect ``mayhem.yaml`` / ``mayhem.yml`` in the cwd.
315
+
316
+ ``mayhem run`` with no path therefore imports ``mayhem.yaml`` from the
317
+ directory the user invokes it from, unless an explicit spec (or a drill
318
+ spec passed via ``--config``) is given.
319
+ """
320
+ if explicit:
321
+ target = Path(explicit)
322
+ if not target.is_file():
323
+ # A caller-provided path that does not exist is a validation
324
+ # failure (schema/input error), not a usage mistake — map it to
325
+ # EXIT code VALIDATION_ERROR via FileNotFoundError.
326
+ raise FileNotFoundError(f"spec file not found: {target}")
327
+ return str(target)
328
+ if config_path:
329
+ target = Path(config_path)
330
+ if target.is_file() and _is_drill_spec_file(target):
331
+ return str(target)
332
+ for name in _SPEC_CANDIDATES:
333
+ candidate = Path.cwd() / name
334
+ if candidate.is_file():
335
+ return str(candidate)
336
+ raise click.UsageError(f"no spec file in cwd; expected one of: {', '.join(_SPEC_CANDIDATES)}")
337
+
338
+
339
+ def _resolve_spec_pair(explicit: str | None, config_path: str | None) -> tuple[str, str | None]:
340
+ """Resolve ``(spec_path, config_path)`` for the layered config layering.
341
+
342
+ When ``--config`` doubled as the drill spec file (case 2 of
343
+ :func:`_resolve_spec`), the returned config path is ``None`` so the config
344
+ layers fall back to defaults (plus the ``skip_default_file_if_spec``
345
+ guard) instead of re-parsing the spec as a strictly-forbidden config
346
+ document.
347
+ """
348
+ spec = _resolve_spec(explicit, config_path=config_path)
349
+ if (
350
+ explicit is None
351
+ and config_path is not None
352
+ and Path(config_path).resolve() == Path(spec).resolve()
353
+ ):
354
+ return spec, None
355
+ return spec, config_path
356
+
357
+
358
+ def _resolve_maniac_sources(
359
+ explicit: str | None,
360
+ config_path: str | None,
361
+ graph: TopologyGraph,
362
+ *,
363
+ pool: str | None = None,
364
+ ) -> tuple[str | None, str | None, DrillSpec | None]:
365
+ """Resolve ``(spec_path, layered_config_path, synthesized_spec)`` for maniac.
366
+
367
+ ``mayhem maniac`` is the one drill command that runs *without* an authored
368
+ config: with only a compose blueprint given, the drill spec is derived
369
+ from the topology (:func:`mayhem.controller.planner.synthesize_maniac_spec`)
370
+ so the draw pool always matches the running stack. Input precedence:
371
+
372
+ 1. An explicit positional path — always the spec (error if missing).
373
+ 2. ``--config`` (or a cwd ``mayhem.yaml`` / ``mayhem.yml``) that is a
374
+ drill spec — the spec, exactly like every other drill command.
375
+ 3. ``--config`` (or a cwd config doc) that is a plain config document —
376
+ the layered config, with the spec synthesized from the topology; its
377
+ ``maniac:`` block tunes the random draw.
378
+ 4. Nothing — pure defaults (no config document anywhere).
379
+
380
+ ``synthesized_spec`` is non-``None`` only when the spec was built from the
381
+ graph; the layered config path is then still honored (case 3), so a
382
+ user-supplied ``mayhem.yaml`` keeps working as the tuning dial. ``pool``
383
+ (``--ctr``) additionally restricts the *synthesized* draw pool to a single
384
+ container subtree, so every drawn round lands on the requested container.
385
+ """
386
+ pool_graph = graph.restrict_to(pool) if pool is not None else graph
387
+ if explicit:
388
+ return _resolve_spec(explicit, config_path=config_path), config_path, None
389
+ if config_path:
390
+ target = Path(config_path)
391
+ if target.is_file() and _is_drill_spec_file(target):
392
+ return str(target), None, None
393
+ return None, str(target), synthesize_maniac_spec(pool_graph)
394
+ for name in _SPEC_CANDIDATES:
395
+ candidate = Path.cwd() / name
396
+ if candidate.is_file():
397
+ if _is_drill_spec_file(candidate):
398
+ return str(candidate), config_path, None
399
+ return None, str(candidate), synthesize_maniac_spec(pool_graph)
400
+ return None, config_path, synthesize_maniac_spec(pool_graph)
401
+
402
+
403
+ def _resolve_engine_from_state() -> str:
404
+ """Resolve the CLI engine flag (``--podman``) to a concrete engine name."""
405
+ from mayhem.cli.app import _STATE
406
+ from mayhem.cli.topology import _resolve_engine
407
+
408
+ return _resolve_engine(str(_STATE.get("engine", ""))) or "podman"
409
+
410
+
411
+ def _graph_from(ctx: click.Context, compose: str | None) -> tuple[TopologyGraph, str | None]:
412
+ from mayhem.cli.topology import _resolve_compose
413
+
414
+ resolved = _resolve_compose(compose)
415
+ try:
416
+ return build_graph(resolved), resolved
417
+ except ValueError as exc:
418
+ raise click.UsageError(str(exc), ctx=ctx) from None
419
+
420
+
421
+ def _require_container(ctr: str, graph: TopologyGraph, ctx: click.Context) -> None:
422
+ """Loud guard for ``--ctr``: the container must exist in the topology."""
423
+ if not graph.node_ids_for_container(ctr):
424
+ available = ", ".join(graph.container_names()) or "<none>"
425
+ raise click.UsageError(
426
+ f"no container named {ctr!r} in the compose topology "
427
+ f"(available: {available}) — tip: use a container_name: value "
428
+ "from the blueprint or the runtime container name",
429
+ ctx=ctx,
430
+ )
431
+
432
+
433
+ @click.command("validate")
434
+ @_compose_option
435
+ @click.argument("experiment", type=click.Path(), required=False, default=None)
436
+ @click.pass_context
437
+ def validate(ctx: click.Context, experiment: str | None, compose: str | None) -> None:
438
+ """Compile a drill spec and run every safety gate without executing it."""
439
+ graph, resolved_compose = _graph_from(ctx, compose)
440
+ obj = _ctx(ctx)
441
+ experiment, config_for_layers = _resolve_spec_pair(experiment, obj.config)
442
+ store = open_store(obj.db)
443
+ try:
444
+ prepared = prepare(
445
+ config_path=config_for_layers,
446
+ profile=obj.profile,
447
+ allow_critical=obj.allow_critical,
448
+ store=store,
449
+ graph=graph,
450
+ compose=resolved_compose,
451
+ spec_path=experiment,
452
+ )
453
+ compiled = plan_from_spec(
454
+ experiment, graph, prepared=prepared, engine=_resolve_engine_from_state()
455
+ )
456
+ finally:
457
+ store.close()
458
+ click.echo(
459
+ f"{style.ok('validated')} {style.cyan(compiled.run_id)}: "
460
+ f"{len(compiled.plan.steps)} step(s), "
461
+ f"fingerprint {prepared.fingerprint[:12]}"
462
+ )
463
+
464
+
465
+ @click.command("plan")
466
+ @_compose_option
467
+ @click.argument("experiment", type=click.Path(), required=False, default=None)
468
+ @click.pass_context
469
+ def plan(ctx: click.Context, experiment: str | None, compose: str | None) -> None:
470
+ """Compile a drill spec against a topology and print the frozen plan JSON."""
471
+ graph, resolved_compose = _graph_from(ctx, compose)
472
+ obj = _ctx(ctx)
473
+ experiment, config_for_layers = _resolve_spec_pair(experiment, obj.config)
474
+ store = open_store(obj.db)
475
+ try:
476
+ prepared = prepare(
477
+ config_path=config_for_layers,
478
+ profile=obj.profile,
479
+ allow_critical=obj.allow_critical,
480
+ store=store,
481
+ graph=graph,
482
+ compose=resolved_compose,
483
+ spec_path=experiment,
484
+ )
485
+ compiled = plan_from_spec(
486
+ experiment, graph, prepared=prepared, engine=_resolve_engine_from_state()
487
+ )
488
+ finally:
489
+ store.close()
490
+ click.echo(compiled.plan.model_dump_json(indent=2))
491
+
492
+
493
+ @click.command("run")
494
+ @_compose_option
495
+ @click.option(
496
+ "--ctr",
497
+ "ctr",
498
+ type=str,
499
+ default=None,
500
+ metavar="CONTAINER",
501
+ help="Only execute faults on this container (container_name from the compose "
502
+ "blueprint, or the runtime container name).",
503
+ )
504
+ @click.argument("experiment", type=click.Path(), required=False, default=None)
505
+ @click.pass_context
506
+ def run(ctx: click.Context, experiment: str | None, compose: str | None, ctr: str | None) -> None:
507
+ """Compile then execute a drill spec; prints the run summary."""
508
+ graph, resolved_compose = _graph_from(ctx, compose)
509
+ obj = _ctx(ctx)
510
+ if ctr is not None:
511
+ _require_container(ctr, graph, ctx)
512
+ experiment, config_for_layers = _resolve_spec_pair(experiment, obj.config)
513
+ store = open_store(obj.db)
514
+ try:
515
+ _sweep_before_run(store)
516
+ prepared = prepare(
517
+ config_path=config_for_layers,
518
+ profile=obj.profile,
519
+ allow_critical=obj.allow_critical,
520
+ store=store,
521
+ graph=graph,
522
+ compose=resolved_compose,
523
+ spec_path=experiment,
524
+ )
525
+ compiled = plan_from_spec(
526
+ experiment, graph, prepared=prepared, engine=_resolve_engine_from_state()
527
+ )
528
+ if ctr is not None:
529
+ compiled = dataclasses.replace(
530
+ compiled, plan=restrict_plan_to_container(compiled.plan, ctr, graph)
531
+ )
532
+ click.echo(
533
+ style.info("info:") + f" --ctr scoped the plan to container {style.cyan(ctr)}",
534
+ err=True,
535
+ )
536
+ engine_name = _resolve_engine_from_state()
537
+ bypass: dict[tuple[str, str], str] = {}
538
+ if _gate_enabled():
539
+ bypass = _gate_bypasses(engine_name, compiled.plan, graph)
540
+ else:
541
+ click.echo(
542
+ style.warn("warning:") + " impact gate skipped (--skip-gate); inert faults may run",
543
+ err=True,
544
+ )
545
+ engine = engine_for(
546
+ store,
547
+ engine_name,
548
+ live_graph=lambda: build_graph(resolved_compose),
549
+ on_event=_debug_progress() if obj.debug else None,
550
+ bypass=bypass,
551
+ )
552
+ result = engine.execute(compiled.plan)
553
+ if obj.debug:
554
+ # per-step lines were streamed live; print the consolidated trailer
555
+ trailer = [
556
+ f"**status**: {style.state(result.status)}",
557
+ f"**wall**: {style.ts(f'{result.wall_seconds:.1f}s')}",
558
+ ]
559
+ trailer.extend(
560
+ style.danger(f"- **DIRTY LEASE** {lease_id}: manual remediation required")
561
+ for lease_id in result.dirty_leases
562
+ )
563
+ trailer.extend(_resilience_trailer_lines(result))
564
+ click.echo("\n".join(trailer))
565
+ else:
566
+ click.echo(result.summary_md())
567
+ # Copy-paste handle for follow-up commands: `mayhem history <run_id>`.
568
+ click.echo(
569
+ f"\n{style.ok('run')} {style.cyan(compiled.run_id)} — "
570
+ f"inspect with {style.yellow(f'mayhem history {compiled.run_id}')}"
571
+ )
572
+ if result.status != "completed":
573
+ ctx.exit(int(ExitCode.EXPERIMENT_FAILURE))
574
+ finally:
575
+ store.close()
576
+
577
+
578
+ @click.command("maniac")
579
+ @_compose_option
580
+ @click.option(
581
+ "-s",
582
+ "--steps",
583
+ "steps",
584
+ type=click.IntRange(1, 500),
585
+ default=None,
586
+ help="Draw exactly N random fault rounds (overrides config.maniac.run_level).",
587
+ )
588
+ @click.option(
589
+ "--ctr",
590
+ "ctr",
591
+ type=str,
592
+ default=None,
593
+ metavar="CONTAINER",
594
+ help="Only draw random fault rounds against this container (container_name "
595
+ "from the compose blueprint, or the runtime container name).",
596
+ )
597
+ @click.argument("experiment", type=click.Path(), required=False, default=None)
598
+ @click.pass_context
599
+ def maniac(
600
+ ctx: click.Context,
601
+ experiment: str | None,
602
+ compose: str | None,
603
+ steps: int | None,
604
+ ctr: str | None,
605
+ ) -> None:
606
+ """Run a drill spec as random fault injection (ADR-M5-1).
607
+
608
+ Compiles the spec exactly like ``mayhem run`` but replaces the authored
609
+ execution with ``config.maniac.run_level`` random (container, fault)
610
+ rounds dialed by ``config.maniac.level`` (1-5). ``-s/--steps`` overrides
611
+ the round count on the command line. Safety gates, per-round
612
+ compensation, success criteria and observability are unchanged; ``seed``
613
+ makes the draw reproducible.
614
+
615
+ With no spec given (positional, ``--config`` drill spec, or a cwd
616
+ ``mayhem.yaml`` drill spec), the spec is synthesized from the compose
617
+ topology — every container pooled with the full container-addressable
618
+ fault catalog — so ``mayhem maniac -c docker-compose.yml`` works as a
619
+ zero-config chaos run. A ``--config``/cwd ``mayhem.yaml`` that is a plain
620
+ config document still tunes the draw via its ``maniac:`` block.
621
+ ``--ctr`` narrows the draw pool to a single container (and, for authored
622
+ specs, drops every other container's rounds), so the run can only ever
623
+ perturbs the requested container.
624
+ """
625
+ graph, resolved_compose = _graph_from(ctx, compose)
626
+ obj = _ctx(ctx)
627
+ if ctr is not None:
628
+ _require_container(ctr, graph, ctx)
629
+ spec_path, config_for_layers, synthesized = _resolve_maniac_sources(
630
+ experiment, obj.config, graph, pool=ctr
631
+ )
632
+ if spec_path is None and synthesized is None:
633
+ raise click.UsageError("no drill spec, and nothing to synthesize")
634
+ if spec_path is None:
635
+ assert synthesized is not None # resolver invariant, see above
636
+ spec_path = f"<{synthesized.name}>"
637
+ store = open_store(obj.db)
638
+ try:
639
+ prepared = prepare(
640
+ config_path=config_for_layers,
641
+ profile=obj.profile,
642
+ allow_critical=obj.allow_critical,
643
+ store=store,
644
+ graph=graph,
645
+ compose=resolved_compose,
646
+ spec_path=spec_path,
647
+ )
648
+ compiled = plan_maniac_from_spec(
649
+ spec_path,
650
+ graph,
651
+ prepared=prepared,
652
+ engine=_resolve_engine_from_state(),
653
+ config_path=config_for_layers,
654
+ profile=obj.profile,
655
+ steps=steps,
656
+ spec=synthesized,
657
+ )
658
+ if ctr is not None:
659
+ compiled = dataclasses.replace(
660
+ compiled, plan=restrict_plan_to_container(compiled.plan, ctr, graph)
661
+ )
662
+ click.echo(
663
+ style.info("info:") + f" --ctr scoped the draw to container {style.cyan(ctr)}",
664
+ err=True,
665
+ )
666
+ draws = sum(1 for step in compiled.plan.steps if step.fault is not None)
667
+ if synthesized is not None:
668
+ click.echo(
669
+ style.info("info:") + " maniac mode — no drill spec; synthesized config "
670
+ f"from compose topology "
671
+ f"({len(synthesized.containers)} container(s)), "
672
+ f"{draws} random fault round(s) drawn",
673
+ err=True,
674
+ )
675
+ else:
676
+ click.echo(
677
+ style.info("info:") + f" maniac mode — {draws} random fault round(s) drawn",
678
+ err=True,
679
+ )
680
+ engine_name = _resolve_engine_from_state()
681
+ bypass: dict[tuple[str, str], str] = {}
682
+ if _gate_enabled():
683
+ bypass = _gate_bypasses(engine_name, compiled.plan, graph)
684
+ else:
685
+ click.echo(
686
+ style.warn("warning:") + " impact gate skipped (--skip-gate); inert faults may run",
687
+ err=True,
688
+ )
689
+ engine = engine_for(
690
+ store,
691
+ engine_name,
692
+ live_graph=lambda: build_graph(resolved_compose),
693
+ on_event=_debug_progress() if obj.debug else None,
694
+ bypass=bypass,
695
+ )
696
+ result = engine.execute(compiled.plan)
697
+ if obj.debug:
698
+ trailer = [
699
+ f"**status**: {style.state(result.status)}",
700
+ f"**wall**: {style.ts(f'{result.wall_seconds:.1f}s')}",
701
+ ]
702
+ trailer.extend(
703
+ style.danger(f"- **DIRTY LEASE** {lease_id}: manual remediation required")
704
+ for lease_id in result.dirty_leases
705
+ )
706
+ trailer.extend(_resilience_trailer_lines(result))
707
+ click.echo("\n".join(trailer))
708
+ else:
709
+ click.echo(result.summary_md())
710
+ click.echo(
711
+ f"\n{style.ok('run')} {style.cyan(compiled.run_id)} — "
712
+ f"inspect with {style.yellow(f'mayhem history {compiled.run_id}')}"
713
+ )
714
+ if result.status != "completed":
715
+ ctx.exit(int(ExitCode.EXPERIMENT_FAILURE))
716
+ finally:
717
+ store.close()
718
+
719
+
720
+ @click.command("status")
721
+ @click.option("--db", "db_opt", default=None, help="SQLite database path.")
722
+ @click.option("--run", "run_id", default=None, help="Show one run in detail.")
723
+ @click.option("--limit", type=int, default=20, show_default=True, help="Rows to list.")
724
+ @click.option("--json", "json_flag", is_flag=True, default=False, help="Output as JSON.")
725
+ @click.pass_context
726
+ def status(
727
+ ctx: click.Context, db_opt: str | None, run_id: str | None, limit: int, json_flag: bool
728
+ ) -> None:
729
+ """Show runs recorded in the database."""
730
+ db = db_opt or _ctx(ctx).db or DEFAULT_DB
731
+ store = open_store(db)
732
+ try:
733
+ if run_id is not None:
734
+ row = run_detail(store, run_id)
735
+ if row is None:
736
+ raise click.UsageError(f"no such run: {run_id}", ctx=ctx)
737
+ click.echo(json.dumps(row, indent=2))
738
+ return
739
+ rows = recent_runs(store, limit)
740
+ if json_flag:
741
+ click.echo(json.dumps(rows, indent=2))
742
+ else:
743
+ for row in rows:
744
+ started = row["started_at"] or "-"
745
+ sid = style.state(f"{row['status']:<10}")
746
+ click.echo(f"{row['id']:<28} {row['kind']:<13} {sid} {started}")
747
+ finally:
748
+ store.close()
749
+
750
+
751
+ @click.command("history")
752
+ @click.argument("run_id")
753
+ @click.option("--json", "json_flag", is_flag=True, default=False, help="Output as JSON.")
754
+ @click.pass_context
755
+ def history(ctx: click.Context, run_id: str, json_flag: bool) -> None:
756
+ """Print steps, events, and leases recorded for one run."""
757
+ store = open_store(_ctx(ctx).db)
758
+ try:
759
+ journal = run_journal(store, run_id)
760
+ finally:
761
+ store.close()
762
+ click.echo(json.dumps(journal, indent=2))
763
+
764
+
765
+ @click.command("recover")
766
+ @click.argument("run_id")
767
+ @click.pass_context
768
+ def recover(ctx: click.Context, run_id: str) -> None:
769
+ """Recover every orphaned fault lease belonging to a run."""
770
+ store = open_store(_ctx(ctx).db)
771
+ try:
772
+ recovered = engine_for(store, _resolve_engine_from_state()).recover_run(run_id)
773
+ if not recovered:
774
+ click.echo(f"nothing to recover for {style.cyan(run_id)}")
775
+ return
776
+ for lease_id in recovered:
777
+ click.echo(f"recovered lease {style.cyan(lease_id)}")
778
+ finally:
779
+ store.close()
780
+
781
+
782
+ @click.command("janitor")
783
+ @click.pass_context
784
+ def janitor(ctx: click.Context) -> None:
785
+ """Reclaim leases past TTL — or owned by a controller that is gone."""
786
+ store = open_store(_ctx(ctx).db)
787
+ try:
788
+ sweep: SweepResult = Janitor(SQLiteLeaseSink(store)).sweep(
789
+ run_liveness=_run_liveness_resolver(store)
790
+ )
791
+ finally:
792
+ store.close()
793
+ if sweep.quiet:
794
+ click.echo(style.cyan("janitor: nothing to do"))
795
+ return
796
+ for lease_id in sweep.expired:
797
+ click.echo(f"expired pending lease {style.cyan(lease_id)}")
798
+ for lease_id in sweep.recovered:
799
+ click.echo(f"recovered orphaned lease {style.cyan(lease_id)}")
800
+ for lease_id in sweep.dirty:
801
+ click.echo(
802
+ style.danger(f"DIRTY lease {lease_id}: compensation failed; manual action required")
803
+ )
804
+ if sweep.dirty:
805
+ ctx.exit(int(ExitCode.RECOVERY_FAILURE))