mayhem-cli 0.5.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. mayhem/agent/__init__.py +1 -0
  2. mayhem/agent/cli.py +36 -0
  3. mayhem/agents/__init__.py +1 -0
  4. mayhem/agents/capabilities.py +106 -0
  5. mayhem/agents/executors.py +430 -0
  6. mayhem/agents/impact.py +729 -0
  7. mayhem/agents/lease_client.py +141 -0
  8. mayhem/agents/probes.py +284 -0
  9. mayhem/agents/protocol.py +134 -0
  10. mayhem/agents/server.py +281 -0
  11. mayhem/agents/sinks.py +60 -0
  12. mayhem/agents/transports.py +134 -0
  13. mayhem/agents/watchdog.py +140 -0
  14. mayhem/cli/__init__.py +11 -0
  15. mayhem/cli/app.py +154 -0
  16. mayhem/cli/campaign.py +496 -0
  17. mayhem/cli/config_cmd.py +47 -0
  18. mayhem/cli/context.py +23 -0
  19. mayhem/cli/dependency.py +429 -0
  20. mayhem/cli/exit_codes.py +24 -0
  21. mayhem/cli/experiment.py +24 -0
  22. mayhem/cli/lifecycle.py +805 -0
  23. mayhem/cli/resolver.py +72 -0
  24. mayhem/cli/services.py +459 -0
  25. mayhem/cli/style.py +101 -0
  26. mayhem/cli/toolkit.py +41 -0
  27. mayhem/cli/topology.py +127 -0
  28. mayhem/config.py +208 -0
  29. mayhem/controller/__init__.py +1 -0
  30. mayhem/controller/compensation.py +2156 -0
  31. mayhem/controller/executor.py +1719 -0
  32. mayhem/controller/janitor.py +196 -0
  33. mayhem/controller/observability_collector.py +382 -0
  34. mayhem/controller/observations.py +102 -0
  35. mayhem/controller/planner.py +715 -0
  36. mayhem/controller/recovery.py +245 -0
  37. mayhem/controller/resilience_report.py +585 -0
  38. mayhem/controller/resource_manager.py +457 -0
  39. mayhem/controller/safety.py +392 -0
  40. mayhem/domain/__init__.py +6 -0
  41. mayhem/domain/campaigns.py +118 -0
  42. mayhem/domain/cancellation.py +110 -0
  43. mayhem/domain/candidates.py +101 -0
  44. mayhem/domain/capabilities.py +86 -0
  45. mayhem/domain/catalog.py +727 -0
  46. mayhem/domain/checks.py +173 -0
  47. mayhem/domain/common.py +104 -0
  48. mayhem/domain/coverage.py +106 -0
  49. mayhem/domain/decisions.py +57 -0
  50. mayhem/domain/errors.py +87 -0
  51. mayhem/domain/events.py +61 -0
  52. mayhem/domain/execution_context.py +120 -0
  53. mayhem/domain/execution_loci.py +94 -0
  54. mayhem/domain/experiments.py +370 -0
  55. mayhem/domain/faults.py +239 -0
  56. mayhem/domain/identity.py +200 -0
  57. mayhem/domain/k8s_adapter.py +132 -0
  58. mayhem/domain/leases.py +186 -0
  59. mayhem/domain/load_strategy.py +98 -0
  60. mayhem/domain/m5_campaign.py +120 -0
  61. mayhem/domain/maniac.py +93 -0
  62. mayhem/domain/observability.py +146 -0
  63. mayhem/domain/outcomes.py +92 -0
  64. mayhem/domain/remote_agent_interface.py +70 -0
  65. mayhem/domain/resources.py +245 -0
  66. mayhem/domain/risks.py +61 -0
  67. mayhem/domain/run_outcome.py +146 -0
  68. mayhem/domain/runtime_adapter.py +256 -0
  69. mayhem/domain/success.py +329 -0
  70. mayhem/domain/topology.py +452 -0
  71. mayhem/infra/__init__.py +1 -0
  72. mayhem/infra/campaign_engine.py +205 -0
  73. mayhem/infra/candidate_gates.py +124 -0
  74. mayhem/infra/candidate_generator.py +110 -0
  75. mayhem/infra/coverage_repository.py +101 -0
  76. mayhem/infra/lease_repository.py +129 -0
  77. mayhem/infra/maniac.py +103 -0
  78. mayhem/infra/migrations.py +596 -0
  79. mayhem/infra/migrator.py +149 -0
  80. mayhem/infra/report.py +227 -0
  81. mayhem/infra/store.py +200 -0
  82. mayhem/py.typed +0 -0
  83. mayhem/spec.py +52 -0
  84. mayhem/toolkit/__init__.py +1 -0
  85. mayhem/toolkit/fingerprint.py +69 -0
  86. mayhem/toolkit/hashing.py +32 -0
  87. mayhem/toolkit/manifests/docker.yaml +11 -0
  88. mayhem/toolkit/manifests/podman.yaml +11 -0
  89. mayhem/toolkit/manifests/stress-ng.yaml +11 -0
  90. mayhem/toolkit/manifests/tc-netem.yaml +11 -0
  91. mayhem/toolkit/manifests/toxiproxy.yaml +10 -0
  92. mayhem/toolkit/registry.py +185 -0
  93. mayhem/toolkit/tool_runner.py +129 -0
  94. mayhem/topology/__init__.py +10 -0
  95. mayhem/topology/providers/__init__.py +0 -0
  96. mayhem/topology/providers/adapter_registry.py +60 -0
  97. mayhem/topology/providers/base.py +31 -0
  98. mayhem/topology/providers/compose.py +207 -0
  99. mayhem/topology/providers/docker_adapter.py +277 -0
  100. mayhem/topology/providers/docker_runtime.py +461 -0
  101. mayhem/topology/providers/podman_adapter.py +328 -0
  102. mayhem/topology/resolve.py +196 -0
  103. mayhem/topology/service.py +158 -0
  104. mayhem_cli-0.5.1.dist-info/METADATA +555 -0
  105. mayhem_cli-0.5.1.dist-info/RECORD +107 -0
  106. mayhem_cli-0.5.1.dist-info/WHEEL +4 -0
  107. mayhem_cli-0.5.1.dist-info/entry_points.txt +3 -0
mayhem/cli/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()
@@ -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))"))