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/lifecycle.py
ADDED
|
@@ -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))
|