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/domain/faults.py
ADDED
|
@@ -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
|
+
)
|