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/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
|
mayhem/cli/dependency.py
ADDED
|
@@ -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
|
mayhem/cli/exit_codes.py
ADDED
|
@@ -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
|
mayhem/cli/experiment.py
ADDED
|
@@ -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")
|