composable-data-stack 0.4.0__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.
- cli/__init__.py +1 -0
- cli/diagnostics.py +13 -0
- cli/graph.py +58 -0
- cli/image_updates.py +326 -0
- cli/image_verification.py +484 -0
- cli/loader.py +180 -0
- cli/main.py +1656 -0
- cli/overlay.py +239 -0
- cli/planner.py +618 -0
- cli/preflight.py +418 -0
- cli/renderer.py +791 -0
- cli/resolver.py +28 -0
- cli/resources/__init__.py +1 -0
- cli/resources/rule-schema.json +274 -0
- cli/resources/rule-set.json +919 -0
- cli/secrets.py +169 -0
- cli/security.py +768 -0
- cli/security_common.py +41 -0
- cli/state.py +112 -0
- cli/up_runner.py +257 -0
- cli/validator.py +570 -0
- composable_data_stack-0.4.0.dist-info/METADATA +872 -0
- composable_data_stack-0.4.0.dist-info/RECORD +27 -0
- composable_data_stack-0.4.0.dist-info/WHEEL +5 -0
- composable_data_stack-0.4.0.dist-info/entry_points.txt +2 -0
- composable_data_stack-0.4.0.dist-info/licenses/LICENSE +201 -0
- composable_data_stack-0.4.0.dist-info/top_level.txt +1 -0
cli/preflight.py
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess # nosec B404
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from .image_verification import default_fixture_path, load_policy_from_env, verify_images
|
|
15
|
+
from .security_common import SECRET_KEY_SEGMENT_RE, infer_profile_class
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_ENV_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)([^}]*)\}")
|
|
19
|
+
# Docker publishes ports on every IPv4 interface when no host IP is specified.
|
|
20
|
+
# Preflight uses this only for a short-lived availability probe.
|
|
21
|
+
_DOCKER_WILDCARD_IPV4 = "0.0.0.0" # nosec B104
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class PreflightCheck:
|
|
26
|
+
status: str
|
|
27
|
+
name: str
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_preflight(
|
|
32
|
+
plan: dict[str, Any],
|
|
33
|
+
compose_yaml: str,
|
|
34
|
+
env_file: Path,
|
|
35
|
+
) -> list[PreflightCheck]:
|
|
36
|
+
checks: list[PreflightCheck] = []
|
|
37
|
+
checks.extend(_check_runtime(plan.get("runtime", {})))
|
|
38
|
+
checks.extend(_check_environment(compose_yaml, env_file))
|
|
39
|
+
checks.extend(_check_ports(compose_yaml))
|
|
40
|
+
checks.extend(_check_images(plan, compose_yaml))
|
|
41
|
+
return checks
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def preflight_passed(checks: list[PreflightCheck]) -> bool:
|
|
45
|
+
return not any(check.status == "FAIL" for check in checks)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_runtime(runtime: dict[str, Any]) -> list[PreflightCheck]:
|
|
49
|
+
runtime_type = runtime.get("type")
|
|
50
|
+
if runtime_type != "docker-compose":
|
|
51
|
+
return [
|
|
52
|
+
PreflightCheck(
|
|
53
|
+
"FAIL",
|
|
54
|
+
"runtime",
|
|
55
|
+
f'Unsupported runtime type "{runtime_type or "<missing>"}".',
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
docker_path = shutil.which("docker")
|
|
60
|
+
if docker_path is None:
|
|
61
|
+
return [
|
|
62
|
+
PreflightCheck(
|
|
63
|
+
"FAIL",
|
|
64
|
+
"runtime.cli",
|
|
65
|
+
"Docker CLI was not found. Install Docker and add it to PATH.",
|
|
66
|
+
)
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
checks = [
|
|
70
|
+
PreflightCheck("PASS", "runtime.cli", f"Docker CLI found at {docker_path}.")
|
|
71
|
+
]
|
|
72
|
+
checks.append(
|
|
73
|
+
_run_runtime_command(
|
|
74
|
+
["docker", "compose", "version"],
|
|
75
|
+
"runtime.compose",
|
|
76
|
+
"Docker Compose is available.",
|
|
77
|
+
"Docker Compose is unavailable. Install the Compose plugin.",
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
checks.append(
|
|
81
|
+
_run_runtime_command(
|
|
82
|
+
["docker", "info"],
|
|
83
|
+
"runtime.daemon",
|
|
84
|
+
"Docker daemon is reachable.",
|
|
85
|
+
"Docker daemon is unreachable. Start Docker and verify access with `docker info`.",
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
return checks
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _run_runtime_command(
|
|
92
|
+
command: list[str],
|
|
93
|
+
name: str,
|
|
94
|
+
success_message: str,
|
|
95
|
+
failure_message: str,
|
|
96
|
+
) -> PreflightCheck:
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run( # nosec B603
|
|
99
|
+
command,
|
|
100
|
+
capture_output=True,
|
|
101
|
+
text=True,
|
|
102
|
+
timeout=15,
|
|
103
|
+
)
|
|
104
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
105
|
+
return PreflightCheck("FAIL", name, failure_message)
|
|
106
|
+
|
|
107
|
+
if result.returncode != 0:
|
|
108
|
+
return PreflightCheck("FAIL", name, failure_message)
|
|
109
|
+
return PreflightCheck("PASS", name, success_message)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _check_environment(compose_yaml: str, env_file: Path) -> list[PreflightCheck]:
|
|
113
|
+
matches = list(_ENV_REFERENCE.finditer(compose_yaml))
|
|
114
|
+
required_names = {m.group(1) for m in matches if _reference_is_required(m.group(2))}
|
|
115
|
+
insecure_defaults = sorted({
|
|
116
|
+
m.group(1)
|
|
117
|
+
for m in matches
|
|
118
|
+
if _reference_has_insecure_default(m.group(1), m.group(2))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
if not required_names and not insecure_defaults:
|
|
122
|
+
return [
|
|
123
|
+
PreflightCheck(
|
|
124
|
+
"PASS",
|
|
125
|
+
"environment",
|
|
126
|
+
"No required runtime environment values were declared.",
|
|
127
|
+
)
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
checks: list[PreflightCheck] = []
|
|
131
|
+
if required_names:
|
|
132
|
+
try:
|
|
133
|
+
values = _load_env_values(env_file)
|
|
134
|
+
except OSError:
|
|
135
|
+
checks.append(
|
|
136
|
+
PreflightCheck(
|
|
137
|
+
"FAIL",
|
|
138
|
+
"environment",
|
|
139
|
+
f"Environment file could not be read: {env_file}.",
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
else:
|
|
143
|
+
missing = sorted(
|
|
144
|
+
name for name in required_names if not values.get(name, "").strip()
|
|
145
|
+
)
|
|
146
|
+
if missing:
|
|
147
|
+
checks.extend(
|
|
148
|
+
PreflightCheck(
|
|
149
|
+
"FAIL",
|
|
150
|
+
f"environment.{name}",
|
|
151
|
+
f'Required environment value "{name}" is missing or empty.',
|
|
152
|
+
)
|
|
153
|
+
for name in missing
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
placeholders = sorted(
|
|
157
|
+
name
|
|
158
|
+
for name in required_names
|
|
159
|
+
if values[name].strip().lower() in {"change-me", "changeme"}
|
|
160
|
+
or values[name].strip().lower().startswith("change-me-")
|
|
161
|
+
)
|
|
162
|
+
checks.append(
|
|
163
|
+
PreflightCheck(
|
|
164
|
+
"PASS",
|
|
165
|
+
"environment",
|
|
166
|
+
f"All {len(required_names)} required runtime environment values are set.",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
if placeholders:
|
|
170
|
+
checks.append(
|
|
171
|
+
PreflightCheck(
|
|
172
|
+
"WARN",
|
|
173
|
+
"environment.placeholders",
|
|
174
|
+
"Replace placeholder values for: "
|
|
175
|
+
+ ", ".join(placeholders)
|
|
176
|
+
+ ".",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
else:
|
|
180
|
+
checks.append(
|
|
181
|
+
PreflightCheck(
|
|
182
|
+
"PASS",
|
|
183
|
+
"environment",
|
|
184
|
+
"No required runtime environment values were declared.",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
if insecure_defaults:
|
|
188
|
+
checks.append(
|
|
189
|
+
PreflightCheck(
|
|
190
|
+
"WARN",
|
|
191
|
+
"environment.insecure-defaults",
|
|
192
|
+
"Replace insecure hardcoded defaults for: " + ", ".join(insecure_defaults) + ".",
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
return checks
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _reference_is_required(suffix: str) -> bool:
|
|
199
|
+
return not suffix or suffix.startswith(":?") or suffix.startswith("?")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _reference_default_value(suffix: str) -> str | None:
|
|
203
|
+
if suffix.startswith(":-"):
|
|
204
|
+
default = suffix[2:]
|
|
205
|
+
elif suffix.startswith("-"):
|
|
206
|
+
default = suffix[1:]
|
|
207
|
+
else:
|
|
208
|
+
return None
|
|
209
|
+
return default or None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _reference_has_insecure_default(name: str, suffix: str) -> bool:
|
|
213
|
+
default = _reference_default_value(suffix)
|
|
214
|
+
if default is None or "$" in default:
|
|
215
|
+
return False
|
|
216
|
+
return bool(SECRET_KEY_SEGMENT_RE.search(name))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _check_images(plan: dict[str, Any], compose_yaml: str) -> list[PreflightCheck]:
|
|
220
|
+
"""
|
|
221
|
+
Enforce the CDS image policy (registry allowlist, digest pins, and, in
|
|
222
|
+
full mode, cosign-verified signatures and provenance attestations).
|
|
223
|
+
|
|
224
|
+
Disabled when the policy mode resolves to "off"; production profiles
|
|
225
|
+
default to "policy" so static supply-chain checks run by default.
|
|
226
|
+
"""
|
|
227
|
+
policy = load_policy_from_env(infer_profile_class(plan))
|
|
228
|
+
if policy.mode == "off":
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
findings = verify_images(compose_yaml, policy, fixture=default_fixture_path())
|
|
232
|
+
if not findings:
|
|
233
|
+
return [
|
|
234
|
+
PreflightCheck(
|
|
235
|
+
"PASS",
|
|
236
|
+
"images",
|
|
237
|
+
"All service images comply with the image verification policy.",
|
|
238
|
+
)
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
return [
|
|
242
|
+
PreflightCheck(
|
|
243
|
+
"FAIL",
|
|
244
|
+
f"images.{finding['path']}",
|
|
245
|
+
f"{finding['rule_id']}: {finding['message']}",
|
|
246
|
+
)
|
|
247
|
+
for finding in findings
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _load_env_values(env_file: Path) -> dict[str, str]:
|
|
252
|
+
values: dict[str, str] = {}
|
|
253
|
+
if env_file.exists():
|
|
254
|
+
with env_file.open(encoding="utf-8") as handle:
|
|
255
|
+
for line in handle:
|
|
256
|
+
stripped = line.strip()
|
|
257
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
258
|
+
continue
|
|
259
|
+
key, value = stripped.split("=", 1)
|
|
260
|
+
key = key.strip()
|
|
261
|
+
value = value.strip()
|
|
262
|
+
if (
|
|
263
|
+
len(value) >= 2
|
|
264
|
+
and value[0] == value[-1]
|
|
265
|
+
and value[0] in {'"', "'"}
|
|
266
|
+
):
|
|
267
|
+
value = value[1:-1]
|
|
268
|
+
if key:
|
|
269
|
+
values[key] = value
|
|
270
|
+
|
|
271
|
+
values.update(os.environ)
|
|
272
|
+
return values
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _check_ports(compose_yaml: str) -> list[PreflightCheck]:
|
|
276
|
+
compose = yaml.safe_load(compose_yaml) or {}
|
|
277
|
+
services = compose.get("services", {})
|
|
278
|
+
declared_ports: list[tuple[str, str, int, str]] = []
|
|
279
|
+
|
|
280
|
+
if isinstance(services, dict):
|
|
281
|
+
for service_name, service in services.items():
|
|
282
|
+
if not isinstance(service, dict):
|
|
283
|
+
continue
|
|
284
|
+
ports = service.get("ports", [])
|
|
285
|
+
if not isinstance(ports, list):
|
|
286
|
+
continue
|
|
287
|
+
for port_entry in ports:
|
|
288
|
+
for host, port, protocol in _published_ports(port_entry):
|
|
289
|
+
declared_ports.append(
|
|
290
|
+
(str(service_name), host, port, protocol)
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
if not declared_ports:
|
|
294
|
+
return [
|
|
295
|
+
PreflightCheck("PASS", "ports", "No host ports were declared.")
|
|
296
|
+
]
|
|
297
|
+
|
|
298
|
+
checks: list[PreflightCheck] = []
|
|
299
|
+
seen: list[tuple[str, int, str, str]] = []
|
|
300
|
+
for service_name, host, port, protocol in declared_ports:
|
|
301
|
+
conflict = next(
|
|
302
|
+
(
|
|
303
|
+
previous_service
|
|
304
|
+
for previous_host, previous_port, previous_protocol, previous_service in seen
|
|
305
|
+
if previous_port == port
|
|
306
|
+
and previous_protocol == protocol
|
|
307
|
+
and _hosts_overlap(previous_host, host)
|
|
308
|
+
),
|
|
309
|
+
None,
|
|
310
|
+
)
|
|
311
|
+
if conflict is not None:
|
|
312
|
+
checks.append(
|
|
313
|
+
PreflightCheck(
|
|
314
|
+
"FAIL",
|
|
315
|
+
f"ports.{service_name}",
|
|
316
|
+
f"Host port {host}:{port}/{protocol} conflicts with the port declared by {conflict}.",
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
continue
|
|
320
|
+
seen.append((host, port, protocol, service_name))
|
|
321
|
+
|
|
322
|
+
available = _port_is_available(host, port, protocol)
|
|
323
|
+
if available is None:
|
|
324
|
+
checks.append(
|
|
325
|
+
PreflightCheck(
|
|
326
|
+
"WARN",
|
|
327
|
+
f"ports.{service_name}",
|
|
328
|
+
f"Host port {host}:{port}/{protocol} uses an unsupported protocol and was not checked.",
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
elif available:
|
|
332
|
+
checks.append(
|
|
333
|
+
PreflightCheck(
|
|
334
|
+
"PASS",
|
|
335
|
+
f"ports.{service_name}",
|
|
336
|
+
f"Host port {host}:{port}/{protocol} is available.",
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
else:
|
|
340
|
+
checks.append(
|
|
341
|
+
PreflightCheck(
|
|
342
|
+
"FAIL",
|
|
343
|
+
f"ports.{service_name}",
|
|
344
|
+
f"Host port {host}:{port}/{protocol} is unavailable. Stop the process using it or change the profile port.",
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return checks
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _hosts_overlap(first: str, second: str) -> bool:
|
|
351
|
+
wildcard_hosts = {_DOCKER_WILDCARD_IPV4, "::", ""}
|
|
352
|
+
return first == second or first in wildcard_hosts or second in wildcard_hosts
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _published_ports(port_entry: Any) -> list[tuple[str, int, str]]:
|
|
356
|
+
if isinstance(port_entry, int):
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
if isinstance(port_entry, dict):
|
|
360
|
+
published = port_entry.get("published")
|
|
361
|
+
if published is None:
|
|
362
|
+
return []
|
|
363
|
+
ports = _expand_port_range(str(published))
|
|
364
|
+
host = str(port_entry.get("host_ip") or _DOCKER_WILDCARD_IPV4)
|
|
365
|
+
protocol = str(port_entry.get("protocol") or "tcp").lower()
|
|
366
|
+
return [(host, port, protocol) for port in ports]
|
|
367
|
+
|
|
368
|
+
if not isinstance(port_entry, str):
|
|
369
|
+
return []
|
|
370
|
+
|
|
371
|
+
if "/" in port_entry:
|
|
372
|
+
value, protocol = port_entry.rsplit("/", 1)
|
|
373
|
+
protocol = protocol.lower()
|
|
374
|
+
else:
|
|
375
|
+
value = port_entry
|
|
376
|
+
protocol = "tcp"
|
|
377
|
+
parts = value.rsplit(":", 2)
|
|
378
|
+
if len(parts) == 1:
|
|
379
|
+
return []
|
|
380
|
+
elif len(parts) == 2:
|
|
381
|
+
host = _DOCKER_WILDCARD_IPV4
|
|
382
|
+
published = parts[0]
|
|
383
|
+
else:
|
|
384
|
+
host = parts[0].strip("[]") or _DOCKER_WILDCARD_IPV4
|
|
385
|
+
published = parts[1]
|
|
386
|
+
|
|
387
|
+
return [
|
|
388
|
+
(host, port, protocol)
|
|
389
|
+
for port in _expand_port_range(published)
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _expand_port_range(value: str) -> list[int]:
|
|
394
|
+
parts = value.split("-", 1)
|
|
395
|
+
try:
|
|
396
|
+
start = int(parts[0])
|
|
397
|
+
end = int(parts[1]) if len(parts) == 2 else start
|
|
398
|
+
except ValueError:
|
|
399
|
+
return []
|
|
400
|
+
if not 1 <= start <= end <= 65535:
|
|
401
|
+
return []
|
|
402
|
+
return list(range(start, end + 1))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _port_is_available(host: str, port: int, protocol: str) -> bool | None:
|
|
406
|
+
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
|
407
|
+
if protocol == "tcp":
|
|
408
|
+
socket_type = socket.SOCK_STREAM
|
|
409
|
+
elif protocol == "udp":
|
|
410
|
+
socket_type = socket.SOCK_DGRAM
|
|
411
|
+
else:
|
|
412
|
+
return None
|
|
413
|
+
try:
|
|
414
|
+
with socket.socket(family, socket_type) as listener:
|
|
415
|
+
listener.bind((host, port))
|
|
416
|
+
except (OSError, OverflowError):
|
|
417
|
+
return False
|
|
418
|
+
return True
|