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/main.py ADDED
@@ -0,0 +1,1656 @@
1
+ # cli/main.py
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess # nosec B404
9
+ import sys
10
+ import tempfile
11
+ from contextlib import suppress
12
+ from importlib.metadata import PackageNotFoundError, version as _package_version
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ try:
17
+ import argcomplete # type: ignore
18
+ except ImportError:
19
+ argcomplete = None
20
+
21
+ from .validator import has_errors, validate_profile
22
+ from .diagnostics import Diagnostic
23
+ from .planner import build_plan
24
+ from .renderer import render_compose
25
+ from .image_updates import collect_module_images, check_image_update
26
+ from .overlay import resolve_profile
27
+ from .preflight import preflight_passed, run_preflight
28
+ from .security import PrecomputedRender, run_security_validation
29
+ from .security_common import SEVERITY_ORDER, infer_profile_class
30
+ from .image_verification import default_fixture_path, load_policy_from_env, verify_images
31
+ from .state import format_state_output, group_services_by_health, parse_compose_ps_json
32
+ from .up_runner import (
33
+ DEFAULT_TIMEOUT_SECONDS,
34
+ default_log_path,
35
+ poll_state_until_settled,
36
+ run_streamed,
37
+ start_log_tail,
38
+ start_up_in_background,
39
+ stop_log_tail,
40
+ )
41
+ from .loader import load_yaml_file
42
+ import yaml
43
+
44
+
45
+ def load_env_file(env_file: str = ".env") -> None:
46
+ """Load environment variables from a .env file."""
47
+ env_path = Path(env_file)
48
+ if not env_path.exists():
49
+ return
50
+
51
+ try:
52
+ with open(env_path, encoding="utf-8-sig") as f:
53
+ lines = f.readlines()
54
+ except (OSError, UnicodeDecodeError) as exc:
55
+ print(f"WARNING Could not read {env_path}: {exc}", file=sys.stderr)
56
+ return
57
+
58
+ for line in lines:
59
+ line = line.strip()
60
+ # Skip empty lines and comments
61
+ if not line or line.startswith("#"):
62
+ continue
63
+
64
+ # Parse KEY=VALUE format
65
+ if "=" in line:
66
+ key, value = line.split("=", 1)
67
+ key = key.strip()
68
+ value = value.strip()
69
+ # Only set if not already in environment
70
+ if key and not os.environ.get(key):
71
+ os.environ[key] = value
72
+
73
+
74
+ def print_diagnostics(diagnostics) -> None:
75
+ for d in diagnostics:
76
+ prefix = "ERROR" if d.level == "error" else "WARN"
77
+ print(f"{prefix} {d.format()}\n")
78
+
79
+
80
+ def profile_completer(prefix, parsed_args, **kwargs):
81
+ return [name for name in list_profiles() if name.startswith(prefix)]
82
+
83
+
84
+ def get_profiles_root() -> Path:
85
+ override = os.getenv("CDS_PROFILE_PATH")
86
+ if override:
87
+ return Path(override).expanduser()
88
+ return find_project_root() / "profiles"
89
+
90
+
91
+ def get_modules_root() -> Path:
92
+ override = os.getenv("CDS_MODULE_PATH")
93
+ if override:
94
+ return Path(override).expanduser()
95
+ return find_project_root() / "modules"
96
+
97
+
98
+ def find_project_root(start: Path | None = None) -> Path:
99
+ """
100
+ Walk up from `start` (default: current working directory) looking for a
101
+ project root marker (pyproject.toml or .git). Falls back to `start` itself
102
+ if no marker is found.
103
+ """
104
+ current = (start or Path.cwd()).resolve()
105
+ for directory in [current, *current.parents]:
106
+ if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
107
+ return directory
108
+ return current
109
+
110
+
111
+ def get_config_path() -> Path:
112
+ """Location of the per-project CDS config file used by `cds use`."""
113
+ override = os.getenv("CDS_CONFIG_PATH")
114
+ if override:
115
+ return Path(override).expanduser()
116
+ return find_project_root() / ".cds" / "config.json"
117
+
118
+
119
+ class ConfigIOError(RuntimeError):
120
+ """Raised when the `cds use` config file cannot be read or written."""
121
+
122
+
123
+ def _atomic_write_text(path: Path, content: str) -> None:
124
+ """Write `content` to `path` atomically via a temp file + os.replace.
125
+
126
+ Raises ConfigIOError (instead of an uncaught traceback) if the parent
127
+ directory can't be created or the write/replace fails, e.g. because
128
+ CDS_CONFIG_PATH points at an unwritable location or a path segment is
129
+ actually a file.
130
+ """
131
+ try:
132
+ path.parent.mkdir(parents=True, exist_ok=True)
133
+ fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
134
+ except OSError as exc:
135
+ raise ConfigIOError(f"Could not prepare {path} for writing: {exc}") from exc
136
+
137
+ try:
138
+ with os.fdopen(fd, "w", encoding="utf-8") as tmp_file:
139
+ tmp_file.write(content)
140
+ os.replace(tmp_name, path)
141
+ except OSError as exc:
142
+ with suppress(OSError):
143
+ os.unlink(tmp_name)
144
+ raise ConfigIOError(f"Could not write config file {path}: {exc}") from exc
145
+
146
+
147
+ def _read_config() -> dict:
148
+ config_path = get_config_path()
149
+ if not config_path.exists():
150
+ return {}
151
+ try:
152
+ data = json.loads(config_path.read_text(encoding="utf-8"))
153
+ except json.JSONDecodeError:
154
+ print(
155
+ f"WARNING {config_path} is not valid JSON; treating it as empty. "
156
+ "It will be overwritten by the next `cds use <profile>`.",
157
+ file=sys.stderr,
158
+ )
159
+ return {}
160
+ except OSError as exc:
161
+ print(f"WARNING Could not read {config_path}: {exc}", file=sys.stderr)
162
+ return {}
163
+ if not isinstance(data, dict):
164
+ print(
165
+ f"WARNING {config_path} contains valid JSON but not a mapping; treating it as empty. "
166
+ "It will be overwritten by the next `cds use <profile>`.",
167
+ file=sys.stderr,
168
+ )
169
+ return {}
170
+ return data
171
+
172
+
173
+ def load_saved_profile() -> str | None:
174
+ """Return the profile name saved via `cds use`, if any."""
175
+ profile = _read_config().get("profile")
176
+ return profile if isinstance(profile, str) and profile else None
177
+
178
+
179
+ def save_profile(profile: str) -> Path:
180
+ """Persist `profile` as the default for this project. Returns the config path.
181
+
182
+ Raises ConfigIOError if the config file cannot be written.
183
+ """
184
+ config_path = get_config_path()
185
+ data = _read_config()
186
+ data["profile"] = profile
187
+ _atomic_write_text(config_path, json.dumps(data, indent=2) + "\n")
188
+ return config_path
189
+
190
+
191
+ def clear_saved_profile() -> bool:
192
+ """Remove a previously saved default profile. Returns True if one was cleared.
193
+
194
+ Raises ConfigIOError if the config file cannot be updated/removed.
195
+ """
196
+ config_path = get_config_path()
197
+ data = _read_config()
198
+ if "profile" not in data:
199
+ return False
200
+ del data["profile"]
201
+ try:
202
+ if data:
203
+ _atomic_write_text(config_path, json.dumps(data, indent=2) + "\n")
204
+ else:
205
+ config_path.unlink()
206
+ except OSError as exc:
207
+ raise ConfigIOError(f"Could not update config file {config_path}: {exc}") from exc
208
+ return True
209
+
210
+
211
+ def _resolve_profile_root(profile_root: Path) -> str | None:
212
+ """
213
+ Resolve an ambient profiles root (CDS_PROFILE_PATH, or the default
214
+ "profiles/" directory) with no explicit profile argument. Returns None if
215
+ `profile_root` doesn't unambiguously resolve to a single profile.
216
+ """
217
+ if profile_root.is_file():
218
+ return str(profile_root.resolve())
219
+
220
+ direct_profile = profile_root / "profile.yaml"
221
+ if direct_profile.exists():
222
+ return str(direct_profile.resolve())
223
+
224
+ if profile_root.is_dir():
225
+ subdirs = [
226
+ directory
227
+ for directory in sorted(profile_root.iterdir())
228
+ if directory.is_dir() and (directory / "profile.yaml").exists()
229
+ ]
230
+ if len(subdirs) == 1:
231
+ return str((subdirs[0] / "profile.yaml").resolve())
232
+
233
+ # profile_root may be set to a bare profile name rather than a path.
234
+ # Try resolving it as a name under the default profiles/ directory.
235
+ default_root = find_project_root() / "profiles"
236
+ if default_root.resolve() != profile_root.resolve():
237
+ name_candidate = default_root / profile_root.name / "profile.yaml"
238
+ if name_candidate.exists():
239
+ return str(name_candidate.resolve())
240
+
241
+ return None
242
+
243
+
244
+ def resolve_profile_path(profile: str | None) -> str:
245
+ profile_root = get_profiles_root()
246
+
247
+ if profile:
248
+ candidate = Path(profile)
249
+ if candidate.is_file():
250
+ return str(candidate.resolve())
251
+
252
+ if candidate.suffix == ".yaml":
253
+ return str(candidate.resolve())
254
+
255
+ if candidate.is_dir():
256
+ direct_profile = candidate / "profile.yaml"
257
+ if direct_profile.exists():
258
+ return str(direct_profile.resolve())
259
+
260
+ subdirs = [
261
+ directory
262
+ for directory in sorted(candidate.iterdir())
263
+ if directory.is_dir() and (directory / "profile.yaml").exists()
264
+ ]
265
+ if len(subdirs) == 1:
266
+ return str((subdirs[0] / "profile.yaml").resolve())
267
+
268
+ if profile_root.is_file():
269
+ return str(profile_root.resolve())
270
+
271
+ candidate_by_name = profile_root / profile / "profile.yaml"
272
+ candidate_file = profile_root / f"{profile}.yaml"
273
+
274
+ if candidate_by_name.exists():
275
+ return str(candidate_by_name.resolve())
276
+ if candidate_file.exists():
277
+ return str(candidate_file.resolve())
278
+
279
+ # CDS_PROFILE_PATH may have been set to a profile name rather than a
280
+ # profiles root directory. Fall back to the default "profiles/" root so
281
+ # that an explicit profile name still resolves correctly.
282
+ default_root = find_project_root() / "profiles"
283
+ if default_root.resolve() != profile_root.resolve():
284
+ default_by_name = default_root / profile / "profile.yaml"
285
+ default_by_file = default_root / f"{profile}.yaml"
286
+ if default_by_name.exists():
287
+ return str(default_by_name.resolve())
288
+ if default_by_file.exists():
289
+ return str(default_by_file.resolve())
290
+
291
+ return str(candidate_by_name.resolve())
292
+
293
+ # No profile argument provided. Resolution order:
294
+ # 1. CDS_PROFILE_PATH, if explicitly set for this invocation. Env vars
295
+ # are per-invocation and reflect the current session more reliably
296
+ # than a persisted, gitignored default that's easy to forget about.
297
+ # This matches common CLI precedence (env var overrides persisted
298
+ # config, e.g. AWS CLI, Azure CLI) -- and was previously inverted
299
+ # here, with the saved default silently winning over the env var.
300
+ # 2. The saved default from `cds use <profile>`.
301
+ # 3. The single profile under the default profiles/ directory, if
302
+ # there is exactly one (also the fallback when CDS_PROFILE_PATH is
303
+ # unset, since profile_root defaults to "profiles").
304
+ env_profile_path = os.getenv("CDS_PROFILE_PATH")
305
+ if env_profile_path:
306
+ resolved_from_env = _resolve_profile_root(Path(env_profile_path).expanduser())
307
+ if resolved_from_env:
308
+ return resolved_from_env
309
+ print(
310
+ f"WARNING CDS_PROFILE_PATH={env_profile_path!r} did not resolve to a single profile; "
311
+ "falling back to saved default.",
312
+ file=sys.stderr,
313
+ )
314
+
315
+ saved_profile = load_saved_profile()
316
+ if saved_profile:
317
+ resolved_saved_profile = resolve_profile_path(saved_profile)
318
+ if not Path(resolved_saved_profile).is_file():
319
+ raise ValueError(
320
+ f"Saved default profile '{saved_profile}' no longer resolves to a file "
321
+ f"(looked for {resolved_saved_profile}). Run `cds use --clear` to remove it, "
322
+ "or `cds use <profile>` to save a new default."
323
+ )
324
+ return resolved_saved_profile
325
+
326
+ resolved_default = _resolve_profile_root(profile_root)
327
+ if resolved_default:
328
+ return resolved_default
329
+
330
+ raise ValueError(
331
+ "No profile specified. Either provide a profile argument, run `cds use <profile>` "
332
+ "to save a default, or set CDS_PROFILE_PATH to a profile file or directory "
333
+ "containing a single profile."
334
+ )
335
+
336
+
337
+ def resolve_project_root(profile_path: str) -> Path:
338
+ """
339
+ Resolve a project root for output artifacts.
340
+
341
+ The resolver walks up from the selected profile location and picks the first
342
+ directory containing either pyproject.toml or .git. If no marker is found,
343
+ it falls back to the current working directory.
344
+ """
345
+ start = Path(profile_path).resolve().parent
346
+ for directory in [start, *start.parents]:
347
+ if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
348
+ return directory
349
+ return Path.cwd().resolve()
350
+
351
+
352
+ def resolve_env_file_path(profile_path: str) -> Path:
353
+ """
354
+ Resolve the default .env location for a profile.
355
+
356
+ Preferred location is alongside profile.yaml. For backward compatibility,
357
+ falls back to project-root .env when profile-local .env is absent.
358
+ """
359
+ profile_env = Path(profile_path).resolve().parent / ".env"
360
+ if profile_env.exists():
361
+ return profile_env
362
+
363
+ project_env = resolve_project_root(profile_path) / ".env"
364
+ return project_env
365
+
366
+
367
+ def list_profiles() -> list[str]:
368
+ profile_root = get_profiles_root()
369
+ profiles: list[str] = []
370
+
371
+ if profile_root.is_file():
372
+ profiles.append(str(profile_root))
373
+ return profiles
374
+
375
+ if not profile_root.exists():
376
+ return profiles
377
+
378
+ if (profile_root / "profile.yaml").exists():
379
+ profiles.append(profile_root.name or ".")
380
+
381
+ for directory in sorted(profile_root.iterdir()):
382
+ if directory.is_dir() and (directory / "profile.yaml").exists():
383
+ profiles.append(directory.name)
384
+
385
+ return profiles
386
+
387
+
388
+ def list_modules() -> list[str]:
389
+ module_root = get_modules_root()
390
+ modules: list[str] = []
391
+
392
+ if module_root.is_file():
393
+ return [str(module_root)]
394
+
395
+ if not module_root.exists():
396
+ return modules
397
+
398
+ for module_file in sorted(module_root.rglob("module.yaml")):
399
+ try:
400
+ modules.append(module_file.parent.relative_to(module_root).as_posix())
401
+ except ValueError:
402
+ modules.append(str(module_file.parent))
403
+
404
+ return modules
405
+
406
+
407
+ def _add_profile_arg(subparser: argparse.ArgumentParser) -> None:
408
+ action = subparser.add_argument(
409
+ "profile",
410
+ nargs="?",
411
+ help=(
412
+ "Profile to use. Accepts a profile name (e.g. local-dagster-postgres-superset), "
413
+ "a path to a profile.yaml file, or a path to a profiles root directory. "
414
+ "When omitted, resolution falls back in order to: CDS_PROFILE_PATH if set, "
415
+ "then the default profile saved via `cds use <profile>`, then the single "
416
+ "profile under profiles/ if there is exactly one. "
417
+ "CDS_PROFILE_PATH accepts the same forms: a profile name, a profile file path, "
418
+ "or a profiles root directory."
419
+ ),
420
+ )
421
+ if argcomplete is not None:
422
+ action.completer = profile_completer # type: ignore[attr-defined]
423
+
424
+
425
+ def _add_environment_arg(subparser: argparse.ArgumentParser) -> None:
426
+ subparser.add_argument(
427
+ "--environment",
428
+ "-e",
429
+ default=None,
430
+ help=(
431
+ "Environment overlay to apply, e.g. dev or prod. Merges "
432
+ "profiles/<name>/environments/<environment>.yaml over the base "
433
+ "profile before resolving. Omit to use the base profile unchanged."
434
+ ),
435
+ )
436
+
437
+
438
+ def _collect_profile_env_vars(
439
+ profile_path: str, environment: str | None = None
440
+ ) -> tuple[list[str], set[str]]:
441
+ """Return (sorted env var names, subset that are true secrets).
442
+
443
+ Env vars declared under `spec.secrets.values` hold sensitive values (passwords,
444
+ keys) and always default to a placeholder. Env vars only referenced elsewhere in
445
+ the profile (e.g. `${CDS_ANALYTICS_DB_NAME}`) are typically non-sensitive
446
+ identifiers like database/user names, so callers can fill in friendlier defaults
447
+ for them instead of a placeholder.
448
+ """
449
+ if environment is not None:
450
+ from .overlay import resolve_profile
451
+
452
+ profile, _, diags = resolve_profile(profile_path, environment)
453
+ else:
454
+ profile, diags = load_yaml_file(Path(profile_path))
455
+ if profile is None:
456
+ error_messages = [d.format() for d in diags if d.level == "error"]
457
+ raise ValueError("Could not load profile: " + "; ".join(error_messages or ["unknown error"]))
458
+
459
+ secret_env_vars: set[str] = set()
460
+ spec = profile.get("spec", {})
461
+ values = spec.get("secrets", {}).get("values", {})
462
+ if isinstance(values, dict):
463
+ for secret_name, secret_def in values.items():
464
+ if not isinstance(secret_def, dict):
465
+ continue
466
+ env_name = secret_def.get("env")
467
+ if isinstance(env_name, str) and env_name:
468
+ secret_env_vars.add(env_name)
469
+ else:
470
+ raise ValueError(f'Secret "{secret_name}" is missing a valid env name.')
471
+
472
+ env_vars = set(secret_env_vars) | _find_profile_env_references(spec)
473
+
474
+ if not env_vars:
475
+ raise ValueError("No environment variables were found in the profile.")
476
+
477
+ return sorted(env_vars), secret_env_vars
478
+
479
+
480
+ def _find_profile_env_references(value) -> set[str]:
481
+ if isinstance(value, dict):
482
+ references: set[str] = set()
483
+ for nested in value.values():
484
+ references.update(_find_profile_env_references(nested))
485
+ return references
486
+ if isinstance(value, list):
487
+ references = set()
488
+ for nested in value:
489
+ references.update(_find_profile_env_references(nested))
490
+ return references
491
+ if isinstance(value, str):
492
+ return set(re.findall(r"\$\{(CDS_[A-Z0-9_]+)\}", value))
493
+ return set()
494
+ def _default_env_value(env_name: str, is_secret: bool) -> str:
495
+ """Best-effort friendly default for a non-secret env var, else the change-me placeholder."""
496
+ if not is_secret:
497
+ # e.g. CDS_ANALYTICS_DB_NAME / CDS_ANALYTICS_DB_USER -> "analytics"
498
+ match = re.match(r"^CDS_([A-Z0-9]+)_DB_(?:NAME|USER)$", env_name)
499
+ if match:
500
+ return match.group(1).lower()
501
+ return "change-me"
502
+
503
+
504
+ def _atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
505
+ """Write `content` to `path` atomically via a temp file + os.replace.
506
+
507
+ Avoids leaving a truncated/partial file behind if the process is
508
+ interrupted mid-write, and avoids races between concurrent writers.
509
+ """
510
+ path.parent.mkdir(parents=True, exist_ok=True)
511
+ fd, tmp_name = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
512
+ try:
513
+ with os.fdopen(fd, "w", encoding=encoding) as tmp_file:
514
+ tmp_file.write(content)
515
+ os.replace(tmp_name, path)
516
+ except OSError:
517
+ with suppress(OSError):
518
+ os.unlink(tmp_name)
519
+ raise
520
+
521
+
522
+ def _write_env_file(
523
+ output_path: Path,
524
+ env_vars: list[str],
525
+ secret_env_vars: set[str],
526
+ profile_path: str,
527
+ force: bool,
528
+ ) -> None:
529
+ if output_path.exists() and not force:
530
+ raise FileExistsError(f"Refusing to overwrite existing file: {output_path}. Use --force to overwrite.")
531
+
532
+ lines = [
533
+ "# Generated by cds init",
534
+ f"# Source profile: {profile_path}",
535
+ "",
536
+ ]
537
+ lines.extend(
538
+ f"{env_name}={_default_env_value(env_name, env_name in secret_env_vars)}" for env_name in env_vars
539
+ )
540
+ lines.append("")
541
+ _atomic_write_text(output_path, "\n".join(lines))
542
+
543
+
544
+ def _cds_version() -> str:
545
+ """Resolve the installed CDS CLI version."""
546
+ try:
547
+ return _package_version("composable-data-stack")
548
+ except PackageNotFoundError:
549
+ return "unknown"
550
+
551
+
552
+ def _completion_instructions(shell: str) -> str:
553
+ """Return copy-pasteable shell setup instructions for cds tab-completion."""
554
+ preamble = (
555
+ "# cds does not modify your shell config automatically (same as kubectl, docker,\n"
556
+ "# gh, and az completion). Copy the steps below into your shell yourself:"
557
+ )
558
+ if shell == "powershell":
559
+ install_step = (
560
+ "# 1. Install argcomplete (skip if already installed):\n"
561
+ "python -m pip install argcomplete"
562
+ )
563
+ setup_step = (
564
+ "# 2. Add to your PowerShell profile ($PROFILE), then restart your shell "
565
+ "(or `. $PROFILE`):\n"
566
+ "register-python-argcomplete --shell powershell cds | Out-String | Invoke-Expression"
567
+ )
568
+ return f"{preamble}\n\n{install_step}\n\n{setup_step}"
569
+
570
+ install_step = (
571
+ "# 1. Install argcomplete (skip if already installed):\n"
572
+ "python3 -m pip install argcomplete"
573
+ )
574
+ if shell == "zsh":
575
+ setup_step = (
576
+ "# 2. Add to ~/.zshrc, then restart your shell (or `source ~/.zshrc`):\n"
577
+ "autoload -U bashcompinit\n"
578
+ "bashcompinit\n"
579
+ 'eval "$(register-python-argcomplete cds)"'
580
+ )
581
+ else:
582
+ setup_step = (
583
+ "# 2. Add to ~/.bashrc, then restart your shell (or `source ~/.bashrc`):\n"
584
+ 'eval "$(register-python-argcomplete cds)"'
585
+ )
586
+ return f"{preamble}\n\n{install_step}\n\n{setup_step}"
587
+
588
+
589
+ def _is_id_keyed_list(value: Any) -> bool:
590
+ """True if every element is a mapping with a stable "id" key (e.g. spec.modules)."""
591
+ return bool(value) and all(isinstance(item, dict) and "id" in item for item in value)
592
+
593
+
594
+ def _diff_values(path: str, a: Any, b: Any, changes: list[tuple[str, str, Any, Any]]) -> None:
595
+ """
596
+ Recursively compare two resolved profile values and append (path, kind, old,
597
+ new) tuples to changes, kind is one of "added", "removed", "changed".
598
+
599
+ Dicts are compared key-by-key. Lists are compared by id instead of position
600
+ (matching cli.overlay's merge semantics, so reordering module entries alone
601
+ is not reported as a change) only when *every* element on *both* sides is a
602
+ mapping with a stable "id" key (e.g. spec.modules). Any list where at least
603
+ one element on either side lacks an "id" falls back to whole-list equality
604
+ comparison, so a heterogeneous list (some entries with "id", some without)
605
+ is still reported in full instead of silently dropping the id-less entries.
606
+ """
607
+ if isinstance(a, dict) and isinstance(b, dict):
608
+ for key in sorted(set(a) | set(b)):
609
+ child_path = f"{path}.{key}" if path else key
610
+ if key not in a:
611
+ changes.append((child_path, "added", None, b[key]))
612
+ elif key not in b:
613
+ changes.append((child_path, "removed", a[key], None))
614
+ else:
615
+ _diff_values(child_path, a[key], b[key], changes)
616
+ return
617
+
618
+ if isinstance(a, list) and isinstance(b, list) and _is_id_keyed_list(a) and _is_id_keyed_list(b):
619
+ a_by_id = {item["id"]: item for item in a if isinstance(item, dict) and "id" in item}
620
+ b_by_id = {item["id"]: item for item in b if isinstance(item, dict) and "id" in item}
621
+ for module_id in sorted(set(a_by_id) | set(b_by_id)):
622
+ child_path = f"{path}[{module_id}]"
623
+ if module_id not in a_by_id:
624
+ changes.append((child_path, "added", None, b_by_id[module_id]))
625
+ elif module_id not in b_by_id:
626
+ changes.append((child_path, "removed", a_by_id[module_id], None))
627
+ else:
628
+ _diff_values(child_path, a_by_id[module_id], b_by_id[module_id], changes)
629
+ return
630
+
631
+ if a != b:
632
+ changes.append((path, "changed", a, b))
633
+
634
+
635
+ def _unverifiable_image_finding(message: str) -> dict[str, Any]:
636
+ return {
637
+ "rule_id": "CDS-VER-004",
638
+ "severity": "high",
639
+ "module": "<profile>",
640
+ "message": message,
641
+ "path": "spec.modules",
642
+ "value": None,
643
+ "recommendation": [
644
+ "Fix the plan/render errors so image verification can run.",
645
+ "Re-run cds security --verify-images after fixing the profile.",
646
+ ],
647
+ }
648
+
649
+
650
+ def _run_image_verification(profile_path: str, environment: str | None) -> list[dict[str, Any]]:
651
+ """
652
+ Render the profile and verify service images against the CDS image policy.
653
+
654
+ Verification runs in "full" mode: static supply-chain checks plus
655
+ cosign-based signature/provenance verification (or the signed-images
656
+ fixture when available for offline verification). Fails closed with a
657
+ high-severity finding when verification was requested but cannot run.
658
+ """
659
+ try:
660
+ profile, _, _ = resolve_profile(profile_path, environment)
661
+ env_file = str(resolve_env_file_path(profile_path))
662
+ plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=environment)
663
+ if has_errors(plan_diags) or plan is None:
664
+ print_diagnostics(plan_diags)
665
+ print("Cannot verify images because plan generation failed.")
666
+ return [
667
+ _unverifiable_image_finding(
668
+ "Image verification could not run because plan generation failed"
669
+ )
670
+ ]
671
+ compose_yaml, render_diags = render_compose(plan, env_file=env_file)
672
+ if has_errors(render_diags):
673
+ print_diagnostics(render_diags)
674
+ print("Cannot verify images because render failed.")
675
+ return [
676
+ _unverifiable_image_finding(
677
+ "Image verification could not run because rendering failed"
678
+ )
679
+ ]
680
+ profile_class = infer_profile_class(profile) if profile is not None else "local"
681
+ policy = load_policy_from_env(profile_class, mode_override="full")
682
+ return verify_images(compose_yaml, policy, fixture=default_fixture_path())
683
+ except Exception as e:
684
+ print(Diagnostic(
685
+ level="error",
686
+ code="E095",
687
+ message=f"Image verification failed unexpectedly: {e}",
688
+ path="spec.modules",
689
+ ).format(), file=sys.stderr)
690
+ return [_unverifiable_image_finding(f"Image verification failed unexpectedly: {e}")]
691
+
692
+
693
+ def main() -> int:
694
+ # Load .env file if it exists
695
+ load_env_file()
696
+
697
+ parser = argparse.ArgumentParser(
698
+ prog="cds",
699
+ description=(
700
+ "Composable Data Stack (CDS): a compiler and CLI for declarative data "
701
+ "platforms. Define reusable modules (orchestrators, warehouses, BI tools, "
702
+ "caches, secrets providers) and wire them together in a profile; cds "
703
+ "validates, plans, and renders the stack to Docker Compose."
704
+ ),
705
+ )
706
+ parser.add_argument(
707
+ "-v",
708
+ "--version",
709
+ action="version",
710
+ version=f"%(prog)s {_cds_version()}",
711
+ )
712
+ subparsers = parser.add_subparsers(dest="command", required=True)
713
+
714
+ validate_parser = subparsers.add_parser("validate", help="Validate a profile")
715
+ _add_profile_arg(validate_parser)
716
+ _add_environment_arg(validate_parser)
717
+
718
+ plan_parser = subparsers.add_parser("plan", help="Build a resolved plan from a profile")
719
+ _add_profile_arg(plan_parser)
720
+ _add_environment_arg(plan_parser)
721
+ plan_parser.add_argument(
722
+ "--output",
723
+ "-o",
724
+ help="Save plan to file (default: print to stdout)",
725
+ )
726
+ plan_parser.add_argument("--json", action="store_true", help="Output plan as JSON (default when printing to stdout)")
727
+
728
+ render_parser = subparsers.add_parser(
729
+ "render",
730
+ help="Render docker compose from a profile or plan file",
731
+ )
732
+ render_parser.add_argument(
733
+ "profile_or_plan",
734
+ nargs="?",
735
+ help="Profile path/identifier or path to saved plan file. Uses CDS_PROFILE_PATH if set.",
736
+ )
737
+ _add_environment_arg(render_parser)
738
+ render_parser.add_argument(
739
+ "--output",
740
+ "-o",
741
+ help="Output file path for rendered output (default: <project-root>/docker-compose.yml)",
742
+ )
743
+
744
+ up_parser = subparsers.add_parser(
745
+ "up",
746
+ help="Validate, plan, render, build, and run the profile with docker compose",
747
+ )
748
+ _add_profile_arg(up_parser)
749
+ _add_environment_arg(up_parser)
750
+ up_parser.add_argument(
751
+ "--detach",
752
+ "-d",
753
+ action="store_true",
754
+ help="Return as soon as the stack starts, skipping the live state view "
755
+ "(docker compose always runs detached internally)",
756
+ )
757
+ up_parser.add_argument(
758
+ "--no-build",
759
+ action="store_true",
760
+ help="Skip docker compose build before starting services",
761
+ )
762
+ up_parser.add_argument(
763
+ "--log-file",
764
+ help="Path to write docker compose build/up/logs output to "
765
+ "(default: .cds/logs/up-<profile>-<timestamp>.log)",
766
+ )
767
+ up_parser.add_argument(
768
+ "--timeout",
769
+ type=float,
770
+ default=DEFAULT_TIMEOUT_SECONDS,
771
+ help=f"Seconds to wait for services to settle before giving up "
772
+ f"(default: {int(DEFAULT_TIMEOUT_SECONDS)}; ignored with --detach)",
773
+ )
774
+ up_parser.add_argument(
775
+ "--no-color",
776
+ action="store_true",
777
+ help="Disable colored labels in the live state view",
778
+ )
779
+
780
+ test_parser = subparsers.add_parser(
781
+ "test",
782
+ help="One-shot smoke validation: validate, security, plan, and render",
783
+ )
784
+ _add_profile_arg(test_parser)
785
+ _add_environment_arg(test_parser)
786
+ test_parser.add_argument(
787
+ "--reveal-secrets",
788
+ action="store_true",
789
+ help=(
790
+ "Print full, unredacted values in security findings (e.g. secrets embedded in a DSN/URL). "
791
+ "By default, values are redacted to avoid echoing real secrets to stdout/CI logs."
792
+ ),
793
+ )
794
+
795
+ preflight_parser = subparsers.add_parser(
796
+ "preflight",
797
+ help="Check runtime prerequisites without starting the profile",
798
+ )
799
+ _add_profile_arg(preflight_parser)
800
+ _add_environment_arg(preflight_parser)
801
+
802
+ state_parser = subparsers.add_parser(
803
+ "state",
804
+ help="Show running service status grouped by health",
805
+ )
806
+ _add_profile_arg(state_parser)
807
+ state_parser.add_argument(
808
+ "--no-color",
809
+ action="store_true",
810
+ help="Disable colored health labels even on a color-capable terminal",
811
+ )
812
+
813
+ init_parser = subparsers.add_parser(
814
+ "init",
815
+ help="Initialize a .env file from profile secret definitions",
816
+ )
817
+ _add_profile_arg(init_parser)
818
+ _add_environment_arg(init_parser)
819
+ init_parser.add_argument(
820
+ "--output",
821
+ "-o",
822
+ help="Output path for generated env file (default: <project-root>/.env)",
823
+ )
824
+ init_parser.add_argument(
825
+ "--force",
826
+ action="store_true",
827
+ help="Overwrite output file if it already exists",
828
+ )
829
+
830
+ list_parser = subparsers.add_parser("list", help="List available profiles or modules")
831
+ list_subparsers = list_parser.add_subparsers(dest="list_command", required=True)
832
+ list_subparsers.add_parser("profiles", help="List available profiles")
833
+ list_subparsers.add_parser("modules", help="List available module sources")
834
+ list_subparsers.add_parser("images", help="List images from module templates and check for newer versions")
835
+
836
+ security_parser = subparsers.add_parser("security", help="Run security validation on a profile")
837
+ _add_profile_arg(security_parser)
838
+ _add_environment_arg(security_parser)
839
+ security_parser.add_argument(
840
+ "--reveal-secrets",
841
+ action="store_true",
842
+ help=(
843
+ "Print full, unredacted values in findings (e.g. secrets embedded in a DSN/URL). "
844
+ "By default, values are redacted to avoid echoing real secrets to stdout/CI logs."
845
+ ),
846
+ )
847
+ security_parser.add_argument(
848
+ "--verify-images",
849
+ action="store_true",
850
+ help=(
851
+ "Also verify OCI image signatures and build provenance against the CDS "
852
+ "image policy. Uses cosign (keyless OIDC by default, CDS_COSIGN_KEY for "
853
+ "key-managed) or the signed-images fixture (CDS_SIGNED_IMAGES_FIXTURE / "
854
+ "tests/fixtures/signed-images.json) for offline verification."
855
+ ),
856
+ )
857
+
858
+ diff_parser = subparsers.add_parser(
859
+ "diff",
860
+ help="Show effective configuration differences between two environment overlays",
861
+ )
862
+ _add_profile_arg(diff_parser)
863
+ diff_parser.add_argument(
864
+ "--from",
865
+ dest="from_environment",
866
+ required=True,
867
+ help="Environment overlay to use as the baseline (e.g. dev).",
868
+ )
869
+ diff_parser.add_argument(
870
+ "--to",
871
+ dest="to_environment",
872
+ required=True,
873
+ help="Environment overlay to compare against the baseline (e.g. prod).",
874
+ )
875
+
876
+ use_parser = subparsers.add_parser(
877
+ "use",
878
+ help="Save (or show/clear) a default profile so it doesn't have to be passed on every command",
879
+ )
880
+ use_action = use_parser.add_argument(
881
+ "profile",
882
+ nargs="?",
883
+ help="Profile name to save as the default. Omit to show the currently saved default.",
884
+ )
885
+ use_parser.add_argument(
886
+ "--clear",
887
+ action="store_true",
888
+ help="Clear the saved default profile instead of setting one",
889
+ )
890
+ if argcomplete is not None:
891
+ use_action.completer = profile_completer # type: ignore[attr-defined]
892
+
893
+ completion_parser = subparsers.add_parser(
894
+ "completion",
895
+ help="Print shell setup instructions for cds tab-completion",
896
+ )
897
+ completion_parser.add_argument(
898
+ "shell",
899
+ choices=["bash", "zsh", "powershell"],
900
+ help="Shell to print setup instructions for",
901
+ )
902
+
903
+ if argcomplete is not None:
904
+ argcomplete.autocomplete(parser)
905
+
906
+ args = parser.parse_args()
907
+
908
+ if args.command == "validate":
909
+ try:
910
+ profile_path = resolve_profile_path(args.profile)
911
+ except ValueError as exc:
912
+ print(f"ERROR {exc}")
913
+ return 1
914
+
915
+ diagnostics = validate_profile(profile_path, environment=args.environment)
916
+
917
+ if diagnostics:
918
+ error_count = sum(1 for d in diagnostics if d.level == "error")
919
+ warning_count = sum(1 for d in diagnostics if d.level == "warning")
920
+
921
+ for d in diagnostics:
922
+ prefix = "ERROR" if d.level == "error" else "WARN"
923
+ print(f"{prefix} {d.format()}\n")
924
+
925
+ print(f"Validation completed with {error_count} error(s), {warning_count} warning(s).")
926
+ else:
927
+ print("Profile is valid.")
928
+
929
+ return 1 if has_errors(diagnostics) else 0
930
+
931
+ if args.command == "plan":
932
+ try:
933
+ profile_path = resolve_profile_path(args.profile)
934
+ except ValueError as exc:
935
+ print(f"ERROR {exc}")
936
+ return 1
937
+
938
+ diagnostics = validate_profile(profile_path, environment=args.environment)
939
+ if has_errors(diagnostics):
940
+ print_diagnostics(diagnostics)
941
+ print("Cannot build plan because validation failed.")
942
+ return 1
943
+
944
+ env_file = str(resolve_env_file_path(profile_path))
945
+ plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=args.environment)
946
+ all_diags = diagnostics + plan_diags
947
+
948
+ if has_errors(all_diags):
949
+ for d in all_diags:
950
+ prefix = "ERROR" if d.level == "error" else "WARN"
951
+ print(f"{prefix} {d.format()}\n")
952
+ print("Plan generation failed.")
953
+ return 1
954
+
955
+ plan_json = json.dumps(plan, indent=2)
956
+
957
+ if args.output:
958
+ # Save plan to file
959
+ output_file = Path(args.output)
960
+ _atomic_write_text(output_file, plan_json)
961
+ print(f"Plan saved to {args.output}")
962
+ else:
963
+ # Output to stdout
964
+ print(plan_json)
965
+
966
+ return 0
967
+
968
+ if args.command == "render":
969
+ # Determine if input is a plan file or profile
970
+ profile_or_plan = args.profile_or_plan
971
+ plan = None
972
+ plan_path = None
973
+ profile_path = None
974
+ all_diags = []
975
+
976
+ # Try to detect if it's a plan file
977
+ is_plan_file = False
978
+ if profile_or_plan:
979
+ candidate_path = Path(profile_or_plan)
980
+ if candidate_path.exists() and candidate_path.is_file():
981
+ # Try to load as plan
982
+ try:
983
+ plan_content = json.loads(candidate_path.read_text(encoding="utf-8"))
984
+ if isinstance(plan_content, dict) and plan_content.get("apiVersion") == "cds/v1alpha1":
985
+ is_plan_file = True
986
+ plan = plan_content
987
+ plan_path = candidate_path
988
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
989
+ pass
990
+
991
+ if is_plan_file:
992
+ if args.environment is not None:
993
+ print(
994
+ "ERROR --environment is not supported when rendering a saved Plan file; "
995
+ "the environment overlay was already applied when the Plan was built."
996
+ )
997
+ return 1
998
+
999
+ # Render from saved plan file
1000
+ if plan is None:
1001
+ print(f"ERROR Failed to load plan from {plan_path}")
1002
+ return 1
1003
+
1004
+ output_path = args.output
1005
+ if output_path is None:
1006
+ # Use project root from plan's sourceProfile, or cwd
1007
+ source_profile = Path(plan.get("sourceProfile", "."))
1008
+ output_path = str(resolve_project_root(str(source_profile)) / "docker-compose.yml")
1009
+
1010
+ env_file = str(resolve_env_file_path(str(source_profile)))
1011
+ compose_yaml, render_diags = render_compose(plan, output_path=output_path, env_file=env_file)
1012
+ all_diags = render_diags
1013
+
1014
+ if has_errors(all_diags):
1015
+ print_diagnostics(all_diags)
1016
+ print("Render failed.")
1017
+ return 1
1018
+
1019
+ print(f"Rendered compose file written to {output_path}")
1020
+ return 0
1021
+ else:
1022
+ # Render from profile (original behavior)
1023
+ try:
1024
+ profile_path = resolve_profile_path(profile_or_plan)
1025
+ except ValueError as exc:
1026
+ print(f"ERROR {exc}")
1027
+ return 1
1028
+
1029
+ diagnostics = validate_profile(profile_path, environment=args.environment)
1030
+ if has_errors(diagnostics):
1031
+ print_diagnostics(diagnostics)
1032
+ print("Cannot render because validation failed.")
1033
+ return 1
1034
+
1035
+ env_file = str(resolve_env_file_path(profile_path))
1036
+ plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=args.environment)
1037
+ all_diags = diagnostics + plan_diags
1038
+ if has_errors(all_diags):
1039
+ print_diagnostics(all_diags)
1040
+ print("Cannot render because plan generation failed.")
1041
+ return 1
1042
+
1043
+ output_path = args.output
1044
+ if output_path is None:
1045
+ output_path = str(resolve_project_root(profile_path) / "docker-compose.yml")
1046
+
1047
+ compose_yaml, render_diags = render_compose(plan, output_path=output_path, env_file=env_file)
1048
+ all_diags = all_diags + render_diags
1049
+
1050
+ if has_errors(all_diags):
1051
+ print_diagnostics(all_diags)
1052
+ print("Render failed.")
1053
+ return 1
1054
+
1055
+ print(f"Rendered compose file written to {output_path}")
1056
+
1057
+ return 0
1058
+
1059
+ if args.command == "up":
1060
+ try:
1061
+ profile_path = resolve_profile_path(args.profile)
1062
+ except ValueError as exc:
1063
+ print(f"ERROR {exc}")
1064
+ return 1
1065
+
1066
+ diagnostics = validate_profile(profile_path, environment=args.environment)
1067
+ if has_errors(diagnostics):
1068
+ print_diagnostics(diagnostics)
1069
+ print("Cannot start stack because validation failed.")
1070
+ return 1
1071
+
1072
+ env_file = str(resolve_env_file_path(profile_path))
1073
+ plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=args.environment)
1074
+ all_diags = diagnostics + plan_diags
1075
+ if has_errors(all_diags):
1076
+ print_diagnostics(all_diags)
1077
+ print("Cannot start stack because plan generation failed.")
1078
+ return 1
1079
+
1080
+ output_path = str(resolve_project_root(profile_path) / "docker-compose.yml")
1081
+ compose_yaml, render_diags = render_compose(plan, output_path=output_path, env_file=env_file)
1082
+ all_diags = all_diags + render_diags
1083
+ if has_errors(all_diags):
1084
+ print_diagnostics(all_diags)
1085
+ print("Cannot start stack because render failed.")
1086
+ return 1
1087
+
1088
+ print(f"Rendered compose file written to {output_path}")
1089
+
1090
+ up_cmd = ["docker", "compose", "-f", output_path, "up", "--detach"]
1091
+
1092
+ expected_service_count = None
1093
+ service_to_image: dict[str, str] = {}
1094
+ try:
1095
+ compose_doc = yaml.safe_load(compose_yaml) or {}
1096
+ services = compose_doc.get("services", {})
1097
+ expected_service_count = len(services)
1098
+ service_to_image = {
1099
+ name: definition["image"]
1100
+ for name, definition in services.items()
1101
+ if isinstance(definition, dict) and definition.get("image")
1102
+ }
1103
+ except yaml.YAMLError:
1104
+ pass
1105
+
1106
+ if args.log_file:
1107
+ log_path = Path(args.log_file)
1108
+ else:
1109
+ log_path = default_log_path(Path(profile_path).parent.name)
1110
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1111
+
1112
+ log_tail_process = None
1113
+ settled = True
1114
+ try:
1115
+ with open(log_path, "a", encoding="utf-8") as log_file:
1116
+ if not args.no_build:
1117
+ build_cmd = ["docker", "compose", "-f", output_path, "build"]
1118
+ print(f"Running: {' '.join(build_cmd)}")
1119
+ build_returncode = run_streamed(
1120
+ build_cmd,
1121
+ log_file,
1122
+ group_by_image=True,
1123
+ service_to_image=service_to_image,
1124
+ use_color=not args.no_color,
1125
+ )
1126
+ if build_returncode != 0:
1127
+ print(f"Build failed (exit {build_returncode}). See {log_path} for details.")
1128
+ return build_returncode
1129
+
1130
+ print(f"Running: {' '.join(up_cmd)}")
1131
+ if args.detach:
1132
+ up_returncode = run_streamed(up_cmd, log_file, echo=args.detach)
1133
+ if up_returncode != 0:
1134
+ print(f"'docker compose up' failed (exit {up_returncode}). See {log_path} for details.")
1135
+ return up_returncode
1136
+ print(
1137
+ f"Stack starting in the background. Run 'cds state' to check status; "
1138
+ f"full output in {log_path}."
1139
+ )
1140
+ return 0
1141
+
1142
+ print(
1143
+ "Switching to the live state view; full docker compose output "
1144
+ f"is being written to {log_path}."
1145
+ )
1146
+ # `docker compose up --detach` can itself block for a long time
1147
+ # waiting on healthcheck-gated `depends_on` dependencies before
1148
+ # it returns control, even with --detach. Run it in the
1149
+ # background so the live state view (driven by `docker compose
1150
+ # ps`, which is fast regardless) starts rendering immediately
1151
+ # instead of appearing only after `up` finishes.
1152
+ up_process = start_up_in_background(up_cmd, log_file)
1153
+
1154
+ # Deferred until `up` finishes (see on_up_finished below):
1155
+ # starting this immediately would have it write container
1156
+ # logs to log_file at the same time `up`'s own subprocess is
1157
+ # still writing its transcript there, interleaving output
1158
+ # mid-line.
1159
+ def _begin_log_tail(_up_exit_code: int) -> None:
1160
+ nonlocal log_tail_process
1161
+ log_tail_process = start_log_tail(output_path, log_file)
1162
+
1163
+ try:
1164
+ use_rich = sys.stdout.isatty() and not args.no_color
1165
+ if use_rich:
1166
+ from rich.live import Live
1167
+ from rich.text import Text
1168
+
1169
+ with Live(auto_refresh=True, refresh_per_second=4, vertical_overflow="visible") as live:
1170
+ settled, _grouped = poll_state_until_settled(
1171
+ output_path,
1172
+ expected_service_count=expected_service_count,
1173
+ timeout=args.timeout,
1174
+ use_color=True,
1175
+ redraw_fn=lambda text: live.update(
1176
+ Text.from_ansi(text),
1177
+ ),
1178
+ up_done_fn=up_process.poll,
1179
+ on_up_finished=_begin_log_tail,
1180
+ )
1181
+ else:
1182
+ settled, _grouped = poll_state_until_settled(
1183
+ output_path,
1184
+ expected_service_count=expected_service_count,
1185
+ timeout=args.timeout,
1186
+ use_color=(not args.no_color) and sys.stdout.isatty(),
1187
+ up_done_fn=up_process.poll,
1188
+ on_up_finished=_begin_log_tail,
1189
+ )
1190
+ except KeyboardInterrupt:
1191
+ # Don't wait on `up` here: it may still be blocked on a
1192
+ # healthcheck for a long time, and we're no longer
1193
+ # watching it once we've stopped polling, so blocking
1194
+ # process exit on it would be surprising. The stack (and
1195
+ # `up`) keep running in the background regardless.
1196
+ print(
1197
+ f"\nStopped watching; the stack keeps running. "
1198
+ f"Docker output logged to {log_path}."
1199
+ )
1200
+ return 130
1201
+
1202
+ while True:
1203
+ try:
1204
+ up_returncode = up_process.wait(timeout=0.5)
1205
+ break
1206
+ except subprocess.TimeoutExpired:
1207
+ continue
1208
+ if up_returncode != 0:
1209
+ print(f"'docker compose up' failed (exit {up_returncode}). See {log_path} for details.")
1210
+ return up_returncode
1211
+ except KeyboardInterrupt:
1212
+ print(
1213
+ f"\nInterrupted. The stack keeps running; "
1214
+ f"Docker output logged to {log_path}."
1215
+ )
1216
+ return 130
1217
+ except FileNotFoundError:
1218
+ print("ERROR docker was not found. Install Docker and ensure it is on your PATH.")
1219
+ return 1
1220
+ finally:
1221
+ if log_tail_process is not None:
1222
+ stop_log_tail(log_tail_process)
1223
+
1224
+ if not settled:
1225
+ print(
1226
+ f"\nStack did not settle within {args.timeout:.0f}s, or a service is unhealthy. "
1227
+ f"Run 'cds state' for the latest status; full output in {log_path}."
1228
+ )
1229
+ return 1
1230
+
1231
+ print(f"\nStack is up. Full output in {log_path}.")
1232
+ return 0
1233
+
1234
+ if args.command == "test":
1235
+ try:
1236
+ profile_path = resolve_profile_path(args.profile)
1237
+ except ValueError as exc:
1238
+ print(f"ERROR {exc}")
1239
+ return 1
1240
+
1241
+ print(f"== cds test: {args.profile} ==\n")
1242
+ stages: list[tuple[str, str]] = []
1243
+
1244
+ diagnostics = validate_profile(profile_path, environment=args.environment)
1245
+ validate_ok = not has_errors(diagnostics)
1246
+ stages.append(("validate", "PASS" if validate_ok else "FAIL"))
1247
+ if not validate_ok:
1248
+ print_diagnostics(diagnostics)
1249
+
1250
+ # Plan and render are computed once here (rather than once more per
1251
+ # stage) so the "security" stage's "rendered-compose"-scoped rules
1252
+ # (e.g. CDS-SEC-070) can reuse the same plan/rendered Compose the
1253
+ # later "plan"/"render" stages report on, instead of planning and
1254
+ # rendering the same profile a second time internally.
1255
+ env_file = str(resolve_env_file_path(profile_path))
1256
+ plan = None
1257
+ plan_diags: list[Diagnostic] = []
1258
+ plan_ok = False
1259
+ compose_yaml = None
1260
+ render_diags: list[Diagnostic] = []
1261
+ render_ok = False
1262
+ if validate_ok:
1263
+ plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=args.environment)
1264
+ plan_ok = not has_errors(diagnostics + plan_diags)
1265
+ if plan_ok:
1266
+ compose_yaml, render_diags = render_compose(plan, env_file=env_file)
1267
+ render_ok = not has_errors(render_diags)
1268
+
1269
+ security_ok = False
1270
+ if validate_ok:
1271
+ try:
1272
+ findings, sec_diags = run_security_validation(
1273
+ profile_path=Path(profile_path),
1274
+ env_file=env_file,
1275
+ environment=args.environment,
1276
+ redact_values=not args.reveal_secrets,
1277
+ precomputed_render=PrecomputedRender(
1278
+ plan=plan if plan_ok else None,
1279
+ rendered_compose_yaml=compose_yaml if render_ok else None,
1280
+ failed=not (plan_ok and render_ok),
1281
+ ),
1282
+ )
1283
+ for diag in sec_diags:
1284
+ print(diag.format(), file=sys.stderr)
1285
+ for f in findings:
1286
+ print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
1287
+ security_ok = not any(f["severity"] == "high" for f in findings)
1288
+ except Exception as e:
1289
+ print(Diagnostic(
1290
+ level="error",
1291
+ code="E095",
1292
+ message=f"Security validation failed unexpectedly: {e}",
1293
+ path="spec.modules",
1294
+ ).format(), file=sys.stderr)
1295
+ security_ok = False
1296
+ stages.append(("security", "PASS" if security_ok else "FAIL"))
1297
+ else:
1298
+ stages.append(("security", "SKIP"))
1299
+
1300
+ if validate_ok:
1301
+ if not plan_ok:
1302
+ print_diagnostics(plan_diags)
1303
+ stages.append(("plan", "PASS" if plan_ok else "FAIL"))
1304
+ else:
1305
+ stages.append(("plan", "SKIP"))
1306
+
1307
+ if validate_ok and plan_ok:
1308
+ if not render_ok:
1309
+ print_diagnostics(render_diags)
1310
+ stages.append(("render", "PASS" if render_ok else "FAIL"))
1311
+ else:
1312
+ stages.append(("render", "SKIP"))
1313
+
1314
+ print("\n-- Summary --")
1315
+ for name, status in stages:
1316
+ print(f"[{status}] {name}")
1317
+
1318
+ all_passed = all(status == "PASS" for _, status in stages)
1319
+ print("\nAll stages passed." if all_passed else "\nOne or more stages failed.")
1320
+ return 0 if all_passed else 1
1321
+
1322
+ if args.command == "preflight":
1323
+ try:
1324
+ profile_path = resolve_profile_path(args.profile)
1325
+ except ValueError as exc:
1326
+ print(f"ERROR {exc}")
1327
+ return 1
1328
+
1329
+ diagnostics = validate_profile(profile_path, environment=args.environment)
1330
+ if has_errors(diagnostics):
1331
+ print_diagnostics(diagnostics)
1332
+ print("Cannot run preflight because validation failed.")
1333
+ return 1
1334
+
1335
+ env_file = resolve_env_file_path(profile_path)
1336
+ plan, plan_diags = build_plan(profile_path, env_file=str(env_file), environment=args.environment)
1337
+ all_diags = diagnostics + plan_diags
1338
+ if has_errors(all_diags) or plan is None:
1339
+ print_diagnostics(all_diags)
1340
+ print("Cannot run preflight because plan generation failed.")
1341
+ return 1
1342
+
1343
+ compose_yaml, render_diags = render_compose(
1344
+ plan,
1345
+ env_file=str(env_file),
1346
+ )
1347
+ all_diags += render_diags
1348
+ if has_errors(all_diags):
1349
+ print_diagnostics(all_diags)
1350
+ print("Cannot run preflight because render failed.")
1351
+ return 1
1352
+
1353
+ checks = run_preflight(plan, compose_yaml, env_file)
1354
+ for check in checks:
1355
+ print(f"[{check.status}] {check.name}: {check.message}")
1356
+
1357
+ if preflight_passed(checks):
1358
+ print("\nPreflight passed.")
1359
+ return 0
1360
+
1361
+ print("\nPreflight failed.")
1362
+ return 1
1363
+
1364
+ if args.command == "state":
1365
+ try:
1366
+ profile_path = resolve_profile_path(args.profile)
1367
+ except ValueError as exc:
1368
+ print(f"ERROR {exc}")
1369
+ return 1
1370
+
1371
+ compose_path = resolve_project_root(profile_path) / "docker-compose.yml"
1372
+ if not compose_path.exists():
1373
+ print(f"ERROR {compose_path} not found. Run 'cds up' first.")
1374
+ return 1
1375
+
1376
+ ps_cmd = ["docker", "compose", "-f", str(compose_path), "ps", "-a", "--format", "json"]
1377
+ try:
1378
+ ps_result = subprocess.run(ps_cmd, capture_output=True, text=True) # nosec B603
1379
+ except FileNotFoundError:
1380
+ print("ERROR docker was not found. Install Docker and ensure it is on your PATH.")
1381
+ return 1
1382
+
1383
+ if ps_result.returncode != 0:
1384
+ print(ps_result.stderr or "ERROR docker compose ps failed.")
1385
+ return ps_result.returncode
1386
+
1387
+ services = parse_compose_ps_json(ps_result.stdout)
1388
+ grouped = group_services_by_health(services)
1389
+ use_color = (not args.no_color) and sys.stdout.isatty()
1390
+ print(format_state_output(grouped, use_color=use_color))
1391
+ return 0
1392
+
1393
+ if args.command == "init":
1394
+ try:
1395
+ profile_path = resolve_profile_path(args.profile)
1396
+ except ValueError as exc:
1397
+ print(f"ERROR {exc}")
1398
+ return 1
1399
+
1400
+ try:
1401
+ env_vars, secret_env_vars = _collect_profile_env_vars(profile_path, environment=args.environment)
1402
+ except ValueError as exc:
1403
+ print(f"ERROR {exc}")
1404
+ return 1
1405
+
1406
+ output_path = Path(args.output) if args.output else (resolve_project_root(profile_path) / ".env")
1407
+ try:
1408
+ _write_env_file(output_path, env_vars, secret_env_vars, profile_path, args.force)
1409
+ except FileExistsError as exc:
1410
+ print(f"ERROR {exc}")
1411
+ return 1
1412
+
1413
+ print(
1414
+ f"Initialized environment for {args.profile}.\n"
1415
+ "Please edit the values in the .env file, then run "
1416
+ f"`cds preflight {args.profile or Path(profile_path).parent.name}`."
1417
+ )
1418
+ return 0
1419
+
1420
+ if args.command == "list":
1421
+ if args.list_command == "profiles":
1422
+ for profile_name in list_profiles():
1423
+ print(profile_name)
1424
+ return 0
1425
+
1426
+ if args.list_command == "modules":
1427
+ for module_source in list_modules():
1428
+ print(module_source)
1429
+ return 0
1430
+
1431
+ if args.list_command == "images":
1432
+ module_root = get_modules_root()
1433
+ images = collect_module_images(module_root)
1434
+ if not images:
1435
+ print("No images found in modules.")
1436
+ return 0
1437
+
1438
+ update_cache: dict[tuple[str, str | None], dict[str, object]] = {}
1439
+
1440
+ for image_entry in images:
1441
+ dockerfile = image_entry.get("dockerfile")
1442
+ cache_key = (image_entry["image"], str(dockerfile) if dockerfile is not None else None)
1443
+ if cache_key not in update_cache:
1444
+ update_cache[cache_key] = check_image_update(
1445
+ image_entry["image"],
1446
+ dockerfile=dockerfile,
1447
+ )
1448
+ info = update_cache[cache_key]
1449
+ status = info["status"]
1450
+ if status == "update-available":
1451
+ print(
1452
+ f"{image_entry['module']}::{image_entry['service']}: {info['image']} -> update available: {info['latest']}"
1453
+ )
1454
+ elif status == "up-to-date":
1455
+ print(
1456
+ f"{image_entry['module']}::{image_entry['service']}: {info['image']} -> up to date"
1457
+ )
1458
+ elif status == "local":
1459
+ print(
1460
+ f"{image_entry['module']}::{image_entry['service']}: {info['image']} -> local image, no remote check"
1461
+ )
1462
+ elif status == "unsupported-registry":
1463
+ print(
1464
+ f"{image_entry['module']}::{image_entry['service']}: {info['image']} -> unsupported registry"
1465
+ )
1466
+ elif status == "lookup-failed":
1467
+ print(
1468
+ f"{image_entry['module']}::{image_entry['service']}: {info['image']} -> registry lookup failed"
1469
+ )
1470
+ else:
1471
+ print(
1472
+ f"{image_entry['module']}::{image_entry['service']}: {info['image']} -> unknown status"
1473
+ )
1474
+ return 0
1475
+
1476
+ if args.command == "security":
1477
+ try:
1478
+ profile_path = resolve_profile_path(args.profile)
1479
+ except ValueError as exc:
1480
+ print(f"ERROR {exc}")
1481
+ return 1
1482
+
1483
+ diagnostics = validate_profile(profile_path, environment=args.environment)
1484
+ if has_errors(diagnostics):
1485
+ print_diagnostics(diagnostics)
1486
+ print("Cannot run security validation because profile validation failed.")
1487
+ return 1
1488
+
1489
+ try:
1490
+ findings, diagnostics = run_security_validation(
1491
+ profile_path=Path(profile_path),
1492
+ env_file=str(resolve_env_file_path(profile_path)),
1493
+ environment=args.environment,
1494
+ redact_values=not args.reveal_secrets,
1495
+ )
1496
+ except Exception as e:
1497
+ print(Diagnostic(
1498
+ level="error",
1499
+ code="E095",
1500
+ message=f"Security validation failed unexpectedly: {e}",
1501
+ path="spec.modules",
1502
+ ).format(), file=sys.stderr)
1503
+ return 2
1504
+
1505
+ for diag in diagnostics:
1506
+ print(diag.format(), file=sys.stderr)
1507
+
1508
+ # A W096 warning means some rendered-compose-scoped rules (e.g.
1509
+ # CDS-SEC-070) were silently skipped because the profile couldn't be
1510
+ # planned/rendered. Unlike `cds test`, this command has no separate
1511
+ # plan/render stage to surface that failure, so treat it as a
1512
+ # non-zero exit rather than reporting "No security findings." as if
1513
+ # the scan were complete.
1514
+ render_scan_skipped = any(d.code == "W096" for d in diagnostics)
1515
+
1516
+ if args.verify_images:
1517
+ image_findings = _run_image_verification(profile_path, args.environment)
1518
+ findings.extend(image_findings)
1519
+ findings.sort(key=lambda f: (
1520
+ SEVERITY_ORDER.get(f["severity"], 99),
1521
+ f["rule_id"],
1522
+ f["path"],
1523
+ ))
1524
+
1525
+ if not findings:
1526
+ if render_scan_skipped:
1527
+ print("No security findings (some checks were skipped; see warnings above).")
1528
+ return 1
1529
+ print("No security findings.")
1530
+ return 0
1531
+
1532
+ for f in findings:
1533
+ print(f"[{f['severity'].upper()}] {f['rule_id']} {f['message']}")
1534
+ print(f" object: {f['path']}")
1535
+ print(f" module: {f['module']}")
1536
+ if f["value"] is not None:
1537
+ print(f" value: {f['value']}")
1538
+ for rec in f["recommendation"]:
1539
+ print(f" fix: {rec}")
1540
+ print()
1541
+
1542
+ return 1 if any(f["severity"] == "high" for f in findings) else 0
1543
+
1544
+ if args.command == "use":
1545
+ if args.clear and args.profile:
1546
+ print(f"ERROR --clear cannot be combined with a profile argument ('{args.profile}').")
1547
+ return 1
1548
+
1549
+ if args.clear:
1550
+ try:
1551
+ cleared = clear_saved_profile()
1552
+ except ConfigIOError as exc:
1553
+ print(f"ERROR {exc}")
1554
+ return 1
1555
+ if cleared:
1556
+ print(f"Cleared saved default profile ({get_config_path()}).")
1557
+ else:
1558
+ print("No saved default profile to clear.")
1559
+ return 0
1560
+
1561
+ if not args.profile:
1562
+ saved_profile = load_saved_profile()
1563
+ if saved_profile:
1564
+ print(saved_profile)
1565
+ else:
1566
+ print("No default profile saved. Run `cds use <profile>` to set one.")
1567
+ return 0
1568
+
1569
+ try:
1570
+ resolved = resolve_profile_path(args.profile)
1571
+ except ValueError as exc:
1572
+ print(f"ERROR {exc}")
1573
+ return 1
1574
+
1575
+ if not Path(resolved).is_file():
1576
+ print(f"ERROR Profile '{args.profile}' could not be found (looked for {resolved}).")
1577
+ return 1
1578
+
1579
+ # When CDS_PROFILE_PATH points directly at a single profile.yaml
1580
+ # file, resolve_profile_path() returns that file for *any* name
1581
+ # argument (there's no profiles directory to look names up under),
1582
+ # which would otherwise let `cds use <typo>` succeed silently and
1583
+ # save a bogus name as if it had been validated. Require the given
1584
+ # name to plausibly identify this profile before accepting it.
1585
+ profile_root = get_profiles_root()
1586
+ if profile_root.is_file() and Path(resolved).resolve() == profile_root.resolve():
1587
+ expected_names = {profile_root.stem, profile_root.parent.name}
1588
+ given_matches_file = Path(args.profile).resolve() == profile_root.resolve()
1589
+ if args.profile not in expected_names and not given_matches_file:
1590
+ print(
1591
+ f"ERROR CDS_PROFILE_PATH points to a single profile file ({profile_root}); "
1592
+ f"'{args.profile}' does not identify it. Pass the file path directly, "
1593
+ f"or use '{profile_root.stem}' or '{profile_root.parent.name}'."
1594
+ )
1595
+ return 1
1596
+
1597
+ try:
1598
+ config_path = save_profile(resolved)
1599
+ except ConfigIOError as exc:
1600
+ print(f"ERROR {exc}")
1601
+ return 1
1602
+ print(f"Saved default profile: {args.profile} (resolves to {resolved})")
1603
+ print(f"Stored in {config_path}")
1604
+ return 0
1605
+
1606
+ if args.command == "completion":
1607
+ print(_completion_instructions(args.shell))
1608
+ return 0
1609
+
1610
+ if args.command == "diff":
1611
+ try:
1612
+ profile_path = resolve_profile_path(args.profile)
1613
+ except ValueError as exc:
1614
+ print(f"ERROR {exc}")
1615
+ return 1
1616
+
1617
+ from_profile, _, from_diags = resolve_profile(profile_path, args.from_environment)
1618
+ to_profile, _, to_diags = resolve_profile(profile_path, args.to_environment)
1619
+ all_diags = from_diags + to_diags
1620
+
1621
+ if from_profile is None or to_profile is None:
1622
+ print_diagnostics(all_diags)
1623
+ print("Cannot diff because one or both environments failed to resolve.")
1624
+ return 1
1625
+ if all_diags:
1626
+ print_diagnostics(all_diags)
1627
+
1628
+ # Profiles only ever hold secret *references* (e.g. "secrets.db_password"),
1629
+ # never resolved secret values, so diffing the resolved profile dicts
1630
+ # directly cannot leak a secret value.
1631
+ changes: list[tuple[str, str, Any, Any]] = []
1632
+ _diff_values("", from_profile, to_profile, changes)
1633
+
1634
+ if not changes:
1635
+ print(f"No differences between environment '{args.from_environment}' and '{args.to_environment}'.")
1636
+ return 0
1637
+
1638
+ print(f"Differences from '{args.from_environment}' to '{args.to_environment}':\n")
1639
+ for path, kind, old, new in sorted(changes, key=lambda c: c[0]):
1640
+ if kind == "added":
1641
+ print(f" + {path}: {json.dumps(new)}")
1642
+ elif kind == "removed":
1643
+ print(f" - {path}: {json.dumps(old)}")
1644
+ else:
1645
+ print(f" ~ {path}: {json.dumps(old)} -> {json.dumps(new)}")
1646
+
1647
+ return 0
1648
+
1649
+
1650
+ print("Base validation not shown here.")
1651
+ return 0
1652
+
1653
+
1654
+
1655
+ if __name__ == "__main__":
1656
+ sys.exit(main())