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/app.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Root CLI assembly and top-level error -> exit-code mapping.
|
|
2
|
+
|
|
3
|
+
The tree is registered here; every group is a :class:`PrefixGroup`, so unique
|
|
4
|
+
prefix resolution works at every level. ``main()`` is the single place where
|
|
5
|
+
mayhem exceptions become documented exit codes — handlers raise typed domain
|
|
6
|
+
errors and never format exit codes themselves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from mayhem.cli import style
|
|
17
|
+
from mayhem.cli.campaign import campaign
|
|
18
|
+
from mayhem.cli.config_cmd import config
|
|
19
|
+
from mayhem.cli.context import CliContext
|
|
20
|
+
from mayhem.cli.dependency import dependency
|
|
21
|
+
from mayhem.cli.exit_codes import ExitCode
|
|
22
|
+
from mayhem.cli.experiment import experiment
|
|
23
|
+
from mayhem.cli.lifecycle import history, janitor, maniac, plan, recover, run, status, validate
|
|
24
|
+
from mayhem.cli.resolver import PREFIX_HELP, CommandResolutionError, PrefixGroup
|
|
25
|
+
from mayhem.cli.toolkit import toolkit
|
|
26
|
+
from mayhem.cli.topology import topology
|
|
27
|
+
from mayhem.controller.planner import PlanningError
|
|
28
|
+
from mayhem.controller.safety import SafetyRefusedError
|
|
29
|
+
from mayhem.domain.errors import (
|
|
30
|
+
DomainError,
|
|
31
|
+
InvariantViolationError,
|
|
32
|
+
SchemaValidationError,
|
|
33
|
+
TargetDriftError,
|
|
34
|
+
TargetResolutionError,
|
|
35
|
+
)
|
|
36
|
+
from mayhem.domain.maniac import ManiacError
|
|
37
|
+
from mayhem.toolkit.tool_runner import ToolError
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from collections.abc import Sequence
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_STATE: dict[str, str] = {"debug": "", "engine": ""}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@click.group(
|
|
47
|
+
cls=PrefixGroup,
|
|
48
|
+
epilog=PREFIX_HELP,
|
|
49
|
+
help="mayhem — safe-by-construction chaos experiments.",
|
|
50
|
+
no_args_is_help=True,
|
|
51
|
+
)
|
|
52
|
+
@click.option("--db", default=None, help="SQLite database path [default: mayhem.db].")
|
|
53
|
+
@click.option("--config", "config_path", default=None, help="Path to mayhem.yaml.")
|
|
54
|
+
@click.option("--profile", default=None, help="Configuration profile name.")
|
|
55
|
+
@click.option("--allow-critical", is_flag=True, help="Acknowledge critical-risk faults.")
|
|
56
|
+
@click.option(
|
|
57
|
+
"--skip-gate",
|
|
58
|
+
is_flag=True,
|
|
59
|
+
help="Run even when the impact gate proved some faults inert.",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"-p",
|
|
63
|
+
"--podman",
|
|
64
|
+
"podman",
|
|
65
|
+
is_flag=True,
|
|
66
|
+
default=False,
|
|
67
|
+
help="Use Podman instead of Docker.",
|
|
68
|
+
)
|
|
69
|
+
@click.option("-d", "--debug", is_flag=True, help="Re-raise errors instead of rendering them.")
|
|
70
|
+
@click.pass_context
|
|
71
|
+
def app(
|
|
72
|
+
ctx: click.Context,
|
|
73
|
+
db: str | None,
|
|
74
|
+
config_path: str | None,
|
|
75
|
+
profile: str | None,
|
|
76
|
+
allow_critical: bool,
|
|
77
|
+
skip_gate: bool,
|
|
78
|
+
podman: bool,
|
|
79
|
+
debug: bool,
|
|
80
|
+
) -> None:
|
|
81
|
+
_STATE["debug"] = "1" if debug else ""
|
|
82
|
+
_STATE["engine"] = "podman" if podman else ""
|
|
83
|
+
_STATE["gate"] = "0" if skip_gate else "1"
|
|
84
|
+
ctx.obj = CliContext(
|
|
85
|
+
db=db or "mayhem.db",
|
|
86
|
+
config=config_path,
|
|
87
|
+
profile=profile,
|
|
88
|
+
allow_critical=allow_critical,
|
|
89
|
+
debug=debug,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
for _cmd in (validate, plan, run, maniac, status, history, recover, janitor, dependency):
|
|
94
|
+
app.add_command(_cmd)
|
|
95
|
+
for _group in (experiment, topology, toolkit, config, campaign):
|
|
96
|
+
app.add_command(_group)
|
|
97
|
+
app.add_command(config, "cfg")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _fail(message: str, code: int) -> int:
|
|
101
|
+
click.echo(f"{style.danger('error:')} {message}", err=True)
|
|
102
|
+
return code
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
106
|
+
"""Dispatch argv and map every failure mode onto a documented exit code."""
|
|
107
|
+
try:
|
|
108
|
+
app.main(
|
|
109
|
+
args=list(argv) if argv is not None else sys.argv[1:],
|
|
110
|
+
prog_name="mayhem",
|
|
111
|
+
standalone_mode=False,
|
|
112
|
+
windows_expand_args=False,
|
|
113
|
+
)
|
|
114
|
+
except CommandResolutionError as exc:
|
|
115
|
+
code = ExitCode.USAGE_ERROR if not exc.candidates else ExitCode.AMBIGUOUS_COMMAND
|
|
116
|
+
return _fail(str(exc), int(code))
|
|
117
|
+
except click.UsageError as exc:
|
|
118
|
+
exc.show()
|
|
119
|
+
return int(ExitCode.USAGE_ERROR)
|
|
120
|
+
except click.exceptions.Exit as exc:
|
|
121
|
+
return int(exc.exit_code)
|
|
122
|
+
except click.exceptions.Abort:
|
|
123
|
+
return _fail("aborted.", int(ExitCode.GENERAL_FAILURE))
|
|
124
|
+
except SafetyRefusedError as exc:
|
|
125
|
+
return _fail(f"safety refused: {exc}", int(ExitCode.SAFETY_REFUSAL))
|
|
126
|
+
except SchemaValidationError as exc:
|
|
127
|
+
code = (
|
|
128
|
+
ExitCode.CONFIG_ERROR
|
|
129
|
+
if getattr(exc, "subject", "") == "config"
|
|
130
|
+
else ExitCode.VALIDATION_ERROR
|
|
131
|
+
)
|
|
132
|
+
return _fail(str(exc), int(code))
|
|
133
|
+
except (
|
|
134
|
+
InvariantViolationError,
|
|
135
|
+
ManiacError,
|
|
136
|
+
PlanningError,
|
|
137
|
+
TargetResolutionError,
|
|
138
|
+
TargetDriftError,
|
|
139
|
+
FileNotFoundError,
|
|
140
|
+
) as exc:
|
|
141
|
+
return _fail(str(exc), int(ExitCode.VALIDATION_ERROR))
|
|
142
|
+
except ToolError as exc:
|
|
143
|
+
return _fail(str(exc), int(ExitCode.TOOLKIT_ERROR))
|
|
144
|
+
except DomainError as exc:
|
|
145
|
+
return _fail(str(exc), int(ExitCode.GENERAL_FAILURE))
|
|
146
|
+
except Exception as exc: # last-resort boundary; see exit-code contract
|
|
147
|
+
if _STATE["debug"]:
|
|
148
|
+
raise
|
|
149
|
+
return _fail(f"{type(exc).__name__}: {exc}", int(ExitCode.GENERAL_FAILURE))
|
|
150
|
+
return int(ExitCode.SUCCESS)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
if __name__ == "__main__":
|
|
154
|
+
raise SystemExit(main())
|
mayhem/cli/campaign.py
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""CLI commands for campaigns (ADR-0023)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from mayhem.cli import style
|
|
12
|
+
from mayhem.cli.exit_codes import ExitCode
|
|
13
|
+
from mayhem.cli.resolver import make_group
|
|
14
|
+
from mayhem.cli.services import (
|
|
15
|
+
build_graph,
|
|
16
|
+
engine_for,
|
|
17
|
+
open_store,
|
|
18
|
+
plan_from_spec,
|
|
19
|
+
prepare,
|
|
20
|
+
run_campaign_sequence,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from click import Context
|
|
25
|
+
|
|
26
|
+
from mayhem.controller.executor import RunResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _ctx(ctx: Context):
|
|
30
|
+
from mayhem.cli.context import CliContext
|
|
31
|
+
|
|
32
|
+
obj = ctx.obj
|
|
33
|
+
assert isinstance(obj, CliContext)
|
|
34
|
+
return obj
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
campaign = make_group("campaign", "Create, list, and inspect chaos campaigns.")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@campaign.command("list")
|
|
41
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
42
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
43
|
+
@click.pass_context
|
|
44
|
+
def list_campaigns(ctx: Context, db_opt: str | None, as_json: bool) -> None:
|
|
45
|
+
"""List all campaigns."""
|
|
46
|
+
db = db_opt or _ctx(ctx).db
|
|
47
|
+
store = open_store(db)
|
|
48
|
+
try:
|
|
49
|
+
rows = store.query("SELECT id, name, status, created_at FROM campaigns ORDER BY created_at")
|
|
50
|
+
if as_json:
|
|
51
|
+
click.echo(json.dumps([dict(r) for r in rows], indent=2))
|
|
52
|
+
else:
|
|
53
|
+
if not rows:
|
|
54
|
+
click.echo("No campaigns.")
|
|
55
|
+
return
|
|
56
|
+
for row in rows:
|
|
57
|
+
click.echo(f"{row['id']:<32} {row['name']:<24} {style.state(row['status'])}")
|
|
58
|
+
finally:
|
|
59
|
+
store.close()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@campaign.command("create")
|
|
63
|
+
@click.argument("name")
|
|
64
|
+
@click.option("--description", "-d", default="", help="Campaign description.")
|
|
65
|
+
@click.option("--hypothesis", "-h", default=None, help="Campaign hypothesis.")
|
|
66
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
67
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
68
|
+
@click.pass_context
|
|
69
|
+
def create_campaign(
|
|
70
|
+
ctx: Context,
|
|
71
|
+
name: str,
|
|
72
|
+
description: str,
|
|
73
|
+
hypothesis: str | None,
|
|
74
|
+
db_opt: str | None,
|
|
75
|
+
as_json: bool,
|
|
76
|
+
) -> None:
|
|
77
|
+
"""Create a new campaign in draft status."""
|
|
78
|
+
import uuid
|
|
79
|
+
from datetime import datetime
|
|
80
|
+
|
|
81
|
+
db = db_opt or _ctx(ctx).db
|
|
82
|
+
store = open_store(db)
|
|
83
|
+
try:
|
|
84
|
+
campaign_id = f"camp-{uuid.uuid4().hex[:12]}"
|
|
85
|
+
now = datetime.now(UTC).isoformat()
|
|
86
|
+
with store.write() as conn:
|
|
87
|
+
conn.execute(
|
|
88
|
+
"INSERT INTO campaigns (id, name, description, status, created_at, updated_at)"
|
|
89
|
+
" VALUES (?, ?, ?, 'draft', ?, ?)",
|
|
90
|
+
(campaign_id, name, description, now, now),
|
|
91
|
+
)
|
|
92
|
+
if as_json:
|
|
93
|
+
row = store.query(
|
|
94
|
+
"SELECT id, name, description, status, created_at, updated_at"
|
|
95
|
+
" FROM campaigns WHERE id = ?",
|
|
96
|
+
(campaign_id,),
|
|
97
|
+
)
|
|
98
|
+
click.echo(json.dumps(dict(row[0]), indent=2))
|
|
99
|
+
else:
|
|
100
|
+
click.echo(style.ok(f"Campaign '{campaign_id}' created successfully."))
|
|
101
|
+
finally:
|
|
102
|
+
store.close()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@campaign.command("show")
|
|
106
|
+
@click.argument("campaign_id")
|
|
107
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
108
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
109
|
+
@click.pass_context
|
|
110
|
+
def show_campaign(ctx: Context, campaign_id: str, db_opt: str | None, as_json: bool) -> None:
|
|
111
|
+
"""Show campaign details."""
|
|
112
|
+
db = db_opt or _ctx(ctx).db
|
|
113
|
+
store = open_store(db)
|
|
114
|
+
try:
|
|
115
|
+
rows = store.query("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
|
|
116
|
+
if not rows:
|
|
117
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
118
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
119
|
+
row = dict(rows[0])
|
|
120
|
+
runs = [
|
|
121
|
+
dict(r)
|
|
122
|
+
for r in store.query(
|
|
123
|
+
"SELECT run_id, data_json, timestamp FROM observations"
|
|
124
|
+
" WHERE source = ? AND kind = 'campaign_run' ORDER BY timestamp",
|
|
125
|
+
(campaign_id,),
|
|
126
|
+
)
|
|
127
|
+
]
|
|
128
|
+
if as_json:
|
|
129
|
+
for key in ("experiments_json", "window_json", "policy_json", "labels_json"):
|
|
130
|
+
if row.get(key):
|
|
131
|
+
row[key] = json.loads(row[key])
|
|
132
|
+
parsed_runs = []
|
|
133
|
+
for r in runs:
|
|
134
|
+
spec = json.loads(r["data_json"]).get("spec", "")
|
|
135
|
+
parsed_runs.append(
|
|
136
|
+
{"spec_path": spec, "run_id": r["run_id"], "timestamp": r["timestamp"]}
|
|
137
|
+
)
|
|
138
|
+
row["runs"] = parsed_runs
|
|
139
|
+
click.echo(json.dumps(row, indent=2))
|
|
140
|
+
else:
|
|
141
|
+
click.echo(f"Campaign: {row['name']} ({row['id']})")
|
|
142
|
+
click.echo(f" Status: {style.state(row['status'])}")
|
|
143
|
+
if row.get("description"):
|
|
144
|
+
click.echo(f" Description: {row['description']}")
|
|
145
|
+
click.echo(f" Created: {row['created_at']}")
|
|
146
|
+
if runs:
|
|
147
|
+
click.echo(" Runs:")
|
|
148
|
+
for r in runs:
|
|
149
|
+
spec = json.loads(r["data_json"]).get("spec", "?")
|
|
150
|
+
click.echo(f" - {spec} -> {r['run_id']} ({r['timestamp']})")
|
|
151
|
+
finally:
|
|
152
|
+
store.close()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@campaign.command("status")
|
|
156
|
+
@click.argument("campaign_id")
|
|
157
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
158
|
+
@click.pass_context
|
|
159
|
+
def campaign_status(ctx: Context, campaign_id: str, db_opt: str | None) -> None:
|
|
160
|
+
"""Show the status of a campaign."""
|
|
161
|
+
db = db_opt or _ctx(ctx).db
|
|
162
|
+
store = open_store(db)
|
|
163
|
+
try:
|
|
164
|
+
rows = store.query("SELECT id, name, status FROM campaigns WHERE id = ?", (campaign_id,))
|
|
165
|
+
if not rows:
|
|
166
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
167
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
168
|
+
row = dict(rows[0])
|
|
169
|
+
click.echo(f"{row['id']} {row['name']} {style.state(row['status'])}")
|
|
170
|
+
finally:
|
|
171
|
+
store.close()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@campaign.command("delete")
|
|
175
|
+
@click.argument("campaign_id")
|
|
176
|
+
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
|
|
177
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
178
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
179
|
+
@click.pass_context
|
|
180
|
+
def delete_campaign(
|
|
181
|
+
ctx: Context, campaign_id: str, yes: bool, db_opt: str | None, as_json: bool
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Delete a campaign (only drafts)."""
|
|
184
|
+
db = db_opt or _ctx(ctx).db
|
|
185
|
+
store = open_store(db)
|
|
186
|
+
try:
|
|
187
|
+
rows = store.query("SELECT id, name, status FROM campaigns WHERE id = ?", (campaign_id,))
|
|
188
|
+
if not rows:
|
|
189
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
190
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
191
|
+
row = dict(rows[0])
|
|
192
|
+
if row["status"] != "draft":
|
|
193
|
+
click.echo(
|
|
194
|
+
f"Cannot delete campaign in '{row['status']}' status (must be 'draft').",
|
|
195
|
+
err=True,
|
|
196
|
+
)
|
|
197
|
+
raise click.UsageError(f"cannot delete campaign in '{row['status']}' status")
|
|
198
|
+
if not yes:
|
|
199
|
+
click.confirm(f"Delete campaign '{row['name']}' ({campaign_id})?", abort=True)
|
|
200
|
+
with store.write() as conn:
|
|
201
|
+
conn.execute("DELETE FROM campaigns WHERE id = ?", (campaign_id,))
|
|
202
|
+
if as_json:
|
|
203
|
+
click.echo(json.dumps({"deleted": campaign_id}))
|
|
204
|
+
else:
|
|
205
|
+
click.echo(style.ok(f"Campaign '{campaign_id}' deleted."))
|
|
206
|
+
finally:
|
|
207
|
+
store.close()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@campaign.command("start")
|
|
211
|
+
@click.argument("campaign_id")
|
|
212
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
213
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
214
|
+
@click.pass_context
|
|
215
|
+
def start_campaign(ctx: Context, campaign_id: str, db_opt: str | None, as_json: bool) -> None:
|
|
216
|
+
"""Start a draft campaign (set status to 'running')."""
|
|
217
|
+
from datetime import datetime
|
|
218
|
+
|
|
219
|
+
db = db_opt or _ctx(ctx).db
|
|
220
|
+
store = open_store(db)
|
|
221
|
+
try:
|
|
222
|
+
rows = store.query("SELECT id, name, status FROM campaigns WHERE id = ?", (campaign_id,))
|
|
223
|
+
if not rows:
|
|
224
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
225
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
226
|
+
row = dict(rows[0])
|
|
227
|
+
if row["status"] != "draft":
|
|
228
|
+
click.echo(
|
|
229
|
+
f"Cannot start campaign in '{row['status']}' status (must be 'draft').",
|
|
230
|
+
err=True,
|
|
231
|
+
)
|
|
232
|
+
raise click.UsageError(f"cannot start campaign in '{row['status']}' status")
|
|
233
|
+
now = datetime.now(UTC).isoformat()
|
|
234
|
+
with store.write() as conn:
|
|
235
|
+
conn.execute(
|
|
236
|
+
"UPDATE campaigns SET status = 'running', updated_at = ? WHERE id = ?",
|
|
237
|
+
(now, campaign_id),
|
|
238
|
+
)
|
|
239
|
+
if as_json:
|
|
240
|
+
rows2 = store.query("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
|
|
241
|
+
r = dict(rows2[0])
|
|
242
|
+
for key in ("experiments_json", "window_json", "policy_json", "labels_json"):
|
|
243
|
+
if r.get(key):
|
|
244
|
+
r[key] = json.loads(r[key])
|
|
245
|
+
click.echo(json.dumps(r, indent=2))
|
|
246
|
+
else:
|
|
247
|
+
click.echo(style.ok(f"Campaign '{campaign_id}' started."))
|
|
248
|
+
finally:
|
|
249
|
+
store.close()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@campaign.command("archive")
|
|
253
|
+
@click.argument("campaign_id")
|
|
254
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
255
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
256
|
+
@click.pass_context
|
|
257
|
+
def archive_campaign(ctx: Context, campaign_id: str, db_opt: str | None, as_json: bool) -> None:
|
|
258
|
+
"""Archive a campaign (set status to 'completed')."""
|
|
259
|
+
from datetime import datetime
|
|
260
|
+
|
|
261
|
+
db = db_opt or _ctx(ctx).db
|
|
262
|
+
store = open_store(db)
|
|
263
|
+
try:
|
|
264
|
+
rows = store.query("SELECT id, name, status FROM campaigns WHERE id = ?", (campaign_id,))
|
|
265
|
+
if not rows:
|
|
266
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
267
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
268
|
+
now = datetime.now(UTC).isoformat()
|
|
269
|
+
with store.write() as conn:
|
|
270
|
+
conn.execute(
|
|
271
|
+
"UPDATE campaigns SET status = 'completed', updated_at = ? WHERE id = ?",
|
|
272
|
+
(now, campaign_id),
|
|
273
|
+
)
|
|
274
|
+
if as_json:
|
|
275
|
+
rows2 = store.query("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
|
|
276
|
+
r = dict(rows2[0])
|
|
277
|
+
for key in ("experiments_json", "window_json", "policy_json", "labels_json"):
|
|
278
|
+
if r.get(key):
|
|
279
|
+
r[key] = json.loads(r[key])
|
|
280
|
+
click.echo(json.dumps(r, indent=2))
|
|
281
|
+
else:
|
|
282
|
+
click.echo(style.ok(f"Campaign '{campaign_id}' archived."))
|
|
283
|
+
finally:
|
|
284
|
+
store.close()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@campaign.command("abort")
|
|
288
|
+
@click.argument("campaign_id")
|
|
289
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
290
|
+
@click.pass_context
|
|
291
|
+
def abort_campaign(ctx: Context, campaign_id: str, db_opt: str | None) -> None:
|
|
292
|
+
"""Abort a draft campaign (set status to 'aborted')."""
|
|
293
|
+
from datetime import datetime
|
|
294
|
+
|
|
295
|
+
db = db_opt or _ctx(ctx).db
|
|
296
|
+
store = open_store(db)
|
|
297
|
+
try:
|
|
298
|
+
rows = store.query("SELECT id, name, status FROM campaigns WHERE id = ?", (campaign_id,))
|
|
299
|
+
if not rows:
|
|
300
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
301
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
302
|
+
now = datetime.now(UTC).isoformat()
|
|
303
|
+
with store.write() as conn:
|
|
304
|
+
conn.execute(
|
|
305
|
+
"UPDATE campaigns SET status = 'aborted', updated_at = ? WHERE id = ?",
|
|
306
|
+
(now, campaign_id),
|
|
307
|
+
)
|
|
308
|
+
click.echo(style.ok(f"Campaign '{campaign_id}' aborted."))
|
|
309
|
+
finally:
|
|
310
|
+
store.close()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@campaign.command("add-experiment")
|
|
314
|
+
@click.argument("campaign_id")
|
|
315
|
+
@click.argument("experiment_path", type=click.Path(exists=True))
|
|
316
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
317
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
318
|
+
@click.pass_context
|
|
319
|
+
def add_experiment(
|
|
320
|
+
ctx: Context,
|
|
321
|
+
campaign_id: str,
|
|
322
|
+
experiment_path: str,
|
|
323
|
+
db_opt: str | None,
|
|
324
|
+
as_json: bool,
|
|
325
|
+
) -> None:
|
|
326
|
+
"""Add an experiment spec file to a campaign."""
|
|
327
|
+
from datetime import datetime
|
|
328
|
+
|
|
329
|
+
db = db_opt or _ctx(ctx).db
|
|
330
|
+
store = open_store(db)
|
|
331
|
+
try:
|
|
332
|
+
rows = store.query("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
|
|
333
|
+
if not rows:
|
|
334
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
335
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
336
|
+
row = dict(rows[0])
|
|
337
|
+
experiments = json.loads(row.get("experiments_json") or "[]")
|
|
338
|
+
experiments.append(experiment_path)
|
|
339
|
+
now = datetime.now(UTC).isoformat()
|
|
340
|
+
with store.write() as conn:
|
|
341
|
+
conn.execute(
|
|
342
|
+
"UPDATE campaigns SET experiments_json = ?, updated_at = ? WHERE id = ?",
|
|
343
|
+
(json.dumps(experiments), now, campaign_id),
|
|
344
|
+
)
|
|
345
|
+
if as_json:
|
|
346
|
+
click.echo(
|
|
347
|
+
json.dumps({"campaign_id": campaign_id, "experiments": experiments}, indent=2)
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
click.echo(style.ok(f"Added experiment to campaign '{campaign_id}'."))
|
|
351
|
+
finally:
|
|
352
|
+
store.close()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _resolve_engine_from_state() -> str:
|
|
356
|
+
from mayhem.cli.app import _STATE
|
|
357
|
+
from mayhem.cli.topology import _resolve_engine
|
|
358
|
+
|
|
359
|
+
return _resolve_engine(str(_STATE.get("engine", ""))) or "podman"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@campaign.command("run")
|
|
363
|
+
@click.argument("campaign_id")
|
|
364
|
+
@click.option("--compose", "-c", type=str, default=None, help="Compose file path.")
|
|
365
|
+
@click.option("--no-gate", is_flag=True, help="Skip the impact gate for this run.")
|
|
366
|
+
@click.option("--db", "db_opt", default=None, help="SQLite database path.")
|
|
367
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit as JSON.")
|
|
368
|
+
@click.pass_context
|
|
369
|
+
def run_campaign(
|
|
370
|
+
ctx: Context,
|
|
371
|
+
campaign_id: str,
|
|
372
|
+
compose: str | None,
|
|
373
|
+
no_gate: bool,
|
|
374
|
+
db_opt: str | None,
|
|
375
|
+
as_json: bool,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Execute a campaign's experiments sequentially (ADR-0023).
|
|
378
|
+
|
|
379
|
+
Each experiment spec is compiled and executed against the compose
|
|
380
|
+
topology. The campaign's policy_json failure policy and window_json
|
|
381
|
+
deadline/cooldown are honored; per-experiment results are recorded in the
|
|
382
|
+
observations table.
|
|
383
|
+
"""
|
|
384
|
+
from datetime import datetime
|
|
385
|
+
|
|
386
|
+
from mayhem.cli.lifecycle import _gate_bypasses, _gate_enabled, _graph_from
|
|
387
|
+
|
|
388
|
+
obj = _ctx(ctx)
|
|
389
|
+
db = db_opt or obj.db
|
|
390
|
+
store = open_store(db)
|
|
391
|
+
try:
|
|
392
|
+
rows = store.query("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
|
|
393
|
+
if not rows:
|
|
394
|
+
click.echo(f"{style.danger('error:')} Campaign {campaign_id!r} not found.", err=True)
|
|
395
|
+
raise FileNotFoundError(f"campaign not found: {campaign_id}")
|
|
396
|
+
row = dict(rows[0])
|
|
397
|
+
experiments = json.loads(row.get("experiments_json") or "[]")
|
|
398
|
+
if not experiments:
|
|
399
|
+
click.echo(
|
|
400
|
+
f"{style.danger('error:')} Campaign has no experiments; add one with"
|
|
401
|
+
" `mayhem campaign add-experiment`.",
|
|
402
|
+
err=True,
|
|
403
|
+
)
|
|
404
|
+
raise click.UsageError("campaign has no experiments", ctx=ctx)
|
|
405
|
+
if row["status"] not in ("draft", "scheduled", "running", "paused"):
|
|
406
|
+
click.echo(
|
|
407
|
+
"Cannot run campaign in "
|
|
408
|
+
f"'{row['status']}' status (must be draft/scheduled/running/paused).",
|
|
409
|
+
err=True,
|
|
410
|
+
)
|
|
411
|
+
raise click.UsageError(f"cannot run campaign in '{row['status']}' status")
|
|
412
|
+
|
|
413
|
+
graph, resolved_compose = _graph_from(ctx, compose)
|
|
414
|
+
engine_name = _resolve_engine_from_state()
|
|
415
|
+
gate = _gate_enabled() and not no_gate
|
|
416
|
+
|
|
417
|
+
now = datetime.now(UTC).isoformat()
|
|
418
|
+
with store.write() as conn:
|
|
419
|
+
conn.execute(
|
|
420
|
+
"UPDATE campaigns SET status = 'running', updated_at = ? WHERE id = ?",
|
|
421
|
+
(now, campaign_id),
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def _run_one(spec_path: str) -> RunResult:
|
|
425
|
+
from mayhem.cli.lifecycle import _debug_progress
|
|
426
|
+
|
|
427
|
+
prepared = prepare(
|
|
428
|
+
config_path=obj.config,
|
|
429
|
+
profile=obj.profile,
|
|
430
|
+
allow_critical=obj.allow_critical,
|
|
431
|
+
store=store,
|
|
432
|
+
graph=graph,
|
|
433
|
+
compose=resolved_compose,
|
|
434
|
+
spec_path=spec_path,
|
|
435
|
+
)
|
|
436
|
+
compiled = plan_from_spec(spec_path, graph, prepared=prepared, engine=engine_name)
|
|
437
|
+
bypass: dict[tuple[str, str], str] = {}
|
|
438
|
+
if gate:
|
|
439
|
+
bypass = _gate_bypasses(engine_name, compiled.plan, graph)
|
|
440
|
+
eng = engine_for(
|
|
441
|
+
store,
|
|
442
|
+
engine_name,
|
|
443
|
+
live_graph=lambda: build_graph(resolved_compose),
|
|
444
|
+
on_event=_debug_progress() if obj.debug else None,
|
|
445
|
+
bypass=bypass,
|
|
446
|
+
)
|
|
447
|
+
return eng.execute(compiled.plan)
|
|
448
|
+
|
|
449
|
+
outcome = run_campaign_sequence(
|
|
450
|
+
campaign_id=campaign_id,
|
|
451
|
+
experiments=experiments,
|
|
452
|
+
policy=json.loads(row.get("policy_json") or "{}"),
|
|
453
|
+
window=json.loads(row.get("window_json") or "{}"),
|
|
454
|
+
store=store,
|
|
455
|
+
run_one=_run_one,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
final_status = "completed" if outcome.status == "completed" else "aborted"
|
|
459
|
+
now = datetime.now(UTC).isoformat()
|
|
460
|
+
with store.write() as conn:
|
|
461
|
+
conn.execute(
|
|
462
|
+
"UPDATE campaigns SET status = ?, updated_at = ? WHERE id = ?",
|
|
463
|
+
(final_status, now, campaign_id),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if as_json:
|
|
467
|
+
click.echo(
|
|
468
|
+
json.dumps(
|
|
469
|
+
{
|
|
470
|
+
"campaign_id": campaign_id,
|
|
471
|
+
"status": outcome.status,
|
|
472
|
+
"runs": [
|
|
473
|
+
{
|
|
474
|
+
"spec_path": e.spec_path,
|
|
475
|
+
"run_id": e.run_id,
|
|
476
|
+
"status": e.status,
|
|
477
|
+
}
|
|
478
|
+
for e in outcome.runs
|
|
479
|
+
],
|
|
480
|
+
},
|
|
481
|
+
indent=2,
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
else:
|
|
485
|
+
for entry in outcome.runs:
|
|
486
|
+
mark = style.ok("ok") if entry.status == "completed" else style.danger("FAIL")
|
|
487
|
+
click.echo(f" [{mark}] {entry.spec_path} -> {entry.run_id} ({entry.status})")
|
|
488
|
+
click.echo(
|
|
489
|
+
f"Campaign {style.cyan(campaign_id)} finished with status"
|
|
490
|
+
f" {style.state(outcome.status)}."
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
if outcome.status != "completed":
|
|
494
|
+
ctx.exit(int(ExitCode.EXPERIMENT_FAILURE))
|
|
495
|
+
finally:
|
|
496
|
+
store.close()
|
mayhem/cli/config_cmd.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``config`` group: show and validate layered configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from mayhem.cli import style
|
|
10
|
+
from mayhem.cli.resolver import make_group
|
|
11
|
+
from mayhem.cli.services import effective_config
|
|
12
|
+
|
|
13
|
+
config = make_group("config", "Inspect the effective layered mayhem configuration.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@config.command("show")
|
|
17
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit resolved config as JSON.")
|
|
18
|
+
@click.pass_context
|
|
19
|
+
def show(ctx: click.Context, as_json: bool) -> None:
|
|
20
|
+
"""Print the effective configuration after all layers are merged."""
|
|
21
|
+
from mayhem.cli.context import CliContext
|
|
22
|
+
|
|
23
|
+
obj = ctx.obj
|
|
24
|
+
assert isinstance(obj, CliContext)
|
|
25
|
+
cfg, sources = effective_config(obj.config, obj.profile)
|
|
26
|
+
if as_json:
|
|
27
|
+
payload = {"config": cfg.model_dump(mode="json", by_alias=True), "sources": sources}
|
|
28
|
+
click.echo(json.dumps(payload, indent=2))
|
|
29
|
+
return
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
click.echo(yaml.safe_dump(cfg.model_dump(mode="json", by_alias=True), sort_keys=False))
|
|
33
|
+
click.echo("# sources:")
|
|
34
|
+
for layer, origin in sources.items():
|
|
35
|
+
click.echo(f"# {layer}: {origin}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@config.command("validate")
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def validate(ctx: click.Context) -> None:
|
|
41
|
+
"""Load every configuration layer; refuse unknown keys or versions."""
|
|
42
|
+
from mayhem.cli.context import CliContext
|
|
43
|
+
|
|
44
|
+
obj = ctx.obj
|
|
45
|
+
assert isinstance(obj, CliContext)
|
|
46
|
+
_cfg, sources = effective_config(obj.config, obj.profile)
|
|
47
|
+
click.echo(style.ok(f"configuration valid ({len(sources)} layer(s))"))
|