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/agents/impact.py
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
"""Fault impact gate — can this fault actually perturb this container?
|
|
2
|
+
|
|
3
|
+
Faults are functional only when the runtime the flavour injects into is real:
|
|
4
|
+
`net.latency` needs ``tc`` *and* the ``NET_ADMIN`` capability to add a netem
|
|
5
|
+
qdisc inside the container, `http.error_injection` needs ``iptables``, payload
|
|
6
|
+
faults need a Python interpreter, `net.load` needs a ``k6`` binary **on the
|
|
7
|
+
drill host** (the load generator drives the container from outside; it is not
|
|
8
|
+
a container package). A fault whose tooling is absent still *completes* (inject
|
|
9
|
+
exits 0) but produces zero perturbation — the run degrades into a survey
|
|
10
|
+
instead of a drill.
|
|
11
|
+
|
|
12
|
+
This module probes the live container once (read-only), decides per family
|
|
13
|
+
whether the injection can physically take effect, and lets the planner gate
|
|
14
|
+
refuse definitively inert faults before they ever execute. Requirements marked
|
|
15
|
+
``host=True`` are resolved against the drill host instead of the container —
|
|
16
|
+
the container-tooling probe and ``mayhem dependency install`` never see them.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import TYPE_CHECKING
|
|
28
|
+
|
|
29
|
+
from mayhem.toolkit.tool_runner import ToolError, run_tool
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from collections.abc import Sequence
|
|
33
|
+
|
|
34
|
+
from mayhem.domain.experiments import ExecutionPlan, PlannedFault
|
|
35
|
+
from mayhem.domain.topology import TopologyGraph
|
|
36
|
+
|
|
37
|
+
_CAP_BITS: dict[str, int] = {
|
|
38
|
+
"NET_ADMIN": 12,
|
|
39
|
+
"SYS_TIME": 25,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class FaultRequirement:
|
|
45
|
+
"""Runtime capability a fault family needs inside the target container.
|
|
46
|
+
|
|
47
|
+
``host=True`` moves the ``bins`` check to the drill host (e.g. ``k6`` for
|
|
48
|
+
``net.load`` — host-side load generator, not container tooling). The
|
|
49
|
+
container probe and ``mayhem dependency install`` ignore host requirements.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
bins: frozenset[str] = frozenset()
|
|
53
|
+
caps: frozenset[str] = frozenset()
|
|
54
|
+
need_root: bool = False
|
|
55
|
+
host: bool = False
|
|
56
|
+
|
|
57
|
+
def describe(self) -> str:
|
|
58
|
+
parts = [f"bin({b}{'@host' if self.host else ''})" for b in sorted(self.bins)]
|
|
59
|
+
parts += [f"cap({c})" for c in sorted(self.caps)]
|
|
60
|
+
if self.need_root:
|
|
61
|
+
parts.append("uid(0)")
|
|
62
|
+
return ", ".join(parts) if parts else "none"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
REQUIREMENTS: dict[str, FaultRequirement] = {
|
|
66
|
+
"proc.pause": FaultRequirement(bins=frozenset({"kill"})),
|
|
67
|
+
"mem.exhaust": FaultRequirement(bins=frozenset({"python"})),
|
|
68
|
+
"mem.leak": FaultRequirement(bins=frozenset({"python"})),
|
|
69
|
+
"cpu.saturate": FaultRequirement(bins=frozenset({"python"})),
|
|
70
|
+
"fs.fill": FaultRequirement(bins=frozenset({"python"})),
|
|
71
|
+
"fs.inode_exhaust": FaultRequirement(bins=frozenset({"python"})),
|
|
72
|
+
"fs.io_stress": FaultRequirement(bins=frozenset({"python"})),
|
|
73
|
+
"fs.read_only": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
|
|
74
|
+
"fd.exhaust": FaultRequirement(bins=frozenset({"python"})),
|
|
75
|
+
"load.spike": FaultRequirement(bins=frozenset({"python"})),
|
|
76
|
+
"fuzz.protocol_abuse": FaultRequirement(bins=frozenset({"python"})),
|
|
77
|
+
"http.latency": FaultRequirement(bins=frozenset({"python"})),
|
|
78
|
+
"db.connection_exhaust": FaultRequirement(bins=frozenset({"python"})),
|
|
79
|
+
"dependency.rate_limit": FaultRequirement(bins=frozenset({"python"})),
|
|
80
|
+
"net.latency": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
|
|
81
|
+
"net.packet_loss": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
|
|
82
|
+
"net.bandwidth": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
|
|
83
|
+
"net.partition": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
|
|
84
|
+
"net.reorder": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
|
|
85
|
+
"net.duplicate": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
|
|
86
|
+
"dependency.timeout": FaultRequirement(bins=frozenset({"tc"}), caps=frozenset({"NET_ADMIN"})),
|
|
87
|
+
"net.load": FaultRequirement(bins=frozenset({"k6"}), host=True),
|
|
88
|
+
"http.error_injection": FaultRequirement(
|
|
89
|
+
bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
|
|
90
|
+
),
|
|
91
|
+
"net.connection_reset": FaultRequirement(
|
|
92
|
+
bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
|
|
93
|
+
),
|
|
94
|
+
"net.connection_refuse": FaultRequirement(
|
|
95
|
+
bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
|
|
96
|
+
),
|
|
97
|
+
"db.slow_query": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
|
|
98
|
+
"db.query_error": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
|
|
99
|
+
"dns.timeout": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
|
|
100
|
+
"dns.servfail": FaultRequirement(bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})),
|
|
101
|
+
"tls.handshake_failure": FaultRequirement(
|
|
102
|
+
bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
|
|
103
|
+
),
|
|
104
|
+
"dependency.block": FaultRequirement(
|
|
105
|
+
bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
|
|
106
|
+
),
|
|
107
|
+
"dependency.flap": FaultRequirement(
|
|
108
|
+
bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
|
|
109
|
+
),
|
|
110
|
+
"dependency.connection_refuse": FaultRequirement(
|
|
111
|
+
bins=frozenset({"iptables"}), caps=frozenset({"NET_ADMIN"})
|
|
112
|
+
),
|
|
113
|
+
"dns.resolve_delay": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
|
|
114
|
+
"dns.nxdomain": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
|
|
115
|
+
"tls.certificate_expired": FaultRequirement(bins=frozenset({"sh"}), need_root=True),
|
|
116
|
+
"clock.skew": FaultRequirement(bins=frozenset({"date"}), caps=frozenset({"SYS_TIME"})),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Engine-addressed faults (kill/stop/start the runtime itself, or drive the
|
|
120
|
+
# host engine: ``update --cpus``, restart cadence): no in-image tooling is
|
|
121
|
+
# needed, so they are never gated on container binaries — the engine being
|
|
122
|
+
# reachable and the container being resolvable is sufficient.
|
|
123
|
+
_ENGINE_FAULTS = frozenset(
|
|
124
|
+
{
|
|
125
|
+
"container.kill",
|
|
126
|
+
"container.restart",
|
|
127
|
+
"container.pause",
|
|
128
|
+
"process.crash_loop",
|
|
129
|
+
"cpu.throttle",
|
|
130
|
+
"node.service_stop",
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
#: Faults whose recovery probe cannot see the live perturbation. The generic
|
|
135
|
+
#: "recovery probe inverted during the window" observation is therefore
|
|
136
|
+
#: inconclusive for these families, and must not be read as "no impact".
|
|
137
|
+
#: Terminate faults (process.stop/kill) blind the probe too: the post-kill
|
|
138
|
+
#: world (process gone) is indistinguishable from the post-recovery world, so
|
|
139
|
+
#: the observation is inconclusive rather than "no impact".
|
|
140
|
+
OBSERVATION_BLIND: frozenset[str] = frozenset({"proc.pause", "process.stop", "process.kill"})
|
|
141
|
+
|
|
142
|
+
#: Package-manager binaries probed so the CLI can tell the user — and offer to
|
|
143
|
+
#: run — the right ``<pm> install`` command when a fault's tooling is missing.
|
|
144
|
+
#: ``package_manager()`` resolves the first present manager (priority order).
|
|
145
|
+
_PACKAGE_MANAGERS = ("apt-get", "apk", "dnf", "yum", "microdnf", "zypper")
|
|
146
|
+
#: In-container binaries a fault family may need. ``k6`` is deliberately absent:
|
|
147
|
+
#: ``net.load`` drives the container from the drill host, so it is gated there
|
|
148
|
+
#: (``host=True``) and never shows up in container dependency management.
|
|
149
|
+
_PROBE_BINS: tuple[str, ...] = (
|
|
150
|
+
"kill",
|
|
151
|
+
"tc",
|
|
152
|
+
"iptables",
|
|
153
|
+
"python",
|
|
154
|
+
"python3",
|
|
155
|
+
"date",
|
|
156
|
+
"sh",
|
|
157
|
+
*_PACKAGE_MANAGERS,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
#: Host-side tooling required by some fault family, checked once via
|
|
161
|
+
#: ``shutil.which`` (cached). Container ``mayhem dependency`` only reports these,
|
|
162
|
+
#: never installs them.
|
|
163
|
+
_HOST_TOOL_BINS: tuple[str, ...] = ("k6",)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class ContainerRuntime:
|
|
168
|
+
"""Probed, read-only view of one live container."""
|
|
169
|
+
|
|
170
|
+
container: str
|
|
171
|
+
engine: str
|
|
172
|
+
bins: dict[str, bool]
|
|
173
|
+
uid: int | None
|
|
174
|
+
cap_eff: int = 0
|
|
175
|
+
|
|
176
|
+
def has_bin(self, name: str) -> bool:
|
|
177
|
+
if name == "python":
|
|
178
|
+
return bool(self.bins.get("python") or self.bins.get("python3"))
|
|
179
|
+
return bool(self.bins.get(name))
|
|
180
|
+
|
|
181
|
+
def has_cap(self, name: str) -> bool:
|
|
182
|
+
bit = _CAP_BITS.get(name)
|
|
183
|
+
return bit is not None and bool(self.cap_eff & (1 << bit))
|
|
184
|
+
|
|
185
|
+
def package_manager(self) -> str | None:
|
|
186
|
+
"""First package-manager binary present, in priority order.
|
|
187
|
+
|
|
188
|
+
``apt-get`` (Debian/Ubuntu), ``apk`` (Alpine), ``dnf`` (Fedora/RHEL9),
|
|
189
|
+
``yum`` (RHEL7/8), ``microdnf`` (minimal RHEL/UBI), ``zypper`` (SUSE).
|
|
190
|
+
"""
|
|
191
|
+
for pm in _PACKAGE_MANAGERS:
|
|
192
|
+
if self.has_bin(pm):
|
|
193
|
+
return pm
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_PROBE_SH = (
|
|
198
|
+
"printf 'BINS'"
|
|
199
|
+
+ "".join(
|
|
200
|
+
f"; printf ' {b}:%s' \"$(command -v {b} >/dev/null 2>&1 && echo 1 || echo 0)\""
|
|
201
|
+
for b in _PROBE_BINS
|
|
202
|
+
)
|
|
203
|
+
+ "; echo; printf 'UID %s\\n' \"$(id -u 2>/dev/null || echo -1)\";"
|
|
204
|
+
+ " printf 'CAPEFF %s\\n' \"$(awk '/CapEff/{print $2}' /proc/1/status 2>/dev/null || echo 0)\""
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
_BINS_RE = re.compile(r"BINS((?:\s+[a-z0-9-]+:[01])+)")
|
|
208
|
+
_UID_RE = re.compile(r"UID (\d+)")
|
|
209
|
+
_CAPEFF_RE = re.compile(r"CAPEFF ([0-9a-fA-F]+)")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def probe_container_runtime(
|
|
213
|
+
engine: str, container: str, timeout_s: int = 10
|
|
214
|
+
) -> ContainerRuntime | None:
|
|
215
|
+
"""One read-only ``engine exec`` returning the container's runtime surface.
|
|
216
|
+
|
|
217
|
+
Returns ``None`` when the engine or container is unreachable — the caller
|
|
218
|
+
treats an unreachable runtime as "cannot prove inert", never as a pass.
|
|
219
|
+
"""
|
|
220
|
+
try:
|
|
221
|
+
result = run_tool(
|
|
222
|
+
[engine, "exec", container, "sh", "-c", _PROBE_SH],
|
|
223
|
+
timeout_s=timeout_s,
|
|
224
|
+
)
|
|
225
|
+
except ToolError:
|
|
226
|
+
return None
|
|
227
|
+
if not result.succeeded:
|
|
228
|
+
return None
|
|
229
|
+
return parse_runtime_output(engine, container, result.stdout)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def parse_runtime_output(engine: str, container: str, text: str) -> ContainerRuntime | None:
|
|
233
|
+
m = _BINS_RE.search(text)
|
|
234
|
+
if m is None:
|
|
235
|
+
return None
|
|
236
|
+
bins = {pair.split(":")[0]: pair.split(":")[1] == "1" for pair in m.group(1).split()}
|
|
237
|
+
uid_m = _UID_RE.search(text)
|
|
238
|
+
cap_m = _CAPEFF_RE.search(text)
|
|
239
|
+
cap_eff = int(cap_m.group(1), 16) if cap_m else 0
|
|
240
|
+
uid = int(uid_m.group(1)) if uid_m else None
|
|
241
|
+
return ContainerRuntime(container=container, engine=engine, bins=bins, uid=uid, cap_eff=cap_eff)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@functools.lru_cache(maxsize=32)
|
|
245
|
+
def _host_bin_present(name: str) -> bool:
|
|
246
|
+
"""Cached host ``which`` check (host tooling, e.g. k6 for net.load)."""
|
|
247
|
+
return shutil.which(name) is not None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def host_tooling_gaps(plan: ExecutionPlan) -> list[str]:
|
|
251
|
+
"""Host-side binaries the plan needs but the drill host lacks (e.g. k6).
|
|
252
|
+
|
|
253
|
+
Container ``mayhem dependency`` reports these but never installs them —
|
|
254
|
+
the load generator lives on the host, not in a distro package.
|
|
255
|
+
"""
|
|
256
|
+
needed: set[str] = set()
|
|
257
|
+
for step in plan.steps:
|
|
258
|
+
fault = step.fault
|
|
259
|
+
if fault is None:
|
|
260
|
+
continue
|
|
261
|
+
requirement = REQUIREMENTS.get(fault.fault_id)
|
|
262
|
+
if requirement is not None and requirement.host:
|
|
263
|
+
needed.update(requirement.bins)
|
|
264
|
+
return sorted(b for b in needed if not _host_bin_present(b))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass(frozen=True)
|
|
268
|
+
class GateVerdict:
|
|
269
|
+
"""Per fault/container: can the injection physically take effect?"""
|
|
270
|
+
|
|
271
|
+
fault_id: str
|
|
272
|
+
container: str
|
|
273
|
+
impact_possible: bool
|
|
274
|
+
missing: tuple[str, ...] = ()
|
|
275
|
+
probed: bool = True
|
|
276
|
+
host: bool = False
|
|
277
|
+
note: str = ""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@functools.lru_cache(maxsize=8)
|
|
281
|
+
def _engine_is_rootless(engine: str) -> bool:
|
|
282
|
+
"""True when the container engine runs rootless (userns).
|
|
283
|
+
|
|
284
|
+
A rootless engine maps container roots onto unprivileged host uids inside a
|
|
285
|
+
user namespace. That makes two fault families *physically* impossible no
|
|
286
|
+
matter what the container reports:
|
|
287
|
+
|
|
288
|
+
* ``CAP_SYS_TIME`` in the container's ``CapEff`` is only meaningful inside
|
|
289
|
+
its userns. Setting the host-global ``CLOCK_REALTIME`` (``clock.skew`` →
|
|
290
|
+
``date -u -s``) needs ``CAP_SYS_TIME`` in the *initial* user namespace,
|
|
291
|
+
which a rootless engine never grants — time namespaces do not virtualize
|
|
292
|
+
the realtime clock. The kernel returns ``EPERM`` (``login: cannot set
|
|
293
|
+
date: Operation not permitted``) even when ``CAP_SYS_TIME`` is set.
|
|
294
|
+
|
|
295
|
+
The container has no way to fake a $(date) read; the fault is inert by
|
|
296
|
+
construction. Probing ``CapEff`` is not enough: the bit reads as present.
|
|
297
|
+
|
|
298
|
+
Detection reuses the engine's own ``info`` output. Best-effort — any
|
|
299
|
+
failure (engine missing, odd output) returns False (assume rootful), so a
|
|
300
|
+
detection hiccup never *blocks* a fault that could work.
|
|
301
|
+
"""
|
|
302
|
+
try:
|
|
303
|
+
result = subprocess.run(
|
|
304
|
+
[engine, "info", "--format", "json"],
|
|
305
|
+
capture_output=True,
|
|
306
|
+
text=True,
|
|
307
|
+
timeout=5,
|
|
308
|
+
check=False,
|
|
309
|
+
)
|
|
310
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
311
|
+
return False
|
|
312
|
+
try:
|
|
313
|
+
info = json.loads(result.stdout)
|
|
314
|
+
except (json.JSONDecodeError, ValueError):
|
|
315
|
+
return False
|
|
316
|
+
host = info.get("host") or {}
|
|
317
|
+
security = host.get("security") or {}
|
|
318
|
+
if isinstance(security, dict) and "rootless" in security:
|
|
319
|
+
return bool(security["rootless"])
|
|
320
|
+
# docker: rootless mode surfaces as a userns/rootless security option
|
|
321
|
+
# (podman nests it under host.security; docker keeps access at top level).
|
|
322
|
+
options = host.get("securityOptions") or info.get("SecurityOptions") or []
|
|
323
|
+
return "userns" in " ".join(options) or "rootless" in " ".join(options)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _gate_sys_time_for_rootless(
|
|
327
|
+
fault_id: str, container: str, engine: str, requirement: FaultRequirement
|
|
328
|
+
) -> GateVerdict | None:
|
|
329
|
+
"""Rootless engines cannot set CLOCK_REALTIME: reject SYS_TIME faults.
|
|
330
|
+
|
|
331
|
+
The container reports ``CAP_SYS_TIME`` (its userns grants the bit) but the
|
|
332
|
+
realtime clock is host-global — setting it demands host-root privilege a
|
|
333
|
+
rootless engine never provides. Treat such a fault as inert (probed) so the
|
|
334
|
+
execution layer bypasses it fail-safe instead of running a doomed inject.
|
|
335
|
+
Returns ``None`` when the fault is unaffected by rootlessness.
|
|
336
|
+
"""
|
|
337
|
+
if "SYS_TIME" not in requirement.caps or not _engine_is_rootless(engine):
|
|
338
|
+
return None
|
|
339
|
+
return GateVerdict(
|
|
340
|
+
fault_id,
|
|
341
|
+
container,
|
|
342
|
+
False,
|
|
343
|
+
probed=True,
|
|
344
|
+
note=(
|
|
345
|
+
"rootless engine: container CAP_SYS_TIME is namespaced; "
|
|
346
|
+
"setting the host CLOCK_REALTIME is denied (EPERM)"
|
|
347
|
+
),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def gate_fault(
|
|
352
|
+
fault_id: str,
|
|
353
|
+
container: str,
|
|
354
|
+
engine: str,
|
|
355
|
+
runtime: ContainerRuntime | None = None,
|
|
356
|
+
) -> GateVerdict:
|
|
357
|
+
"""Verdict for one fault against one (optionally pre-probed) container."""
|
|
358
|
+
if fault_id in _ENGINE_FAULTS:
|
|
359
|
+
return GateVerdict(fault_id, container, True, note="engine-addressed fault")
|
|
360
|
+
requirement = REQUIREMENTS.get(fault_id)
|
|
361
|
+
if requirement is None:
|
|
362
|
+
return GateVerdict(fault_id, container, True, note="no in-image tooling required")
|
|
363
|
+
if requirement.host:
|
|
364
|
+
return _gate_host_requirement(fault_id, container, requirement)
|
|
365
|
+
rootless_gate = _gate_sys_time_for_rootless(fault_id, container, engine, requirement)
|
|
366
|
+
if rootless_gate is not None:
|
|
367
|
+
return rootless_gate
|
|
368
|
+
run = runtime if runtime is not None else probe_container_runtime(engine, container)
|
|
369
|
+
if run is None:
|
|
370
|
+
return GateVerdict(
|
|
371
|
+
fault_id,
|
|
372
|
+
container,
|
|
373
|
+
False,
|
|
374
|
+
probed=False,
|
|
375
|
+
note="runtime unreachable — cannot prove impact possible",
|
|
376
|
+
)
|
|
377
|
+
missing: list[str] = []
|
|
378
|
+
for req_bin in sorted(requirement.bins):
|
|
379
|
+
if not run.has_bin(req_bin):
|
|
380
|
+
missing.append(f"bin:{req_bin}")
|
|
381
|
+
for req_cap in sorted(requirement.caps):
|
|
382
|
+
if not run.has_cap(req_cap):
|
|
383
|
+
missing.append(f"cap:{req_cap}")
|
|
384
|
+
if requirement.need_root and run.uid not in (0, None):
|
|
385
|
+
missing.append("uid(0)")
|
|
386
|
+
possible = not missing
|
|
387
|
+
note = "" if possible else f"missing {', '.join(missing)}"
|
|
388
|
+
return GateVerdict(fault_id, container, possible, tuple(missing), note=note)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _gate_host_requirement(
|
|
392
|
+
fault_id: str, container: str, requirement: FaultRequirement
|
|
393
|
+
) -> GateVerdict:
|
|
394
|
+
"""Host-addressed requirements (e.g. net.load → k6 on the drill host).
|
|
395
|
+
|
|
396
|
+
The container is irrelevant here: whether the fault perturbs depends on
|
|
397
|
+
host-side attack tooling. No container probe runs, so nothing about this
|
|
398
|
+
verdict can leak into container dependency planning.
|
|
399
|
+
"""
|
|
400
|
+
missing = [f"bin:{b}" for b in sorted(requirement.bins) if not _host_bin_present(b)]
|
|
401
|
+
possible = not missing
|
|
402
|
+
note = "" if possible else f"missing host tooling: {', '.join(missing)}"
|
|
403
|
+
return GateVerdict(fault_id, container, possible, tuple(missing), host=True, note=note)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _container_for(graph: TopologyGraph, fault: PlannedFault) -> str | None:
|
|
407
|
+
"""First resolvable container the fault would inject into (for gating)."""
|
|
408
|
+
for target in fault.targets:
|
|
409
|
+
for node_id in target.node_ids:
|
|
410
|
+
node = graph.by_id(node_id)
|
|
411
|
+
if node is None:
|
|
412
|
+
continue
|
|
413
|
+
name = getattr(node, "container_name", None)
|
|
414
|
+
if isinstance(name, str) and name:
|
|
415
|
+
return name
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def scan_plan_faults(
|
|
420
|
+
plan: ExecutionPlan, graph: TopologyGraph, engine: str
|
|
421
|
+
) -> tuple[list[GateVerdict], bool]:
|
|
422
|
+
"""Gate every fault in ``plan`` against its live container.
|
|
423
|
+
|
|
424
|
+
Returns ``(verdicts, engine_probed)``. ``engine_probed`` is False when the
|
|
425
|
+
engine could not be reached at all; callers must not fail the run on that.
|
|
426
|
+
``verdicts`` holds one entry per (fault, container) pair, with
|
|
427
|
+
``impact_possible=False`` only when the live container proved the fault's
|
|
428
|
+
tooling is absent.
|
|
429
|
+
"""
|
|
430
|
+
seen: dict[tuple[str, str], ContainerRuntime | None] = {}
|
|
431
|
+
verdicts: list[GateVerdict] = []
|
|
432
|
+
engine_probed = False
|
|
433
|
+
for step in plan.steps:
|
|
434
|
+
fault = step.fault
|
|
435
|
+
if fault is None:
|
|
436
|
+
continue
|
|
437
|
+
container = _container_for(graph, fault)
|
|
438
|
+
if container is None:
|
|
439
|
+
verdicts.append(
|
|
440
|
+
GateVerdict(
|
|
441
|
+
fault.fault_id,
|
|
442
|
+
"?",
|
|
443
|
+
False,
|
|
444
|
+
probed=False,
|
|
445
|
+
note="no container target resolved — nothing to gate on",
|
|
446
|
+
)
|
|
447
|
+
)
|
|
448
|
+
continue
|
|
449
|
+
requirement = REQUIREMENTS.get(fault.fault_id)
|
|
450
|
+
if requirement is not None and requirement.host:
|
|
451
|
+
# Host-addressed fault (net.load → k6): the container runtime is
|
|
452
|
+
# irrelevant, so we never probe it and never count it as engine
|
|
453
|
+
# reachability.
|
|
454
|
+
verdicts.append(gate_fault(fault.fault_id, container, engine))
|
|
455
|
+
continue
|
|
456
|
+
key = (engine, container)
|
|
457
|
+
if key in seen:
|
|
458
|
+
runtime = seen[key]
|
|
459
|
+
else:
|
|
460
|
+
runtime = probe_container_runtime(engine, container)
|
|
461
|
+
seen[key] = runtime
|
|
462
|
+
if runtime is not None:
|
|
463
|
+
engine_probed = True
|
|
464
|
+
verdicts.append(gate_fault(fault.fault_id, container, engine, runtime))
|
|
465
|
+
return verdicts, engine_probed
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def bypass_from_verdicts(
|
|
469
|
+
verdicts: Sequence[GateVerdict],
|
|
470
|
+
) -> dict[tuple[str, str], str]:
|
|
471
|
+
"""Verified-inert injections → ``{(fault_id, container): reason}``.
|
|
472
|
+
|
|
473
|
+
Fail-safe contract: a fault whose tooling is **proven absent** in its
|
|
474
|
+
target container is bypassed at execution time (logged as ``bypass due to
|
|
475
|
+
<reason>``) instead of aborting the whole run. Only ``probed`` verdicts
|
|
476
|
+
count — an unreachable runtime cannot be proven inert, so those faults are
|
|
477
|
+
still attempted.
|
|
478
|
+
"""
|
|
479
|
+
return {
|
|
480
|
+
(v.fault_id, v.container): v.note or v.fault_id
|
|
481
|
+
for v in verdicts
|
|
482
|
+
if v.probed and not v.impact_possible
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ── Missing-tooling remediation ─────────────────────────────────────────────
|
|
487
|
+
# The gate marks a fault inert when its in-image tooling is absent. The bins a
|
|
488
|
+
# fault family needs map onto distro packages; the right <pm> is detected from
|
|
489
|
+
# the live container (package_manager()). Bare-metal knowledge:
|
|
490
|
+
# python → python3 (the probe treats python|python3 as one requirement)
|
|
491
|
+
# tc → iproute2 (Debian/Alpine/SUSE) / iproute (RHEL-family)
|
|
492
|
+
# kill → procps(-ng) (kill(1) lives in the process-utils package)
|
|
493
|
+
# date → coreutils
|
|
494
|
+
# sh → dash / busybox / bash depending on the family
|
|
495
|
+
# ``k6`` ships in no distro repo (net.load needs the Grafana k6 binary), so it
|
|
496
|
+
# is reported as a manual step, never auto-installed.
|
|
497
|
+
_PM_PACKAGES: dict[str, dict[str, str]] = {
|
|
498
|
+
"python": dict.fromkeys(_PACKAGE_MANAGERS, "python3"),
|
|
499
|
+
"tc": {
|
|
500
|
+
"apt-get": "iproute2",
|
|
501
|
+
"apk": "iproute2",
|
|
502
|
+
"dnf": "iproute",
|
|
503
|
+
"yum": "iproute",
|
|
504
|
+
"microdnf": "iproute",
|
|
505
|
+
"zypper": "iproute2",
|
|
506
|
+
},
|
|
507
|
+
"iptables": dict.fromkeys(_PACKAGE_MANAGERS, "iptables"),
|
|
508
|
+
"kill": {
|
|
509
|
+
"apt-get": "procps",
|
|
510
|
+
"apk": "procps",
|
|
511
|
+
"dnf": "procps-ng",
|
|
512
|
+
"yum": "procps-ng",
|
|
513
|
+
"microdnf": "procps-ng",
|
|
514
|
+
"zypper": "procps",
|
|
515
|
+
},
|
|
516
|
+
"date": dict.fromkeys(_PACKAGE_MANAGERS, "coreutils"),
|
|
517
|
+
"sh": {
|
|
518
|
+
"apt-get": "dash",
|
|
519
|
+
"apk": "busybox",
|
|
520
|
+
"dnf": "bash",
|
|
521
|
+
"yum": "bash",
|
|
522
|
+
"microdnf": "bash",
|
|
523
|
+
"zypper": "bash",
|
|
524
|
+
},
|
|
525
|
+
}
|
|
526
|
+
#: Bins whose package cannot be installed from a distro repo. Reported as a
|
|
527
|
+
#: manual step with guidance instead of being auto-installed. (Host-side tools
|
|
528
|
+
#: like k6 are not listed here — they are covered by ``host=True`` gating and
|
|
529
|
+
#: reported via ``host_tooling_gaps()``, never as container packages.)
|
|
530
|
+
_MANUAL_BINS: dict[str, str] = {}
|
|
531
|
+
|
|
532
|
+
#: Engine-manager → ``<pm> install`` sub-command shape. ``apt-get`` also needs
|
|
533
|
+
#: an ``update`` pass first (best-effort; a missing index fails loudly).
|
|
534
|
+
#: ``microdnf`` only exists in minimal RHEL-family images and is its own binary
|
|
535
|
+
#: (not a dnf flag).
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _install_argv(
|
|
539
|
+
engine: str, container: str, pm: str, packages: Sequence[str], *, as_root: bool
|
|
540
|
+
) -> list[list[str]]:
|
|
541
|
+
"""Exec argv list that installs ``packages`` inside ``container``.
|
|
542
|
+
|
|
543
|
+
Each inner list is one standalone ``engine exec`` invocation so the CLI can
|
|
544
|
+
report per-command results. ``as_root`` prefixes ``--user 0`` when the probe
|
|
545
|
+
showed the container's default user is non-root (package managers need
|
|
546
|
+
write access to system dirs).
|
|
547
|
+
"""
|
|
548
|
+
prefix = [engine, "exec", container]
|
|
549
|
+
if as_root:
|
|
550
|
+
prefix += ["--user", "0"]
|
|
551
|
+
if pm == "apt-get":
|
|
552
|
+
return [
|
|
553
|
+
[*prefix, "apt-get", "update"],
|
|
554
|
+
[*prefix, "apt-get", "install", "-y", *packages],
|
|
555
|
+
]
|
|
556
|
+
if pm == "apk":
|
|
557
|
+
return [[*prefix, "apk", "add", "--no-cache", *packages]]
|
|
558
|
+
if pm in ("dnf", "yum", "microdnf"):
|
|
559
|
+
return [[*prefix, pm, "install", "-y", *packages]]
|
|
560
|
+
if pm == "zypper":
|
|
561
|
+
return [[*prefix, "zypper", "-n", "install", *packages]]
|
|
562
|
+
return []
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
@dataclass(frozen=True)
|
|
566
|
+
class ContainerDependencyPlan:
|
|
567
|
+
"""Everything the CLI needs to restore a container's fault tooling."""
|
|
568
|
+
|
|
569
|
+
container: str
|
|
570
|
+
engine: str
|
|
571
|
+
pm: str | None
|
|
572
|
+
#: Installable package names, deduplicated and sorted ('' when pm is None).
|
|
573
|
+
packages: tuple[str, ...] = ()
|
|
574
|
+
#: The missing bin names those packages provide (verification probe targets).
|
|
575
|
+
bins: tuple[str, ...] = ()
|
|
576
|
+
#: Bins with no auto-installable package — printed as guidance
|
|
577
|
+
#: (e.g. a container with no package manager at all).
|
|
578
|
+
manual: tuple[str, ...] = ()
|
|
579
|
+
#: cap:* requirements the gate flagged — must be granted at runtime, e.g.
|
|
580
|
+
#: ``podman run --cap-add=NET_ADMIN``; never installable in-image.
|
|
581
|
+
caps_missing: tuple[str, ...] = ()
|
|
582
|
+
#: A gated fault also needs uid(0); package installs are attempted as root.
|
|
583
|
+
need_root: bool = False
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def installable(self) -> bool:
|
|
587
|
+
return bool(self.packages)
|
|
588
|
+
|
|
589
|
+
@property
|
|
590
|
+
def gaps_remain(self) -> bool:
|
|
591
|
+
return not (self.installable or self.manual or self.caps_missing or self.need_root)
|
|
592
|
+
|
|
593
|
+
def install_argv(self) -> list[list[str]]:
|
|
594
|
+
if self.pm is None or not self.packages:
|
|
595
|
+
return []
|
|
596
|
+
return _install_argv(
|
|
597
|
+
self.engine, self.container, self.pm, self.packages, as_root=self.need_root
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def dependency_plan(
|
|
602
|
+
plan: ExecutionPlan, graph: TopologyGraph, engine: str
|
|
603
|
+
) -> list[ContainerDependencyPlan]:
|
|
604
|
+
"""Union the missing tooling over every planned fault, per container.
|
|
605
|
+
|
|
606
|
+
One plan per container that hosts at least one gated-out fault. The plan
|
|
607
|
+
carries the detected package manager, the installable package list, and the
|
|
608
|
+
non-installable gaps (caps need a runtime flag, a container without a
|
|
609
|
+
package manager can only be re-provisioned at image build time, uid(0)
|
|
610
|
+
faults need a root exec). Host-addressed faults (``net.load`` → k6 on the
|
|
611
|
+
drill host) are outside container dependency management — see
|
|
612
|
+
``host_tooling_gaps()``. Containers that are healthy for every planned
|
|
613
|
+
fault — or unreachable — produce no entry.
|
|
614
|
+
"""
|
|
615
|
+
runtimes: dict[str, ContainerRuntime | None] = {}
|
|
616
|
+
missing_by: dict[str, set[str]] = {}
|
|
617
|
+
for step in plan.steps:
|
|
618
|
+
fault = step.fault
|
|
619
|
+
if fault is None:
|
|
620
|
+
continue
|
|
621
|
+
requirement = REQUIREMENTS.get(fault.fault_id)
|
|
622
|
+
if requirement is not None and requirement.host:
|
|
623
|
+
continue # host-addressed tooling (k6) — not a container dependency
|
|
624
|
+
container = _container_for(graph, fault)
|
|
625
|
+
if container is None:
|
|
626
|
+
continue
|
|
627
|
+
if container not in runtimes:
|
|
628
|
+
runtimes[container] = probe_container_runtime(engine, container)
|
|
629
|
+
run = runtimes[container]
|
|
630
|
+
if run is None:
|
|
631
|
+
continue # unreachable — cannot plan tooling for it
|
|
632
|
+
verdict = gate_fault(fault.fault_id, container, engine, run)
|
|
633
|
+
if verdict.impact_possible:
|
|
634
|
+
continue
|
|
635
|
+
missing_by.setdefault(container, set()).update(verdict.missing)
|
|
636
|
+
plans: list[ContainerDependencyPlan] = []
|
|
637
|
+
for container in sorted(missing_by):
|
|
638
|
+
run = runtimes[container]
|
|
639
|
+
missing = sorted(missing_by[container])
|
|
640
|
+
pm = run.package_manager() if run else None
|
|
641
|
+
packages: set[str] = set()
|
|
642
|
+
bin_map: dict[str, str] = {}
|
|
643
|
+
manual: list[str] = []
|
|
644
|
+
caps_missing = [m for m in missing if m.startswith("cap:")]
|
|
645
|
+
need_root = "uid(0)" in missing
|
|
646
|
+
for item in missing:
|
|
647
|
+
if not item.startswith("bin:"):
|
|
648
|
+
continue
|
|
649
|
+
bin_name = item.split(":", 1)[1]
|
|
650
|
+
mapping = _PM_PACKAGES.get(bin_name, {})
|
|
651
|
+
pkg = mapping.get(pm) if pm else None
|
|
652
|
+
if pkg is not None:
|
|
653
|
+
packages.add(pkg)
|
|
654
|
+
bin_map[bin_name] = pkg
|
|
655
|
+
continue
|
|
656
|
+
manual.append(bin_name)
|
|
657
|
+
plans.append(
|
|
658
|
+
ContainerDependencyPlan(
|
|
659
|
+
container=container,
|
|
660
|
+
engine=engine,
|
|
661
|
+
pm=pm,
|
|
662
|
+
packages=tuple(sorted(packages)),
|
|
663
|
+
bins=tuple(sorted(bin_map)),
|
|
664
|
+
manual=tuple(sorted(set(manual))),
|
|
665
|
+
caps_missing=tuple(caps_missing),
|
|
666
|
+
need_root=need_root,
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
return plans
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@dataclass(frozen=True)
|
|
673
|
+
class ContainerCompilePlan:
|
|
674
|
+
"""Offline (no runtime probe) tooling a compose service must carry.
|
|
675
|
+
|
|
676
|
+
Produced by ``compile_requirements``: the union of every fault family's
|
|
677
|
+
requirements for the container across the whole drill plan. Unlike
|
|
678
|
+
``ContainerDependencyPlan`` this is state-independent — it is the join of
|
|
679
|
+
the plan, not a diff against a live container. Host-addressed tooling
|
|
680
|
+
(``net.load`` → k6) never lands here.
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
container: str
|
|
684
|
+
#: Bins the planned faults require *and* that map to distro packages.
|
|
685
|
+
bins: tuple[str, ...] = ()
|
|
686
|
+
#: Capability names the service must be started with (bare, e.g.
|
|
687
|
+
#: ``"NET_ADMIN"`` — maps straight onto compose ``cap_add:``).
|
|
688
|
+
caps: tuple[str, ...] = ()
|
|
689
|
+
#: Bins with no mapped distro package — reported, never compile-able.
|
|
690
|
+
manual: tuple[str, ...] = ()
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def compile_requirements(plan: ExecutionPlan, graph: TopologyGraph) -> list[ContainerCompilePlan]:
|
|
694
|
+
"""Union the tooling requirements per container over the whole plan.
|
|
695
|
+
|
|
696
|
+
The compose compiler needs the *requirement* set, not the diff against a
|
|
697
|
+
possibly-unstarted stack: the generated ``docker-compose.mayhem.yml`` must
|
|
698
|
+
carry the tooling before any container runs. Host-addressed faults are
|
|
699
|
+
skipped — they can never be compiled into a service definition.
|
|
700
|
+
"""
|
|
701
|
+
bins_by: dict[str, set[str]] = {}
|
|
702
|
+
caps_by: dict[str, set[str]] = {}
|
|
703
|
+
manual_by: dict[str, set[str]] = {}
|
|
704
|
+
for step in plan.steps:
|
|
705
|
+
fault = step.fault
|
|
706
|
+
if fault is None:
|
|
707
|
+
continue
|
|
708
|
+
requirement = REQUIREMENTS.get(fault.fault_id)
|
|
709
|
+
if requirement is None or requirement.host:
|
|
710
|
+
continue
|
|
711
|
+
container = _container_for(graph, fault)
|
|
712
|
+
if container is None:
|
|
713
|
+
continue
|
|
714
|
+
manual = {b for b in requirement.bins if b not in _PM_PACKAGES}
|
|
715
|
+
bins = {b for b in requirement.bins if b in _PM_PACKAGES}
|
|
716
|
+
bins_by.setdefault(container, set()).update(bins)
|
|
717
|
+
manual_by.setdefault(container, set()).update(manual)
|
|
718
|
+
caps_by.setdefault(container, set()).update(requirement.caps)
|
|
719
|
+
plans: list[ContainerCompilePlan] = []
|
|
720
|
+
for container in sorted(set(bins_by) | set(caps_by) | set(manual_by)):
|
|
721
|
+
plans.append(
|
|
722
|
+
ContainerCompilePlan(
|
|
723
|
+
container=container,
|
|
724
|
+
bins=tuple(sorted(bins_by.get(container, ()))),
|
|
725
|
+
caps=tuple(sorted(caps_by.get(container, ()))),
|
|
726
|
+
manual=tuple(sorted(manual_by.get(container, ()))),
|
|
727
|
+
)
|
|
728
|
+
)
|
|
729
|
+
return plans
|