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
@@ -0,0 +1,239 @@
1
+ """Fault definitions and invocations (ADR-0004, fault-taxonomy contract).
2
+
3
+ A ``FaultDefinition`` is exactly what safety/planning consumes; the coverage
4
+ matrix in ``docs/fault-catalog/`` is generated from these definitions and only
5
+ counts cells proven by tests.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import StrEnum
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
13
+
14
+ from mayhem.domain.capabilities import Capability, Identifier
15
+ from mayhem.domain.common import Duration
16
+ from mayhem.domain.errors import SchemaValidationError
17
+ from mayhem.domain.risks import EnvironmentClass, RiskLevel
18
+ from mayhem.domain.topology import NodeKind
19
+
20
+
21
+ class FaultCategory(StrEnum):
22
+ PROCESS = "process"
23
+ CPU = "cpu"
24
+ MEMORY = "memory"
25
+ STORAGE = "storage"
26
+ NETWORK = "network"
27
+ CONTAINER = "container"
28
+ NODE = "node"
29
+ HTTP_API = "http_api"
30
+ DATABASE = "database"
31
+ LOAD = "load"
32
+ FUZZ = "fuzz"
33
+ DNS = "dns"
34
+ TLS = "tls"
35
+ CLOCK = "clock"
36
+ FD = "fd"
37
+ DEPENDENCY = "dependency"
38
+ K8S = "k8s" # ADR-M7-3: capacity / network / preemption sub-categories
39
+
40
+ @classmethod
41
+ def from_fault_id(cls, fault_id: str) -> FaultCategory:
42
+ prefix = fault_id.split(".", 1)[0]
43
+ category = _PREFIX_TO_CATEGORY.get(prefix)
44
+ if category is None:
45
+ raise SchemaValidationError(
46
+ "fault_id",
47
+ f"unknown category prefix {prefix!r} in {fault_id!r}; "
48
+ f"expected one of {sorted(_PREFIX_TO_CATEGORY)}",
49
+ )
50
+ return category
51
+
52
+
53
+ _PREFIX_TO_CATEGORY: dict[str, FaultCategory] = {
54
+ "net": FaultCategory.NETWORK,
55
+ "cpu": FaultCategory.CPU,
56
+ "mem": FaultCategory.MEMORY,
57
+ "fs": FaultCategory.STORAGE,
58
+ "disk": FaultCategory.STORAGE,
59
+ "storage": FaultCategory.STORAGE,
60
+ "proc": FaultCategory.PROCESS,
61
+ "process": FaultCategory.PROCESS,
62
+ "container": FaultCategory.CONTAINER,
63
+ "node": FaultCategory.NODE,
64
+ "http": FaultCategory.HTTP_API,
65
+ "db": FaultCategory.DATABASE,
66
+ "load": FaultCategory.LOAD,
67
+ "fuzz": FaultCategory.FUZZ,
68
+ "dns": FaultCategory.DNS,
69
+ "tls": FaultCategory.TLS,
70
+ "clock": FaultCategory.CLOCK,
71
+ "fd": FaultCategory.FD,
72
+ "dependency": FaultCategory.DEPENDENCY,
73
+ "k8s": FaultCategory.K8S,
74
+ }
75
+
76
+
77
+ class ParamType(StrEnum):
78
+ STRING = "string"
79
+ INTEGER = "integer"
80
+ FLOAT = "float"
81
+ BOOLEAN = "boolean"
82
+ DURATION = "duration"
83
+ PERCENT = "percent"
84
+ BYTES = "bytes"
85
+
86
+
87
+ class ParamSpec(BaseModel):
88
+ model_config = ConfigDict(frozen=True)
89
+
90
+ name: Identifier
91
+ type: ParamType
92
+ required: bool = False
93
+ default: str | int | float | bool | None = None
94
+ minimum: float | None = None
95
+ maximum: float | None = None
96
+
97
+
98
+ class FaultDefinition(BaseModel):
99
+ """Metadata contract for one fault (see docs/architecture/fault-taxonomy.md)."""
100
+
101
+ model_config = ConfigDict(frozen=True)
102
+
103
+ id: str
104
+ category: FaultCategory
105
+ risk: RiskLevel
106
+ reversible: bool = True
107
+ required_caps: frozenset[Capability] = Field(default_factory=frozenset)
108
+ applicable_node_kinds: frozenset[NodeKind] = Field(default_factory=frozenset)
109
+ max_duration_s: float = 300.0
110
+ backends: tuple[Identifier, ...] = ()
111
+ safe_env_classes: frozenset[EnvironmentClass] = Field(
112
+ default_factory=lambda: frozenset(EnvironmentClass)
113
+ )
114
+ params_schema: tuple[ParamSpec, ...] = ()
115
+
116
+ @field_validator("id")
117
+ @classmethod
118
+ def _known_prefix(cls, value: str) -> str:
119
+ FaultCategory.from_fault_id(value) # raises on unknown prefix
120
+ return value
121
+
122
+ @model_validator(mode="after")
123
+ def _category_matches_id(self) -> FaultDefinition:
124
+ expected = FaultCategory.from_fault_id(self.id)
125
+ if self.category is not expected:
126
+ raise SchemaValidationError(
127
+ "category",
128
+ f"fault {self.id!r} declares category {self.category.value!r} "
129
+ f"but prefix maps to {expected.value!r}",
130
+ )
131
+ return self
132
+
133
+ def validate_params(self, params: dict[str, object]) -> dict[str, object]:
134
+ """Validate raw params against ``params_schema``; returns normalized values.
135
+
136
+ Raises:
137
+ SchemaValidationError: On unknown, missing, or mistyped parameters.
138
+ """
139
+ known = {spec.name: spec for spec in self.params_schema}
140
+ normalized: dict[str, object] = {}
141
+ for name in params:
142
+ if name not in known:
143
+ raise SchemaValidationError(f"params[{self.id}]", f"unknown parameter {name!r}")
144
+ for spec in self.params_schema:
145
+ if spec.name not in params:
146
+ if spec.required and spec.default is None:
147
+ raise SchemaValidationError(
148
+ f"params[{self.id}]",
149
+ f"missing required parameter {spec.name!r}",
150
+ )
151
+ if spec.default is not None:
152
+ normalized[spec.name] = spec.default
153
+ continue
154
+ normalized[spec.name] = _coerce(spec, params[spec.name])
155
+ return normalized
156
+
157
+
158
+ class FaultInvocation(BaseModel):
159
+ """One concrete fault application against resolved targets."""
160
+
161
+ model_config = ConfigDict(frozen=True)
162
+
163
+ fault_id: str
164
+ targets: frozenset[str] # resolved node ids; non-empty enforced below
165
+ params: dict[str, object] = Field(default_factory=dict)
166
+ duration: Duration
167
+ backend: Identifier | None = None # None => executor picks via fallback group
168
+ lease_id: str | None = None # set when recovery machinery takes ownership
169
+
170
+ @field_validator("targets")
171
+ @classmethod
172
+ def _at_least_one_target(cls, value: frozenset[str]) -> frozenset[str]:
173
+ if not value:
174
+ raise SchemaValidationError("targets", "fault invocation requires >= 1 target")
175
+ return value
176
+
177
+ @field_validator("fault_id")
178
+ @classmethod
179
+ def _valid_fault_id(cls, value: str) -> str:
180
+ FaultCategory.from_fault_id(value)
181
+ return value
182
+
183
+
184
+ def _numeric(raw: object) -> float:
185
+ """Strict numeric coercion; bools are not numbers in fault params."""
186
+ if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
187
+ raise TypeError("expected a number")
188
+ return float(raw)
189
+
190
+
191
+ def _convert(spec: ParamSpec, raw: object) -> object:
192
+ match spec.type:
193
+ case ParamType.STRING:
194
+ value: object = str(raw)
195
+ case ParamType.INTEGER:
196
+ number = _numeric(raw)
197
+ if not number.is_integer():
198
+ raise ValueError(f"{raw!r} is not an integer")
199
+ value = int(number)
200
+ case ParamType.FLOAT:
201
+ value = _numeric(raw)
202
+ case ParamType.BOOLEAN:
203
+ if not isinstance(raw, bool):
204
+ raise ValueError("expected boolean")
205
+ value = raw
206
+ case ParamType.DURATION:
207
+ from mayhem.domain.common import parse_duration # noqa: PLC0415
208
+
209
+ if isinstance(raw, (int, float)):
210
+ value = float(raw) # numeric seconds (e.g. a 10.0 catalog default)
211
+ else:
212
+ value = parse_duration(str(raw))
213
+ case ParamType.BYTES:
214
+ from mayhem.domain.common import parse_bytes # noqa: PLC0415
215
+
216
+ value = parse_bytes(str(raw))
217
+ case ParamType.PERCENT:
218
+ number = _numeric(raw)
219
+ if not 0.0 <= number <= 100.0:
220
+ raise ValueError(f"{number} outside [0, 100]")
221
+ value = number
222
+ case _:
223
+ raise AssertionError(f"unhandled param type {spec.type}")
224
+ return value
225
+
226
+
227
+ def _coerce(spec: ParamSpec, raw: object) -> object:
228
+ try:
229
+ result = _convert(spec, raw)
230
+ except (TypeError, ValueError) as exc:
231
+ raise SchemaValidationError(
232
+ f"params[{spec.name}]", f"{raw!r} is not a valid {spec.type.value}: {exc}"
233
+ ) from exc
234
+ if isinstance(result, (int, float)) and not isinstance(result, bool):
235
+ if spec.minimum is not None and float(result) < spec.minimum:
236
+ raise SchemaValidationError(f"params[{spec.name}]", f"below minimum {spec.minimum}")
237
+ if spec.maximum is not None and float(result) > spec.maximum:
238
+ raise SchemaValidationError(f"params[{spec.name}]", f"above maximum {spec.maximum}")
239
+ return result
@@ -0,0 +1,200 @@
1
+ """Runtime identity and runtime metadata value objects (ADR-M1-1, ADR-M1-2).
2
+
3
+ The container half of the codebase used to bind targets by authored name
4
+ (``container_name``, ADR-0019/0020) plus ad-hoc scalars. Those are now split
5
+ into two sharply different concepts:
6
+
7
+ * :class:`RuntimeIdentity` **is** the identity — equality and hashing operate on
8
+ the three identity fields only. It is the key used by every persisted
9
+ plan/lease/execution/recovery record.
10
+ * :class:`RuntimeMetadata` is descriptive context (project, service, labels,
11
+ lifecycle timestamps). It is explicitly excluded from equality: metadata churn
12
+ never changes the identity.
13
+
14
+ ``container_name``/``service`` are *authoring resolver keys* that resolve to a
15
+ ``RuntimeIdentity`` at planning time; they are never identity equality.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from pydantic import BaseModel, ConfigDict, field_validator
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+
28
+ class RuntimeIdentity(BaseModel):
29
+ """Equality key for a single running workload (a container, later a process).
30
+
31
+ Attributes:
32
+ runtime: Engine label (``"docker"``/``"podman"``) — label only, per
33
+ ADR-0013.
34
+ host_id: Host node the workload runs under (e.g. ``h-podman-local``).
35
+ runtime_id: Engine-reported runtime object id (full container id).
36
+ """
37
+
38
+ model_config = ConfigDict(frozen=True)
39
+
40
+ runtime: str
41
+ host_id: str | None = None
42
+ runtime_id: str
43
+
44
+ @field_validator("runtime", "runtime_id")
45
+ @classmethod
46
+ def _non_empty(cls, value: str) -> str:
47
+ if not value.strip():
48
+ raise ValueError("identity fields runtime and runtime_id must be non-empty")
49
+ return value
50
+
51
+ def __eq__(self, other: object) -> bool:
52
+ if not isinstance(other, RuntimeIdentity):
53
+ return NotImplemented
54
+ # Equality on the three identity fields ONLY (ADR-M1-1); names, labels,
55
+ # and metadata never participate.
56
+ return (self.runtime, self.host_id, self.runtime_id) == (
57
+ other.runtime,
58
+ other.host_id,
59
+ other.runtime_id,
60
+ )
61
+
62
+ def __hash__(self) -> int:
63
+ return hash((self.runtime, self.host_id, self.runtime_id))
64
+
65
+ def resolve_key(self) -> str:
66
+ """Canonical, opaque, store-safe identity key (round-trippable)."""
67
+ return f"{self.runtime}|{self.host_id or ''}|{self.runtime_id}"
68
+
69
+ def key(self) -> str: # alias: key() == resolve_key()
70
+ """Short canonical key, usable as a SQL/store column value."""
71
+ return self.resolve_key()
72
+
73
+ @classmethod
74
+ def from_key(cls, key: str) -> RuntimeIdentity:
75
+ """Rebuild an identity from a canonical ``resolve_key()`` string."""
76
+ try:
77
+ runtime, host_id, runtime_id = key.split("|", maxsplit=2)
78
+ except ValueError as exc:
79
+ raise ValueError(f"not a canonical identity key: {key!r}") from exc
80
+ return cls(
81
+ runtime=runtime,
82
+ host_id=host_id or None,
83
+ runtime_id=runtime_id,
84
+ )
85
+
86
+
87
+ class ProcessRuntimeIdentity(BaseModel):
88
+ """Equality key for a single running process (ADR-M2 Phase 2.4).
89
+
90
+ A PID alone is not an identity: the kernel recycles PIDs after exit, so a
91
+ short-lived process can exit and its PID be reused by an unrelated process
92
+ while a lease is still active. The boot time (process start time, ``/proc/
93
+ <pid>/stat`` field 22, expressed in clock ticks since boot) disambiguates.
94
+
95
+ Attributes:
96
+ host_id: Host node the process runs under (e.g. ``h-local``).
97
+ pid: Host PID (or container-namespace PID for container-addressed runs).
98
+ boot_time: Process start time in clock ticks since boot; ``None`` when
99
+ the platform does not expose it (e.g. non-Linux), in which case the
100
+ guard degrades to pid-only checks.
101
+ container_name: Owning container name where applicable (ADR-0019/0020),
102
+ part of the identity for container-addressed targets.
103
+ """
104
+
105
+ model_config = ConfigDict(frozen=True)
106
+
107
+ host_id: str
108
+ pid: int
109
+ boot_time: int | None = None
110
+ container_name: str | None = None
111
+
112
+ @field_validator("pid")
113
+ @classmethod
114
+ def _positive_pid(cls, value: int) -> int:
115
+ if value <= 0:
116
+ raise ValueError("pid must be positive")
117
+ return value
118
+
119
+ def __eq__(self, other: object) -> bool:
120
+ if not isinstance(other, ProcessRuntimeIdentity):
121
+ return NotImplemented
122
+ return (self.host_id, self.pid, self.boot_time, self.container_name) == (
123
+ other.host_id,
124
+ other.pid,
125
+ other.boot_time,
126
+ other.container_name,
127
+ )
128
+
129
+ def __hash__(self) -> int:
130
+ return hash((self.host_id, self.pid, self.boot_time, self.container_name))
131
+
132
+ def resolve_key(self) -> str:
133
+ """Canonical, opaque, store-safe identity key (round-trippable)."""
134
+ return f"{self.host_id}|{self.pid}|{self.boot_time or ''}|{self.container_name or ''}"
135
+
136
+ def key(self) -> str: # alias: key() == resolve_key()
137
+ return self.resolve_key()
138
+
139
+ @classmethod
140
+ def from_key(cls, key: str) -> ProcessRuntimeIdentity:
141
+ """Rebuild an identity from a canonical ``resolve_key()`` string."""
142
+ try:
143
+ host_id, pid, boot_time, container_name = key.split("|", maxsplit=3)
144
+ except ValueError as exc:
145
+ raise ValueError(f"not a canonical process identity key: {key!r}") from exc
146
+ return cls(
147
+ host_id=host_id,
148
+ pid=int(pid),
149
+ boot_time=int(boot_time) if boot_time else None,
150
+ container_name=container_name or None,
151
+ )
152
+
153
+
154
+ class RuntimeMetadata(BaseModel):
155
+ """Descriptive, non-identity runtime context (ADR-M1-2).
156
+
157
+ All fields are mutable/descriptive and explicitly excluded from identity
158
+ equality. ``project``/``service`` come from the engine's compose labels;
159
+ ``name`` is the container name; lifecycle timestamps come from inspect.
160
+ """
161
+
162
+ model_config = ConfigDict(frozen=True)
163
+
164
+ project: str | None = None
165
+ service: str | None = None
166
+ name: str | None = None
167
+ labels: dict[str, str] = {}
168
+ created_at: str | None = None
169
+ started_at: str | None = None
170
+ image: str | None = None
171
+
172
+ @classmethod
173
+ def from_compose_labels(
174
+ cls, labels: Mapping[str, Any], name: str | None = None
175
+ ) -> RuntimeMetadata:
176
+ """Build metadata from engine compose labels (podman/docker)."""
177
+ raw = {str(k): str(v) for k, v in (labels or {}).items()}
178
+ return cls(
179
+ project=raw.get("com.docker.compose.project"),
180
+ service=raw.get("com.docker.compose.service"),
181
+ name=name,
182
+ labels=raw,
183
+ )
184
+
185
+ @classmethod
186
+ def from_inspect(cls, info: Mapping[str, Any], name: str | None = None) -> RuntimeMetadata:
187
+ """Build metadata from an engine ``inspect``-like mapping.
188
+
189
+ Recognized keys: ``"labels"``, ``"project"``, ``"service"``,
190
+ ``"created_at"``, ``"started_at"``, ``"image"``.
191
+ """
192
+ meta = cls.from_compose_labels(info.get("labels") or {}, name=name)
193
+ overrides: dict[str, Any] = {}
194
+ if info.get("created_at"):
195
+ overrides["created_at"] = str(info["created_at"])
196
+ if info.get("started_at"):
197
+ overrides["started_at"] = str(info["started_at"])
198
+ if info.get("image"):
199
+ overrides["image"] = str(info["image"])
200
+ return meta.model_copy(update=overrides)
@@ -0,0 +1,132 @@
1
+ """Kubernetes adapter interface (ADR-M7-1).
2
+
3
+ Defines the ``RuntimeAdapter``-compatible seam for Kubernetes. **No
4
+ Kubernetes runtime is implemented in this milestone** — the adapter returns
5
+ ``UNSUPPORTED`` for every capability until a future cluster driver ships.
6
+
7
+ Node-kind extensions (``NodeKind.POD`` / ``NodeKind.K8S_NODE``) are declared
8
+ in the topology model (ADR-M7-2); this module provides the capability contract
9
+ so the planner can refuse K8s plans with a clear, actionable message pointing
10
+ back to this ADR.
11
+
12
+ Design decisions
13
+ ----------------
14
+ * ``is_available()`` returns ``False`` — no transport is wired yet.
15
+ * ``capabilities()`` reports an empty supported set so that the verdict
16
+ matrix yields ``UNSUPPORTED`` for every ``RuntimeCapability``.
17
+ * ``list_nodes`` / ``list_pods`` are stubs returning empty lists — real
18
+ discovery belongs to the cluster-driver implementation (M8).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from mayhem.domain.runtime_adapter import (
26
+ AdapterCapabilities,
27
+ CapabilityRequirements,
28
+ CapabilityVerdict,
29
+ RuntimeAdapter,
30
+ RuntimeCapability,
31
+ VerdictResult,
32
+ )
33
+ from mayhem.topology.providers.base import PartialGraph
34
+
35
+ if TYPE_CHECKING:
36
+ from mayhem.domain.identity import RuntimeIdentity, RuntimeMetadata
37
+
38
+ # ── ADR-M7 reference constants ──────────────────────────────────────────────
39
+
40
+ ADR_M7_1 = "ADR-M7-1"
41
+ """k8s RuntimeAdapter interface contract."""
42
+
43
+ ADR_M7_2 = "ADR-M7-2"
44
+ """Topology node-kind extensions (PodNode / K8sNode)."""
45
+
46
+ ADR_M7_3 = "ADR-M7-3"
47
+ """k8s fault categories (capacity / network / preemption)."""
48
+
49
+ ADR_M7_4 = "ADR-M7-4"
50
+ """Capability matrix rows for k8s (default UNSUPPORTED)."""
51
+
52
+ UNSUPPORTED_MSG = (
53
+ "kubernetes execution not yet supported; see the RuntimeAdapter contract at ADR-M7-1"
54
+ )
55
+
56
+
57
+ class KubernetesAdapter(RuntimeAdapter):
58
+ """Stub Kubernetes adapter — every capability UNSUPPORTED (ADR-M7-1).
59
+
60
+ Registered in the adapter registry as ``"kubernetes"`` so that
61
+ ``best_effort("kubernetes")`` can locate the contract. The adapter is
62
+ never *available* (``is_available() → False``) until a live-cluster
63
+ driver is implemented.
64
+ """
65
+
66
+ ENGINE = "kubernetes"
67
+
68
+ def __init__(self, engine: str = ENGINE) -> None:
69
+ self._engine = engine
70
+
71
+ # ── RuntimeAdapter contract ──────────────────────────────────────────────
72
+
73
+ @property
74
+ def id(self) -> str:
75
+ return self._engine
76
+
77
+ def is_available(self) -> bool:
78
+ """No transport implemented; never *available* for execution yet."""
79
+ return False
80
+
81
+ def capabilities(self) -> AdapterCapabilities:
82
+ """Empty capability set — all RuntimeCapabilities yield UNSUPPORTED."""
83
+ return AdapterCapabilities(
84
+ engine=self._engine,
85
+ supported=frozenset(),
86
+ alternatives=frozenset(),
87
+ version=None,
88
+ )
89
+
90
+ def evaluate(self, reqs: CapabilityRequirements) -> VerdictResult:
91
+ """Every capability returns UNSUPPORTED (ADR-M7-1, ADR-M7-4)."""
92
+ verdicts = {cap.value: CapabilityVerdict.UNSUPPORTED.value for cap in RuntimeCapability}
93
+ # Any explicit requirement makes this blocking.
94
+ blocking = bool(reqs.namespaces or reqs.tools or reqs.runtimes) or True
95
+ return VerdictResult(
96
+ engine=self._engine,
97
+ requirements=reqs,
98
+ verdicts=verdicts,
99
+ blocking=blocking,
100
+ )
101
+
102
+ # ── discovery stubs ─────────────────────────────────────────────────────
103
+
104
+ def ps(self) -> list[dict[str, Any]]:
105
+ return []
106
+
107
+ def inspect(self, container_id: str) -> tuple[RuntimeIdentity, RuntimeMetadata | None]:
108
+ raise NotImplementedError(f"{UNSUPPORTED_MSG}; inspect({container_id!r})")
109
+
110
+ def exec(self, container_id: str, cmd: list[str], *, timeout_s: float = 30) -> str:
111
+ raise NotImplementedError(f"{UNSUPPORTED_MSG}; exec({container_id!r}, {cmd!r})")
112
+
113
+ def pid(self, container_id: str) -> int | None:
114
+ return None
115
+
116
+ def signal(self, container_id: str, signo: int) -> None:
117
+ raise NotImplementedError(f"{UNSUPPORTED_MSG}; signal({container_id!r}, {signo})")
118
+
119
+ def netns(self, container_id: str) -> str | None:
120
+ return None
121
+
122
+ def filter_by_compose(self, project: str, services: tuple[str, ...] | None = None) -> None:
123
+ return None
124
+
125
+ def filter_by_names(self, names: list[str]) -> None:
126
+ return None
127
+
128
+ def discover(self) -> PartialGraph:
129
+ return PartialGraph(
130
+ source=self._engine,
131
+ notes=("kubernetes adapter is interface-only (ADR-M7-1)",),
132
+ )