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/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