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