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
mayhem/cli/context.py ADDED
@@ -0,0 +1,23 @@
1
+ """Shared CLI context object.
2
+
3
+ Global options live once on the root group and travel through
4
+ ``ctx.obj`` as an immutable :class:`CliContext`. Subcommands never re-declare
5
+ them and never reach into module-level globals — this object is the only
6
+ channel for user intent, which keeps the handlers testable and makes a future
7
+ REST/UI layer trivial to add (construct a CliContext programmatically).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+
14
+ DEFAULT_DB = ".mayhem/mayhem.db"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class CliContext:
19
+ db: str = DEFAULT_DB
20
+ config: str | None = None
21
+ profile: str | None = None
22
+ allow_critical: bool = False
23
+ debug: bool = False
@@ -0,0 +1,429 @@
1
+ """``mayhem dependency`` — inspect and install fault-tooling dependencies.
2
+
3
+ The impact gate ([bypass] ``... missing bin:python``) refuses faults whose
4
+ in-image tooling is proven absent. This command group closes that gap:
5
+
6
+ * ``check`` — probe each container, report the missing packages and the
7
+ exact ``engine exec`` install command per package manager.
8
+ * ``install`` — detect the container's package manager (apt-get / apk / dnf /
9
+ yum / microdnf / zypper), install the mapped packages, re-probe and report.
10
+
11
+ Capabilities (``cap:NET_ADMIN``) and uid(0) requirements are *not* packages —
12
+ they are runtime flags (``--cap-add``, root exec) and are reported as guidance,
13
+ never installed. Host-side tooling (``net.load`` → k6 on the drill host) is
14
+ reported by ``check`` but never installed: ``mayhem dependency`` manages
15
+ container compatibility only.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+
23
+ import click
24
+
25
+ from mayhem.cli import style
26
+ from mayhem.cli.context import CliContext
27
+ from mayhem.cli.exit_codes import ExitCode
28
+ from mayhem.cli.lifecycle import (
29
+ _compose_option,
30
+ _graph_from,
31
+ _resolve_engine_from_state,
32
+ _resolve_spec_pair,
33
+ )
34
+ from mayhem.cli.services import open_store, plan_from_spec, prepare
35
+ from mayhem.toolkit.tool_runner import run_tool
36
+
37
+ if TYPE_CHECKING:
38
+ from collections.abc import Mapping, Sequence
39
+
40
+ from mayhem.agents.impact import ContainerCompilePlan, ContainerDependencyPlan
41
+ from mayhem.domain.experiments import ExecutionPlan
42
+ from mayhem.domain.topology import TopologyGraph
43
+
44
+
45
+ def _ctx(ctx: click.Context) -> CliContext:
46
+ obj = ctx.obj
47
+ assert isinstance(obj, CliContext)
48
+ return obj
49
+
50
+
51
+ def _dependency_context(
52
+ ctx: click.Context, compose: str | None, experiment: str | None
53
+ ) -> tuple[ExecutionPlan, TopologyGraph, str]:
54
+ """Resolve graph + compiled plan the same way ``mayhem run`` does."""
55
+ graph, resolved_compose = _graph_from(ctx, compose)
56
+ obj = _ctx(ctx)
57
+ spec_path, config_for_layers = _resolve_spec_pair(experiment, obj.config)
58
+ store = open_store(obj.db)
59
+ try:
60
+ prepared = prepare(
61
+ config_path=config_for_layers,
62
+ profile=obj.profile,
63
+ allow_critical=obj.allow_critical,
64
+ store=store,
65
+ graph=graph,
66
+ compose=resolved_compose,
67
+ spec_path=spec_path,
68
+ )
69
+ compiled = plan_from_spec(
70
+ spec_path, graph, prepared=prepared, engine=_resolve_engine_from_state()
71
+ )
72
+ finally:
73
+ store.close()
74
+ engine_name = _resolve_engine_from_state()
75
+ return compiled.plan, graph, engine_name
76
+
77
+
78
+ def _render_dependency(dp: ContainerDependencyPlan, *, detailed: bool) -> None:
79
+ """One container's dependency outcome (used by check and install)."""
80
+ from mayhem.agents.impact import _MANUAL_BINS
81
+
82
+ status = style.state("ok") if dp.gaps_remain else style.yellow("missing")
83
+ click.echo(f"{status} {dp.container} (pm: {dp.pm or 'none'})")
84
+ if dp.packages:
85
+ click.echo(f" install: {', '.join(dp.packages)}")
86
+ for bin_name in dp.manual:
87
+ hint = _MANUAL_BINS.get(bin_name)
88
+ click.echo(f" manual: {bin_name}" + (f" — {hint}" if hint else ""))
89
+ for item in dp.caps_missing:
90
+ click.echo(f" runtime flag: {item} (not a package — add --cap-add)")
91
+ if dp.need_root:
92
+ click.echo(" runtime: needs uid(0); installs run as --user 0")
93
+ if dp.pm is None and dp.manual:
94
+ click.echo(
95
+ " note: no package manager detected — install tooling into this"
96
+ " image at build time (distroless/scratch images have no PM)"
97
+ )
98
+ if dp.installable and detailed:
99
+ for argv in dp.install_argv():
100
+ click.echo(f" cmd: {' '.join(argv)}")
101
+
102
+
103
+ @click.group(
104
+ "dependency",
105
+ help="Inspect and install in-image tooling that gates fault families.",
106
+ no_args_is_help=True,
107
+ )
108
+ def dependency() -> None:
109
+ """Manage container tooling required by planned fault families."""
110
+
111
+
112
+ @dependency.command("check")
113
+ @_compose_option
114
+ @click.argument("experiment", type=click.Path(), required=False, default=None)
115
+ @click.pass_context
116
+ def check(ctx: click.Context, experiment: str | None, compose: str | None) -> None:
117
+ """Probe containers and report packages that would un-bypass faults."""
118
+ from mayhem.agents.impact import dependency_plan as _dep_plan
119
+ from mayhem.agents.impact import host_tooling_gaps as _host_gaps
120
+
121
+ plan, graph, engine_name = _dependency_context(ctx, compose, experiment)
122
+ deps = _dep_plan(plan, graph, engine_name)
123
+ host_gaps = _host_gaps(plan)
124
+ if not deps and not host_gaps:
125
+ click.echo(style.ok("no missing tooling") + " — every planned fault can inject")
126
+ return
127
+ for dp in deps:
128
+ _render_dependency(dp, detailed=True)
129
+ for name in host_gaps:
130
+ click.echo(
131
+ f" host: {style.yellow('missing')} {name}"
132
+ " — runs on the drill host, not in a container; install it on the"
133
+ " host (mayhem dependency manages containers only)"
134
+ )
135
+
136
+
137
+ @dependency.command("install")
138
+ @_compose_option
139
+ @click.argument("experiment", type=click.Path(), required=False, default=None)
140
+ @click.option("-y", "--yes", is_flag=True, help="Install without confirmation.")
141
+ @click.option("--dry-run", is_flag=True, help="Print commands without executing.")
142
+ @click.pass_context
143
+ def install(
144
+ ctx: click.Context,
145
+ experiment: str | None,
146
+ compose: str | None,
147
+ yes: bool,
148
+ dry_run: bool,
149
+ ) -> None:
150
+ """Detect each container's package manager and install the mapped packages."""
151
+ from mayhem.agents.impact import dependency_plan as _dep_plan
152
+ from mayhem.agents.impact import host_tooling_gaps as _host_gaps
153
+
154
+ plan, graph, engine_name = _dependency_context(ctx, compose, experiment)
155
+ deps = _dep_plan(plan, graph, engine_name)
156
+ installable = [dp for dp in deps if dp.installable]
157
+ if not installable:
158
+ click.echo(style.ok("nothing to install") + " — no auto-installable tooling missing")
159
+ for name in _host_gaps(plan):
160
+ click.echo(
161
+ f" host: {style.yellow('manual')} {name}"
162
+ " — host-side tooling (not a container package); install on the drill host"
163
+ )
164
+ if not yes and not dry_run and installable:
165
+ click.confirm(
166
+ f"install {sum(len(d.packages) for d in installable)} package(s) across "
167
+ f"{len(installable)} container(s)?",
168
+ abort=True,
169
+ )
170
+ failures = 0
171
+ for dp in installable:
172
+ if dry_run:
173
+ _render_dependency(dp, detailed=True)
174
+ continue
175
+ _install_one(dp)
176
+ if not _verify_container(dp):
177
+ failures += 1
178
+ for dp in deps:
179
+ if not dp.installable:
180
+ _render_dependency(dp, detailed=False)
181
+ if failures:
182
+ ctx.exit(int(ExitCode.EXPERIMENT_FAILURE))
183
+
184
+
185
+ def _install_one(dp: ContainerDependencyPlan) -> None:
186
+ for argv in dp.install_argv():
187
+ result = run_tool(argv)
188
+ status = style.ok("[ok]") if result.succeeded else style.danger("[FAIL]")
189
+ click.echo(f"{status} {' '.join(argv)}")
190
+ if not result.succeeded:
191
+ click.echo(f" {result.stderr.strip()[:300]}")
192
+
193
+
194
+ def _verify_container(dp: ContainerDependencyPlan) -> bool:
195
+ """Re-probe the container and report which planned bins are now present."""
196
+ from mayhem.agents.impact import probe_container_runtime
197
+
198
+ run = probe_container_runtime(dp.engine, dp.container)
199
+ if run is None:
200
+ click.echo(style.warn(" unreachable after install — cannot verify"))
201
+ return False
202
+ missing = [b for b in dp.bins if not run.has_bin(b)]
203
+ if missing:
204
+ bin_label = ", ".join(missing)
205
+ click.echo(style.warn(f" still missing: {bin_label}"))
206
+ return False
207
+ click.echo(style.ok(" verified") + f" {dp.container} now has {', '.join(dp.bins)}")
208
+ return True
209
+
210
+
211
+ # ── compile: bake the tooling into a docker-compose.mayhem.yml ──────────
212
+
213
+
214
+ def _bin_missing_expr(bin_name: str) -> str:
215
+ """``sh`` test that is true when ``bin_name`` is absent from the container.
216
+
217
+ ``python`` is satisfied by either interpreter (the probe behaves the same),
218
+ so the generated guard checks python OR python3.
219
+ """
220
+ if bin_name == "python":
221
+ return "command -v python >/dev/null 2>&1 || command -v python3 >/dev/null 2>&1"
222
+ return f"command -v {bin_name} >/dev/null 2>&1"
223
+
224
+
225
+ def _install_arms(bins: Sequence[str]) -> list[str]:
226
+ """One ``sh`` conditional arm per package manager present in the image.
227
+
228
+ Only managers that own the mapped packages get an arm — an arm is the
229
+ ``command -v <pm> && { <pm> install ...; return; }`` line that runs when
230
+ the container's real package manager is available. Package lists are the
231
+ per-PM mappings from ``impact._PM_PACKAGES``.
232
+ """
233
+ from mayhem.agents.impact import _PACKAGE_MANAGERS, _PM_PACKAGES
234
+
235
+ per_pm: dict[str, list[str]] = {}
236
+ for bin_name in bins:
237
+ mapping = _PM_PACKAGES.get(bin_name, {})
238
+ for pm in _PACKAGE_MANAGERS:
239
+ pkg = mapping.get(pm)
240
+ if pkg is not None:
241
+ per_pm.setdefault(pm, []).append(pkg)
242
+ arms: list[str] = []
243
+ for pm in _PACKAGE_MANAGERS:
244
+ packages = sorted(set(per_pm.get(pm, ())))
245
+ if not packages:
246
+ continue
247
+ joined = " ".join(packages)
248
+ if pm == "apt-get":
249
+ cmd = f"apt-get update && apt-get install -y {joined}"
250
+ elif pm == "apk":
251
+ cmd = f"apk add --no-cache {joined}"
252
+ elif pm == "zypper":
253
+ cmd = f"zypper --non-interactive install {joined}"
254
+ else:
255
+ cmd = f"{pm} install -y {joined}"
256
+ arms.append(f" command -v {pm} >/dev/null 2>&1 && {{ {cmd}; return; }}")
257
+ return arms
258
+
259
+
260
+ def _bootstrap_script(bins: Sequence[str]) -> str:
261
+ """``sh`` bootstrap for a compose ``entrypoint``.
262
+
263
+ Idempotent: installs the mapped packages for whichever package manager the
264
+ image actually runs (only when a required bin is absent), then execs the
265
+ service's own command — compose appends the service ``command`` (or the
266
+ image CMD) as ``$0 $@``, so ``exec "$0" "$@"`` preserves it exactly, with
267
+ or without an explicit ``command:`` in the compose file.
268
+ """
269
+ if not bins:
270
+ return ""
271
+ missing = " || ".join(f"! ( {_bin_missing_expr(b)})" for b in bins)
272
+ arms = _install_arms(bins)
273
+ if not arms:
274
+ return ""
275
+ lines = [
276
+ "mayhem_install() {",
277
+ *arms,
278
+ "}",
279
+ f"if {missing}; then mayhem_install; fi",
280
+ 'if [ -n "$0" ]; then exec "$0" "$@"; fi',
281
+ 'echo "mayhem: no command to run after dependency bootstrap" >&2',
282
+ "exit 1",
283
+ ]
284
+ return "\n".join(lines)
285
+
286
+
287
+ def _service_key_for(services: Mapping[str, object], container: str) -> str | None:
288
+ """Compose service key that produces ``container`` (by name or key)."""
289
+ for key, raw in services.items():
290
+ if not isinstance(raw, dict):
291
+ continue
292
+ if raw.get("container_name") == container or key == container:
293
+ return key
294
+ return None
295
+
296
+
297
+ @dependency.command("compile")
298
+ @_compose_option
299
+ @click.argument("experiment", type=click.Path(), required=False, default=None)
300
+ @click.option(
301
+ "-o",
302
+ "--output",
303
+ "output_path",
304
+ default="docker-compose.mayhem.yml",
305
+ show_default=True,
306
+ help="Generated compose file (the input is never overwritten).",
307
+ )
308
+ @click.pass_context
309
+ def compile_cmd(
310
+ ctx: click.Context,
311
+ experiment: str | None,
312
+ compose: str | None,
313
+ output_path: str,
314
+ ) -> None:
315
+ """Emit a compose file with the drill's fault tooling baked in.
316
+
317
+ Reads the drill spec and the ``-c`` compose blueprint, then writes
318
+ ``docker-compose.mayhem.yml`` where every service the drill targets gains
319
+ a ``cap_add:`` list for its capability requirements and a bootstrap
320
+ entrypoint that installs the missing tooling on first start (apk /
321
+ apt-get / dnf / yum / microdnf / zypper, whichever the image has) and then
322
+ execs the service's own command. Start the stack with
323
+ ``podman compose -f docker-compose.mayhem.yml up -d``.
324
+ """
325
+ import copy
326
+
327
+ import yaml
328
+
329
+ from mayhem.agents.impact import compile_requirements as _compile_reqs
330
+ from mayhem.agents.impact import host_tooling_gaps as _host_gaps
331
+ from mayhem.cli.topology import _resolve_compose
332
+
333
+ plan, graph, _engine = _dependency_context(ctx, compose, experiment)
334
+ source = _resolve_compose(compose)
335
+ if source is None:
336
+ raise click.UsageError("no compose file found — pass -c docker-compose.yml", ctx=ctx)
337
+ source_path = Path(source)
338
+ document = yaml.safe_load(source_path.read_text(encoding="utf-8")) or {}
339
+ services = document.get("services")
340
+ if not isinstance(services, dict):
341
+ raise click.ClickException(f"{source}: no 'services' mapping to extend")
342
+
343
+ out_path = Path(output_path)
344
+ if out_path.resolve() == source_path.resolve():
345
+ raise click.UsageError(
346
+ "refusing to overwrite the input compose file — pick another -o",
347
+ ctx=ctx,
348
+ )
349
+
350
+ out = copy.deepcopy(document)
351
+ out_services = out.setdefault("services", {})
352
+ changed: list[tuple[str, ContainerCompilePlan, str | None]] = []
353
+ for compiled in _compile_reqs(plan, graph):
354
+ key = _service_key_for(services, compiled.container)
355
+ service = out_services.get(key) if key is not None else None
356
+ if key is None or not isinstance(service, dict):
357
+ click.echo(
358
+ f" {style.yellow('*')} {compiled.container}: not in the compose "
359
+ "file — tooling cannot be compiled in; fix mayhem.yaml or the "
360
+ "compose service name",
361
+ err=True,
362
+ )
363
+ continue
364
+ caps = _merge_caps(service, compiled.caps)
365
+ script = _bootstrap_script(compiled.bins)
366
+ if caps is not None:
367
+ service["cap_add"] = caps
368
+ if script:
369
+ source_spec = services.get(key) if key is not None else None
370
+ source_programless = isinstance(source_spec, dict) and not _service_has_program(
371
+ source_spec
372
+ )
373
+ service["entrypoint"] = ["/bin/sh", "-c", script]
374
+ if source_programless:
375
+ click.echo(
376
+ f" {style.yellow('*')} {compiled.container}: the service has no "
377
+ "'command' or 'entrypoint' of its own — it relies on the image "
378
+ "CMD, and a container engine that resets CMD when entrypoint is "
379
+ "overridden (e.g. podman-compose) drops it; the bootstrap "
380
+ "cannot exec a program and the container exits at first start. "
381
+ "Add an explicit 'command:' to the service.",
382
+ err=True,
383
+ )
384
+ changed.append((key, compiled, script))
385
+ if compiled.manual:
386
+ click.echo(
387
+ f" {style.yellow('*')} {compiled.container}: manual tooling "
388
+ f"— {', '.join(compiled.manual)} (no distro package to install)"
389
+ )
390
+
391
+ if not changed:
392
+ click.echo(style.ok("nothing to compile") + " — no planned fault needs tooling")
393
+ return
394
+ out_path.write_text(
395
+ yaml.safe_dump(out, sort_keys=False, default_flow_style=False, allow_unicode=True)
396
+ )
397
+ for svc_key, compiled, bootstrap in changed:
398
+ caps_label = ", ".join(compiled.caps) or "—"
399
+ script_label = "bootstrap entrypoint" if bootstrap else "no packages"
400
+ click.echo(f" {style.ok('[added]')} {svc_key}: cap_add {caps_label}; {script_label}")
401
+ for name in _host_gaps(plan):
402
+ click.echo(
403
+ f" host: {style.yellow('manual')} {name}"
404
+ " — runs on the drill host, not in a container; install on the drill host"
405
+ )
406
+ click.echo(
407
+ f"{style.ok('wrote')} {style.cyan(str(out_path))} — extend {source_path.name} untouched"
408
+ )
409
+ click.echo(
410
+ f" next: {style.cyan('podman compose -f ' + str(out_path) + ' up -d')}"
411
+ " then rerun the drill"
412
+ )
413
+
414
+
415
+ def _merge_caps(service: dict[str, object], required: Sequence[str]) -> list[str] | None:
416
+ """Union existing ``cap_add`` with the requirements; None when nothing needed."""
417
+ existing = service.get("cap_add")
418
+ base: list[str] = []
419
+ if isinstance(existing, list):
420
+ base = [str(c) for c in existing if c != "ALL"]
421
+ elif existing not in (None, "ALL"):
422
+ base = [str(existing)]
423
+ merged = sorted(set(base) | set(required))
424
+ return merged if merged else None
425
+
426
+
427
+ def _service_has_program(service: dict[str, object]) -> bool:
428
+ """True when the service defines its own ``command`` or ``entrypoint``."""
429
+ return service.get("command") is not None or service.get("entrypoint") is not None
@@ -0,0 +1,24 @@
1
+ """Stable, documented CLI exit codes.
2
+
3
+ Every failure category has its own number so scripts and CI can react
4
+ precisely. The mapping is part of mayhem's public contract; never reuse a
5
+ number for a different meaning. Documented in docs/reference/cli.md.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import IntEnum
11
+
12
+
13
+ class ExitCode(IntEnum):
14
+ SUCCESS = 0
15
+ GENERAL_FAILURE = 1 # anything not covered below
16
+ USAGE_ERROR = 2 # bad flags/arguments (Click's native usage error)
17
+ CONFIG_ERROR = 3 # configuration layering/validation failed
18
+ VALIDATION_ERROR = 4 # experiment/spec/target validation failed
19
+ SAFETY_REFUSAL = 5 # a safety gate refused the operation
20
+ EXPERIMENT_FAILURE = 6 # experiment ran and did not complete
21
+ RECOVERY_FAILURE = 7 # recovery/janitor left dirty state behind
22
+ AGENT_ERROR = 8 # agent transport/runtime failure
23
+ TOOLKIT_ERROR = 9 # external tool invocation failed structurally
24
+ AMBIGUOUS_COMMAND = 10 # command prefix matched multiple commands
@@ -0,0 +1,24 @@
1
+ """``experiment`` group: inspect and validate experiment specs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+
7
+ from mayhem.cli.lifecycle import validate as _validate_handler
8
+ from mayhem.cli.resolver import make_group
9
+
10
+ experiment = make_group("experiment", "Inspect and validate authored experiments.")
11
+
12
+
13
+ @experiment.command("show")
14
+ @click.argument("experiment", type=click.Path())
15
+ def show(experiment: str) -> None:
16
+ """Print the parsed drill spec as JSON."""
17
+ from mayhem.spec import load_drill
18
+
19
+ loaded = load_drill(experiment)
20
+ click.echo(loaded.model_dump_json(indent=2))
21
+
22
+
23
+ # Same handler object, registered under the group: zero behavioral drift.
24
+ experiment.add_command(_validate_handler, name="validate")