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/security_common.py ADDED
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+
7
+ SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
8
+
9
+ SECRET_KEY_RE = re.compile(r"(?i)(password|secret|token|key|credential|passwd|pwd)")
10
+
11
+ _SECRET_KEY_WORDS = (
12
+ "password",
13
+ "passwd",
14
+ "pass",
15
+ "pwd",
16
+ "secret",
17
+ "token",
18
+ "key",
19
+ "credential",
20
+ "apikey",
21
+ "accesskey",
22
+ "secretkey",
23
+ "passphrase",
24
+ )
25
+
26
+ SECRET_KEY_SEGMENT_RE = re.compile(
27
+ r"(?i)(?:^|[-_])(?:" + "|".join(_SECRET_KEY_WORDS) + r")(?:$|[^a-z0-9])"
28
+ )
29
+
30
+ ENVIRONMENT_TO_CLASS = {
31
+ "local": "local",
32
+ "development": "dev",
33
+ "staging": "staging",
34
+ "production": "prod",
35
+ }
36
+
37
+
38
+ def infer_profile_class(profile: dict[str, Any]) -> str:
39
+ """Map a profile's declared environment to the security policy class."""
40
+ environment = (profile or {}).get("metadata", {}).get("environment", "local")
41
+ return ENVIRONMENT_TO_CLASS.get(environment, "local")
cli/state.py ADDED
@@ -0,0 +1,112 @@
1
+ """
2
+ Groups `docker compose ps -a --format json` output by health state for
3
+ `cds state`. Provider-neutral: reads only the generic fields Compose
4
+ itself emits (Service/Name, Health, State), no module- or
5
+ service-specific knowledge.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+
13
+ def parse_compose_ps_json(raw_output: str) -> list[dict[str, Any]]:
14
+ """
15
+ Parses `docker compose ps --format json` output in either shape.
16
+ Returns [] for blank output. Skips lines that aren't valid JSON
17
+ objects rather than failing the whole parse on one bad line.
18
+ """
19
+ text = raw_output.strip()
20
+ if not text:
21
+ return []
22
+
23
+ try:
24
+ parsed = json.loads(text)
25
+ if isinstance(parsed, list):
26
+ return [entry for entry in parsed if isinstance(entry, dict)]
27
+ if isinstance(parsed, dict):
28
+ return [parsed]
29
+ except json.JSONDecodeError:
30
+ pass
31
+
32
+ services: list[dict[str, Any]] = []
33
+ for line in text.splitlines():
34
+ line = line.strip()
35
+ if not line:
36
+ continue
37
+ try:
38
+ entry = json.loads(line)
39
+ except json.JSONDecodeError:
40
+ continue
41
+ if isinstance(entry, dict):
42
+ services.append(entry)
43
+ return services
44
+
45
+
46
+ def _bucket_for(service: dict[str, Any]) -> str:
47
+ health = str(service.get("Health") or "").strip()
48
+ if health:
49
+ return health.upper()
50
+
51
+ state = str(service.get("State") or "").strip().lower()
52
+ if state == "exited":
53
+ exit_code_raw = service.get("ExitCode")
54
+ try:
55
+ exit_code = int(exit_code_raw)
56
+ except (TypeError, ValueError):
57
+ return "UNKNOWN"
58
+ return "HEALTHY EXIT" if exit_code == 0 else "UNHEALTHY EXIT"
59
+ if state == "running":
60
+ return "RUNNING"
61
+
62
+ # Paused/Dead/etc. aren't states normal cds workflows produce, no
63
+ # dedicated bucket for them, they fall in with everything else unknown.
64
+ return "UNKNOWN"
65
+
66
+
67
+ def group_services_by_health(services: list[dict[str, Any]]) -> dict[str, list[str]]:
68
+ """
69
+ Groups parsed compose-ps entries into {bucket_name: [service_name, ...]},
70
+ both deterministically sorted. Bucket is Health when the service has a
71
+ healthcheck ("starting"/"healthy"/"unhealthy"); else "healthy exit"/
72
+ "unhealthy exit" by ExitCode when State is "exited"; else "running";
73
+ else "UNKNOWN" (covers Paused/Dead/etc., which normal cds workflows
74
+ don't produce).
75
+ """
76
+ buckets: dict[str, set[str]] = {}
77
+ for service in services:
78
+ name = str(service.get("Service") or service.get("Name") or "").strip()
79
+ if not name:
80
+ continue
81
+ bucket = _bucket_for(service)
82
+ buckets.setdefault(bucket, set()).add(name)
83
+
84
+ return {bucket: sorted(names) for bucket, names in sorted(buckets.items())}
85
+
86
+
87
+ _COLORS = {
88
+ "HEALTHY": "\033[32m",
89
+ "RUNNING": "\033[32m",
90
+ "UNHEALTHY": "\033[31m",
91
+ "HEALTHY EXIT": "\033[92m",
92
+ "UNHEALTHY EXIT": "\033[91m",
93
+ "STARTING": "\033[33m",
94
+ "UNKNOWN": "\033[2m",
95
+ }
96
+ _RESET = "\033[0m"
97
+
98
+
99
+ def format_state_output(grouped: dict[str, list[str]], use_color: bool = False) -> str:
100
+ if not grouped:
101
+ return "No services found."
102
+
103
+ lines = []
104
+ for bucket, names in grouped.items():
105
+ label = f"{bucket}:"
106
+ if use_color:
107
+ color = _COLORS.get(bucket, "")
108
+ label = f"{color}{label}{_RESET}" if color else label
109
+ lines.append(label)
110
+ for name in names:
111
+ lines.append(f" - {name}")
112
+ return "\n".join(lines)
cli/up_runner.py ADDED
@@ -0,0 +1,257 @@
1
+ # cli/up_runner.py
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ import subprocess # nosec B404
6
+ import sys
7
+ import time
8
+ from collections import Counter
9
+ from collections.abc import Callable
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import IO
13
+
14
+ from .state import format_state_output, group_services_by_health, parse_compose_ps_json
15
+
16
+ _BUILD_SERVICE_RE = re.compile(r"^([\w][\w.-]*?):\s+Building\b")
17
+
18
+ DEFAULT_POLL_INTERVAL_SECONDS = 2.0
19
+ DEFAULT_TIMEOUT_SECONDS = 180.0
20
+
21
+ _SETTLED_BUCKETS = {"HEALTHY", "RUNNING", "HEALTHY EXIT", "UNHEALTHY EXIT", "UNHEALTHY"}
22
+ _FAILURE_BUCKETS = {"UNHEALTHY", "UNHEALTHY EXIT"}
23
+
24
+
25
+ def default_log_path(profile_name: str, logs_dir: Path | None = None) -> Path:
26
+ """
27
+ Default log path for a `cds up` run: `.cds/logs/up-<profile>-<UTC
28
+ timestamp>.log`, relative to the current working directory. Slashes
29
+ and spaces in `profile_name` (profiles can be passed as paths) are
30
+ flattened so the result is always a single valid filename.
31
+
32
+ `logs_dir` is injectable so tests don't have to write into a real
33
+ `.cds/logs` under the repo checkout.
34
+ """
35
+ base = logs_dir if logs_dir is not None else Path(".cds") / "logs"
36
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
37
+ safe_profile = profile_name.strip().replace("/", "-").replace("\\", "-").replace(" ", "-") or "profile"
38
+ return base / f"up-{safe_profile}-{timestamp}.log"
39
+
40
+
41
+ def run_streamed(
42
+ cmd: list[str],
43
+ log_file: IO[str],
44
+ echo: bool = True,
45
+ group_by_image: bool = False,
46
+ service_to_image: dict[str, str] | None = None,
47
+ use_color: bool = True,
48
+ ) -> int:
49
+ """
50
+ Runs `cmd` with stdout+stderr merged, writing each line to
51
+ `log_file` as it arrives (flushed immediately, so `tail -f` on the
52
+ log file works while the command is still running) and, if `echo`,
53
+ to this process's stdout too.
54
+
55
+ When `group_by_image` is True and `echo` is True, the output is
56
+ annotated with section headers that identify which Docker Compose
57
+ service each build phase belongs to. The log file always receives
58
+ the raw, un-annotated output.
59
+
60
+ Section headers are colored only when `use_color` is True and
61
+ stdout is a TTY, matching the ANSI handling in `_default_redraw`
62
+ (e.g. CI logs and redirected output stay plain).
63
+
64
+ Returns the command's exit code. Raises FileNotFoundError if
65
+ `cmd[0]` isn't on PATH, same as subprocess.run.
66
+ """
67
+ process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) # nosec B603
68
+ if process.stdout is None:
69
+ raise RuntimeError("subprocess.Popen returned no stdout despite stdout=PIPE")
70
+ try:
71
+ current_group: str | None = None
72
+ seen_groups: set[str] = set()
73
+ image_container_counts = Counter((service_to_image or {}).values())
74
+ for line in process.stdout:
75
+ log_file.write(line)
76
+ log_file.flush()
77
+ if echo:
78
+ if group_by_image:
79
+ m = _BUILD_SERVICE_RE.match(line)
80
+ if m:
81
+ service = m.group(1)
82
+ image = (service_to_image or {}).get(service, service)
83
+ if image != current_group:
84
+ current_group = image
85
+ if image not in seen_groups:
86
+ seen_groups.add(image)
87
+ count = image_container_counts.get(image, 1)
88
+ label = f"Building {image}"
89
+ if count > 1:
90
+ label += f" ({count} containers)"
91
+ header = f"── {label} "
92
+ header += "─" * max(1, 60 - len(header))
93
+ if use_color and sys.stdout.isatty():
94
+ sys.stdout.write(f"\n\033[36m{header}\033[0m\n")
95
+ else:
96
+ sys.stdout.write(f"\n{header}\n")
97
+ sys.stdout.write(line)
98
+ sys.stdout.flush()
99
+ finally:
100
+ process.stdout.close()
101
+ return process.wait()
102
+
103
+
104
+ def start_log_tail(compose_path: str, log_file: IO[str]) -> subprocess.Popen:
105
+ """
106
+ Starts `docker compose logs -f` in the background, piped only to
107
+ `log_file` (not the terminal; the terminal is showing the live
108
+ state view while this runs). Caller is responsible for stopping it
109
+ with `stop_log_tail` once the stack settles or `cds up` exits.
110
+ """
111
+ logs_cmd = ["docker", "compose", "-f", compose_path, "logs", "-f", "--no-color"]
112
+ return subprocess.Popen(logs_cmd, stdout=log_file, stderr=subprocess.STDOUT, text=True) # nosec B603
113
+
114
+
115
+ def start_up_in_background(cmd: list[str], log_file: IO[str]) -> subprocess.Popen:
116
+ """
117
+ Starts `docker compose up --detach` in the background, piped only to
118
+ `log_file`. Unlike `run_streamed`, this does not block: `docker
119
+ compose up` can itself block for a long time waiting on
120
+ healthcheck-gated `depends_on` dependencies to become healthy before
121
+ it returns, even though `--detach` is passed. Running it in the
122
+ background lets the live state view (which polls `docker compose ps`
123
+ directly) start rendering immediately instead of waiting for `up` to
124
+ finish. Caller is responsible for reaping it with `process.wait()`.
125
+ """
126
+ return subprocess.Popen(cmd, stdout=log_file, stderr=subprocess.STDOUT, text=True) # nosec B603
127
+
128
+
129
+ def stop_log_tail(process: subprocess.Popen, timeout: float = 5.0) -> None:
130
+ """Terminates a background log-tail process started by start_log_tail."""
131
+ if process.poll() is not None:
132
+ return
133
+ process.terminate()
134
+ try:
135
+ process.wait(timeout=timeout)
136
+ except subprocess.TimeoutExpired:
137
+ process.kill()
138
+ process.wait()
139
+
140
+
141
+ def _is_settled(grouped: dict[str, list[str]], expected_service_count: int | None) -> bool:
142
+ if not grouped:
143
+ return expected_service_count == 0
144
+
145
+ pending = sum(len(names) for bucket, names in grouped.items() if bucket not in _SETTLED_BUCKETS)
146
+ if pending:
147
+ return False
148
+ if expected_service_count is not None:
149
+ seen = sum(len(names) for names in grouped.values())
150
+ if seen < expected_service_count:
151
+ return False
152
+ return True
153
+
154
+
155
+ def _default_redraw(text: str) -> None:
156
+ if sys.stdout.isatty():
157
+ sys.stdout.write("\033[2J\033[H")
158
+ sys.stdout.write(text + "\n")
159
+ else:
160
+ print(text)
161
+ sys.stdout.flush()
162
+
163
+
164
+ def poll_state_until_settled(
165
+ compose_path: str,
166
+ *,
167
+ expected_service_count: int | None = None,
168
+ poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
169
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
170
+ use_color: bool = False,
171
+ sleep_fn: Callable[[float], None] = time.sleep,
172
+ now_fn: Callable[[], float] = time.monotonic,
173
+ ps_fn: Callable[[], subprocess.CompletedProcess] | None = None,
174
+ redraw_fn: Callable[[str], None] | None = None,
175
+ up_done_fn: Callable[[], int | None] | None = None,
176
+ on_up_finished: Callable[[int], None] | None = None,
177
+ ) -> tuple[bool, dict[str, list[str]]]:
178
+ """
179
+ Polls `docker compose ps -a --format json` every `poll_interval`
180
+ seconds, redrawing the grouped `cds state` view each time, until
181
+ every service `docker compose ps` reports is in a terminal bucket
182
+ (HEALTHY, RUNNING, HEALTHY EXIT, UNHEALTHY EXIT, or UNHEALTHY) or
183
+ `timeout` seconds elapse.
184
+
185
+ Returns `(settled, grouped)`. `settled` is False if the loop timed
186
+ out, or if any service ended in UNHEALTHY / UNHEALTHY EXIT.
187
+
188
+ `ps_fn`, `sleep_fn`, `now_fn`, and `redraw_fn` are injectable so this
189
+ can be unit tested with a fake clock and canned `ps` output instead
190
+ of real Docker calls and real sleeping.
191
+
192
+ `up_done_fn`, if given, is polled once per iteration and must return
193
+ `None` while `docker compose up` is still running, or its exit code
194
+ once it has finished. This lets the caller run `up` in the
195
+ background while this loop redraws the live view immediately,
196
+ without either process blocking the other:
197
+
198
+ - If `up` exits non-zero, this returns `(False, grouped)` right
199
+ away instead of burning through the full `timeout`, since
200
+ services that `up` never started will never settle.
201
+ - The `timeout` clock only starts once `up` finishes successfully,
202
+ so a stack whose healthchecks legitimately outlast `timeout`
203
+ isn't penalized for time `up` itself spent blocked on
204
+ healthcheck-gated `depends_on` dependencies. Omitting `up_done_fn`
205
+ preserves the old behavior of starting the clock immediately.
206
+
207
+ `on_up_finished`, if given, is called exactly once, the first time
208
+ `up_done_fn` reports a successful result (exit code 0), so callers can
209
+ defer setup (e.g. starting a log tail) until `up` is done rather
210
+ than running it concurrently with `up`'s own output.
211
+ """
212
+ if ps_fn is None:
213
+ def ps_fn() -> subprocess.CompletedProcess:
214
+ ps_cmd = ["docker", "compose", "-f", compose_path, "ps", "-a", "--format", "json"]
215
+ return subprocess.run(ps_cmd, capture_output=True, text=True) # nosec B603
216
+
217
+ if redraw_fn is None:
218
+ redraw_fn = _default_redraw
219
+
220
+ if up_done_fn is None:
221
+ # No background `up` process to track: behave as if it had
222
+ # already finished successfully, so the timeout clock starts
223
+ # immediately (matches the pre-existing behavior).
224
+ def up_done_fn() -> int | None:
225
+ return 0
226
+
227
+ start: float | None = None
228
+ up_finished_seen = False
229
+ grouped: dict[str, list[str]] = {}
230
+ while True:
231
+ ps_result = ps_fn()
232
+ services = parse_compose_ps_json(ps_result.stdout) if ps_result.returncode == 0 else []
233
+ grouped = group_services_by_health(services)
234
+ redraw_fn(format_state_output(grouped, use_color=use_color))
235
+
236
+ if _is_settled(grouped, expected_service_count):
237
+ has_failure = any(bucket in _FAILURE_BUCKETS and names for bucket, names in grouped.items())
238
+ return (not has_failure), grouped
239
+
240
+ up_exit_code = up_done_fn()
241
+ if up_exit_code is not None:
242
+ if not up_finished_seen:
243
+ up_finished_seen = True
244
+ if up_exit_code == 0 and on_up_finished is not None:
245
+ on_up_finished(up_exit_code)
246
+ if up_exit_code != 0:
247
+ # `up` itself already failed; services it never started
248
+ # will never settle, so don't wait out the full timeout.
249
+ return False, grouped
250
+ if start is None:
251
+ start = now_fn()
252
+
253
+ if start is not None and now_fn() - start >= timeout:
254
+ return False, grouped
255
+
256
+ sleep_fn(poll_interval)
257
+