composable-data-stack 0.4.0__tar.gz → 0.5.0__tar.gz

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.
Files changed (76) hide show
  1. {composable_data_stack-0.4.0/composable_data_stack.egg-info → composable_data_stack-0.5.0}/PKG-INFO +12 -2
  2. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/README.md +10 -0
  3. composable_data_stack-0.5.0/cli/constants.py +8 -0
  4. composable_data_stack-0.5.0/cli/getter.py +592 -0
  5. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/image_verification.py +2 -1
  6. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/main.py +174 -48
  7. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/planner.py +1 -7
  8. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/renderer.py +3 -23
  9. composable_data_stack-0.5.0/cli/resources/contract.schema.json +71 -0
  10. composable_data_stack-0.5.0/cli/resources/module.schema.json +204 -0
  11. composable_data_stack-0.5.0/cli/resources/profile.schema.json +227 -0
  12. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resources/rule-set.json +2 -111
  13. composable_data_stack-0.5.0/cli/utils.py +21 -0
  14. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/validator.py +82 -28
  15. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0/composable_data_stack.egg-info}/PKG-INFO +12 -2
  16. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/SOURCES.txt +8 -0
  17. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/requires.txt +1 -1
  18. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/pyproject.toml +2 -2
  19. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_cds_workflow.py +37 -0
  20. composable_data_stack-0.5.0/tests/test_dagster_definitions_uri.py +135 -0
  21. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_environment_cli.py +9 -2
  22. composable_data_stack-0.5.0/tests/test_getter.py +534 -0
  23. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_image_security_scan_workflow.py +4 -0
  24. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_image_verification.py +31 -0
  25. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_main.py +256 -0
  26. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_overlay.py +9 -0
  27. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_planner.py +16 -0
  28. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_preflight.py +22 -0
  29. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_publish_images_workflow.py +1 -1
  30. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_render_example_profile.py +9 -0
  31. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_security.py +18 -5
  32. composable_data_stack-0.5.0/tests/test_validator.py +496 -0
  33. composable_data_stack-0.4.0/tests/test_validator.py +0 -253
  34. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/LICENSE +0 -0
  35. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/__init__.py +0 -0
  36. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/diagnostics.py +0 -0
  37. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/graph.py +0 -0
  38. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/image_updates.py +0 -0
  39. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/loader.py +0 -0
  40. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/overlay.py +0 -0
  41. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/preflight.py +0 -0
  42. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resolver.py +0 -0
  43. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resources/__init__.py +0 -0
  44. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resources/rule-schema.json +0 -0
  45. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/secrets.py +0 -0
  46. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/security.py +0 -0
  47. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/security_common.py +0 -0
  48. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/state.py +0 -0
  49. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/up_runner.py +0 -0
  50. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  51. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
  52. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/top_level.txt +0 -0
  53. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/setup.cfg +0 -0
  54. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_completion.py +0 -0
  55. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_compose_runtime_smoke.py +0 -0
  56. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_dagster_config_generation.py +0 -0
  57. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  58. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_dagster_hardening.py +0 -0
  59. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_environment_classification.py +0 -0
  60. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_graph.py +0 -0
  61. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_image_updates.py +0 -0
  62. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_module_isolation.py +0 -0
  63. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_modules_no_committed_secrets.py +0 -0
  64. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_mvp_proof_workflow.py +0 -0
  65. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_python_version_alignment.py +0 -0
  66. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_release_version_check.py +0 -0
  67. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_renderer.py +0 -0
  68. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_resolve_profile_path.py +0 -0
  69. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_resolver.py +0 -0
  70. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_secrets.py +0 -0
  71. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_smoke_example_profile.py +0 -0
  72. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_state.py +0 -0
  73. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_superset_hardening.py +0 -0
  74. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_trivyignore.py +0 -0
  75. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_up_runner.py +0 -0
  76. {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_upstream_image_hardening.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: composable-data-stack
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: CLI for validating and composing composable data stack profiles
5
5
  Author: Ronald Hensbergen
6
6
  License-Expression: Apache-2.0
@@ -31,7 +31,7 @@ Provides-Extra: dev
31
31
  Requires-Dist: coverage>=7.6; extra == "dev"
32
32
  Requires-Dist: bandit>=1.7; extra == "dev"
33
33
  Requires-Dist: pip-audit>=2.7; extra == "dev"
34
- Requires-Dist: ruff==0.16.2; extra == "dev"
34
+ Requires-Dist: ruff==0.16.4; extra == "dev"
35
35
  Dynamic: license-file
36
36
 
37
37
  # 🚀 Composable Data Stack (CDS)
@@ -642,6 +642,7 @@ before and never look for an `environments/` directory.
642
642
 
643
643
  |Command|Description|
644
644
  |---|---|
645
+ |cds get \<profile\> [--remote \<repo\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets into a local CDS layout|
645
646
  |cds init [profile]|Generate a project `.env` template from profile secret definitions|
646
647
  |cds validate [profile]|Validate modules and contracts|
647
648
  |cds preflight [profile]|Check runtime tools, required environment values, and host ports without starting services|
@@ -660,6 +661,15 @@ all accept `--environment <name>` (or `-e <name>`) to merge
660
661
  `environments/<name>.yaml` over the base profile before resolving; see
661
662
  [Environment Overlays](#environment-overlays).
662
663
 
664
+ `cds get` copies the selected `profiles/<name>/` tree, every referenced module
665
+ directory, and any local build-context assets referenced by those modules'
666
+ Dockerfiles. By default it reads from the current repository and writes into the
667
+ current working directory; use `--remote` to point at another checked-out
668
+ repository, `--into` to choose a destination root, `--dry-run` to inspect the
669
+ copy plan first, and `--force` to replace conflicting local files. Successful
670
+ fetches record tracking metadata in `.cds/get-manifest.json` for future update
671
+ workflows.
672
+
663
673
  `[profile]` accepts:
664
674
 
665
675
  | Form | Example |
@@ -606,6 +606,7 @@ before and never look for an `environments/` directory.
606
606
 
607
607
  |Command|Description|
608
608
  |---|---|
609
+ |cds get \<profile\> [--remote \<repo\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets into a local CDS layout|
609
610
  |cds init [profile]|Generate a project `.env` template from profile secret definitions|
610
611
  |cds validate [profile]|Validate modules and contracts|
611
612
  |cds preflight [profile]|Check runtime tools, required environment values, and host ports without starting services|
@@ -624,6 +625,15 @@ all accept `--environment <name>` (or `-e <name>`) to merge
624
625
  `environments/<name>.yaml` over the base profile before resolving; see
625
626
  [Environment Overlays](#environment-overlays).
626
627
 
628
+ `cds get` copies the selected `profiles/<name>/` tree, every referenced module
629
+ directory, and any local build-context assets referenced by those modules'
630
+ Dockerfiles. By default it reads from the current repository and writes into the
631
+ current working directory; use `--remote` to point at another checked-out
632
+ repository, `--into` to choose a destination root, `--dry-run` to inspect the
633
+ copy plan first, and `--force` to replace conflicting local files. Successful
634
+ fetches record tracking metadata in `.cds/get-manifest.json` for future update
635
+ workflows.
636
+
627
637
  `[profile]` accepts:
628
638
 
629
639
  | Form | Example |
@@ -0,0 +1,8 @@
1
+ # Guards recursive traversal of user-controlled YAML structures (schema
2
+ # defaults, contract/config interpolation) against maliciously or accidentally
3
+ # deeply nested documents that would otherwise raise an unhandled
4
+ # RecursionError / stack overflow.
5
+ MAX_NESTING_DEPTH = 100
6
+
7
+ class MaxNestingDepthExceeded(Exception):
8
+ """Raised when a recursive structure exceeds MAX_NESTING_DEPTH."""
@@ -0,0 +1,592 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shlex
6
+ import shutil
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .loader import load_yaml_file, resolve_module_dir
13
+ from .planner import MaxNestingDepthExceeded, apply_defaults, substitute_string
14
+
15
+
16
+ _TRACKING_FILE = Path(".cds") / "get-manifest.json"
17
+ _SKIP_DIRS = {
18
+ ".cds",
19
+ ".git",
20
+ ".mypy_cache",
21
+ ".pytest_cache",
22
+ ".ruff_cache",
23
+ ".venv",
24
+ "__pycache__",
25
+ "build",
26
+ "dist",
27
+ "htmlcov",
28
+ }
29
+ _SKIP_FILES = {
30
+ ".coverage",
31
+ ".coverage.xml",
32
+ ".env",
33
+ "docker-compose.yml",
34
+ }
35
+
36
+
37
+ class GetError(RuntimeError):
38
+ """Raised when `cds get` cannot complete safely."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class CopyAction:
43
+ source: Path
44
+ destination: Path
45
+ repo_relative_path: str
46
+
47
+
48
+ def fetch_profile(
49
+ profile: str,
50
+ *,
51
+ remote: str | None = None,
52
+ destination_root: Path | None = None,
53
+ force: bool = False,
54
+ dry_run: bool = False,
55
+ ) -> tuple[list[CopyAction], Path]:
56
+ source_repo = _resolve_source_repository(remote)
57
+ target_root = (destination_root or Path.cwd()).expanduser().resolve()
58
+ profile_path = _resolve_source_profile_path(source_repo, profile)
59
+ asset_roots = _collect_asset_roots(source_repo, profile_path)
60
+
61
+ actions = _build_copy_plan(source_repo, asset_roots, target_root)
62
+ if dry_run:
63
+ return actions, target_root / _TRACKING_FILE
64
+
65
+ conflicts = _find_conflicts(actions)
66
+ if conflicts and not force:
67
+ rendered = ", ".join(conflicts[:5])
68
+ extra = "" if len(conflicts) <= 5 else f" (+{len(conflicts) - 5} more)"
69
+ raise GetError(
70
+ "Refusing to overwrite existing files without --force: "
71
+ f"{rendered}{extra}"
72
+ )
73
+
74
+ for action in actions:
75
+ action.destination.parent.mkdir(parents=True, exist_ok=True)
76
+ shutil.copy2(action.source, action.destination)
77
+
78
+ _write_tracking_manifest(
79
+ target_root=target_root,
80
+ requested_profile=profile,
81
+ source_repo=source_repo,
82
+ profile_path=profile_path,
83
+ remote=remote,
84
+ actions=actions,
85
+ asset_roots=asset_roots,
86
+ )
87
+ return actions, target_root / _TRACKING_FILE
88
+
89
+
90
+ def format_get_plan(actions: list[CopyAction], *, destination_root: Path) -> str:
91
+ if not actions:
92
+ return f"No file changes required under {destination_root}."
93
+
94
+ lines = [f"Planned {len(actions)} file(s) under {destination_root}:"]
95
+ for action in actions[:20]:
96
+ lines.append(f" - {action.repo_relative_path}")
97
+ if len(actions) > 20:
98
+ lines.append(f" - ... {len(actions) - 20} more")
99
+ return "\n".join(lines)
100
+
101
+
102
+ def _resolve_source_repository(remote: str | None) -> Path:
103
+ candidate = Path(remote).expanduser() if remote else _find_project_root()
104
+ resolved = candidate.resolve()
105
+ if not resolved.exists():
106
+ raise GetError(f"Source repository does not exist: {resolved}")
107
+ if not resolved.is_dir():
108
+ raise GetError(f"Source repository must be a directory: {resolved}")
109
+ if not (resolved / "profiles").exists():
110
+ raise GetError(
111
+ f"Source repository must contain a profiles/ directory: {resolved}"
112
+ )
113
+ return resolved
114
+
115
+
116
+ def _find_project_root(start: Path | None = None) -> Path:
117
+ current = (start or Path.cwd()).resolve()
118
+ for directory in [current, *current.parents]:
119
+ if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
120
+ return directory
121
+ return current
122
+
123
+
124
+ def _resolve_source_profile_path(source_repo: Path, profile: str) -> Path:
125
+ profile_selector = Path(profile)
126
+ candidates = [
127
+ source_repo / profile_selector,
128
+ source_repo / "profiles" / profile_selector,
129
+ source_repo / "profiles" / profile_selector / "profile.yaml",
130
+ source_repo / "profiles" / f"{profile}.yaml",
131
+ ]
132
+
133
+ for candidate in candidates:
134
+ if candidate.is_dir() and (candidate / "profile.yaml").is_file():
135
+ resolved = (candidate / "profile.yaml").resolve()
136
+ _require_within_repo(resolved, source_repo, f'profile "{profile}"')
137
+ return resolved
138
+ if candidate.is_file() and candidate.suffix == ".yaml":
139
+ resolved = candidate.resolve()
140
+ _require_within_repo(resolved, source_repo, f'profile "{profile}"')
141
+ return resolved
142
+
143
+ raise GetError(
144
+ f'Could not resolve profile "{profile}" under {source_repo / "profiles"}'
145
+ )
146
+
147
+
148
+ def _collect_asset_roots(source_repo: Path, profile_path: Path) -> list[Path]:
149
+ profile_dir = profile_path.parent
150
+ asset_roots: set[Path] = {
151
+ profile_dir if profile_path.name == "profile.yaml" else profile_path
152
+ }
153
+ profile_doc, profile_diags = load_yaml_file(profile_path)
154
+ if profile_diags or profile_doc is None:
155
+ raise GetError(f"Could not load source profile {profile_path}")
156
+
157
+ spec = profile_doc.get("spec")
158
+ modules = spec.get("modules", []) if isinstance(spec, dict) else []
159
+ if not isinstance(modules, list):
160
+ raise GetError(f"Profile modules must be a list in {profile_path}")
161
+
162
+ for index, module in enumerate(modules):
163
+ if not isinstance(module, dict):
164
+ raise GetError(f"Profile module at index {index} must be a mapping")
165
+ source = module.get("source")
166
+ if not isinstance(source, str) or not source:
167
+ raise GetError(f'Profile module "{module.get("id", index)}" is missing a source')
168
+
169
+ module_dir = resolve_module_dir(source, profile_dir, module_root=None)
170
+ if module_dir is None or not module_dir.exists():
171
+ raise GetError(f'Could not resolve module source "{source}" from {profile_dir}')
172
+ _require_within_repo(module_dir, source_repo, f'module source "{source}"')
173
+ asset_roots.add(module_dir.resolve())
174
+ asset_roots.update(
175
+ _collect_module_runtime_assets(source_repo, module_dir.resolve(), module)
176
+ )
177
+
178
+ return sorted(asset_roots)
179
+
180
+
181
+ def _collect_module_runtime_assets(
182
+ source_repo: Path,
183
+ module_dir: Path,
184
+ module_instance: dict[str, Any],
185
+ ) -> set[Path]:
186
+ module_doc, module_diags = load_yaml_file(module_dir / "module.yaml")
187
+ if module_diags or module_doc is None:
188
+ raise GetError(f"Could not load module {module_dir / 'module.yaml'}")
189
+
190
+ compose = (
191
+ module_doc.get("spec", {})
192
+ .get("implementation", {})
193
+ .get("compose", {})
194
+ )
195
+ if not isinstance(compose, dict):
196
+ return set()
197
+
198
+ assets: set[Path] = set()
199
+ services = compose.get("services", {})
200
+ if not isinstance(services, dict):
201
+ return assets
202
+
203
+ context = _module_interpolation_context(module_doc, module_instance)
204
+
205
+ for service in services.values():
206
+ if not isinstance(service, dict):
207
+ continue
208
+ assets.update(
209
+ _collect_build_assets(
210
+ source_repo,
211
+ module_dir,
212
+ service.get("build"),
213
+ interpolation_context=context,
214
+ )
215
+ )
216
+
217
+ return assets
218
+
219
+
220
+ def _collect_build_assets(
221
+ source_repo: Path,
222
+ module_dir: Path,
223
+ build: Any,
224
+ *,
225
+ interpolation_context: dict[str, Any] | None,
226
+ ) -> set[Path]:
227
+ if build is None:
228
+ return set()
229
+
230
+ if isinstance(build, str):
231
+ context_value = build
232
+ dockerfile_name = "Dockerfile"
233
+ elif isinstance(build, dict):
234
+ context_value = build.get("context")
235
+ dockerfile_name = build.get("dockerfile", "Dockerfile")
236
+ else:
237
+ return set()
238
+
239
+ if isinstance(context_value, str):
240
+ context_value = _resolve_template_value(context_value, interpolation_context)
241
+ if (
242
+ not isinstance(context_value, str)
243
+ or not context_value
244
+ or _contains_template(context_value)
245
+ ):
246
+ return set()
247
+ if isinstance(dockerfile_name, str):
248
+ dockerfile_name = _resolve_template_value(dockerfile_name, interpolation_context)
249
+ if (
250
+ not isinstance(dockerfile_name, str)
251
+ or not dockerfile_name
252
+ or _contains_template(dockerfile_name)
253
+ ):
254
+ return set()
255
+
256
+ context_path = _resolve_existing_local_path(context_value, [module_dir, source_repo])
257
+ if context_path is None:
258
+ return set()
259
+ _require_within_repo(context_path, source_repo, f'build context "{context_value}"')
260
+
261
+ dockerfile_path = (
262
+ Path(dockerfile_name).resolve()
263
+ if Path(dockerfile_name).is_absolute()
264
+ else (context_path / dockerfile_name).resolve()
265
+ )
266
+ _require_within_repo(dockerfile_path, source_repo, f'build.dockerfile "{dockerfile_name}"')
267
+ if not dockerfile_path.exists():
268
+ return {context_path}
269
+
270
+ assets: set[Path] = {dockerfile_path.resolve()}
271
+ assets.update(_parse_dockerfile_assets(context_path, dockerfile_path.resolve()))
272
+ return assets
273
+
274
+
275
+ def _parse_dockerfile_assets(context_path: Path, dockerfile_path: Path) -> set[Path]:
276
+ try:
277
+ content = dockerfile_path.read_text(encoding="utf-8")
278
+ except OSError as exc:
279
+ raise GetError(f"Could not read Dockerfile {dockerfile_path}: {exc}") from exc
280
+
281
+ assets: set[Path] = set()
282
+ logical_lines: list[str] = []
283
+ current = ""
284
+ for raw_line in content.splitlines():
285
+ stripped = _strip_dockerfile_comment(raw_line).rstrip()
286
+ if not stripped:
287
+ if current:
288
+ logical_lines.append(current.strip())
289
+ current = ""
290
+ continue
291
+ if stripped.endswith("\\"):
292
+ current = f"{current} {stripped[:-1].rstrip()}".strip()
293
+ continue
294
+ current = f"{current} {stripped}".strip()
295
+ logical_lines.append(current)
296
+ current = ""
297
+ if current:
298
+ logical_lines.append(current.strip())
299
+
300
+ for line in logical_lines:
301
+ upper = line.upper()
302
+ if not (upper.startswith("COPY ") or upper.startswith("ADD ")):
303
+ continue
304
+ assets.update(
305
+ _extract_dockerfile_instruction_sources(context_path, dockerfile_path, line)
306
+ )
307
+
308
+ return assets
309
+
310
+
311
+ def _extract_dockerfile_instruction_sources(
312
+ context_path: Path,
313
+ dockerfile_path: Path,
314
+ line: str,
315
+ ) -> set[Path]:
316
+ instruction, remainder = line.split(None, 1)
317
+ remainder, copy_from_stage = _strip_dockerfile_instruction_options(remainder.strip())
318
+ if copy_from_stage or not remainder:
319
+ return set()
320
+
321
+ assets: set[Path] = set()
322
+ if remainder.startswith("["):
323
+ try:
324
+ values = json.loads(remainder)
325
+ except json.JSONDecodeError as exc:
326
+ raise GetError(
327
+ f"Could not parse {instruction.upper()} sources in {dockerfile_path}: {exc}"
328
+ ) from exc
329
+ if not isinstance(values, list) or len(values) < 2:
330
+ return assets
331
+ source_items = [value for value in values[:-1] if isinstance(value, str)]
332
+ else:
333
+ try:
334
+ parts = shlex.split(remainder)
335
+ except ValueError as exc:
336
+ raise GetError(
337
+ f"Could not parse {instruction.upper()} sources in {dockerfile_path}: {exc}"
338
+ ) from exc
339
+ if len(parts) < 2:
340
+ return assets
341
+ source_items = parts[:-1]
342
+
343
+ for source in source_items:
344
+ if not isinstance(source, str):
345
+ continue
346
+ if source == ".":
347
+ assets.add(context_path.resolve())
348
+ continue
349
+ if source.startswith("http://") or source.startswith("https://"):
350
+ continue
351
+ if _contains_template(source):
352
+ continue
353
+ if Path(source).is_absolute():
354
+ continue
355
+ matches = list(context_path.glob(source))
356
+ if not matches and "*" not in source and "?" not in source and "[" not in source:
357
+ matches = [context_path / source]
358
+ for match in matches:
359
+ if match.exists():
360
+ assets.add(match.resolve())
361
+
362
+ return assets
363
+
364
+
365
+ def _strip_dockerfile_instruction_options(remainder: str) -> tuple[str, bool]:
366
+ copy_from_stage = False
367
+ while remainder.startswith("--"):
368
+ parts = remainder.split(None, 1)
369
+ option = parts[0]
370
+ if option.startswith("--from="):
371
+ copy_from_stage = True
372
+ if len(parts) == 1:
373
+ return "", copy_from_stage
374
+ remainder = parts[1].strip()
375
+ return remainder, copy_from_stage
376
+
377
+
378
+ def _contains_template(value: str) -> bool:
379
+ return "${" in value
380
+
381
+
382
+ def _strip_dockerfile_comment(line: str) -> str:
383
+ in_single_quote = False
384
+ in_double_quote = False
385
+ escaped = False
386
+
387
+ for index, char in enumerate(line):
388
+ if escaped:
389
+ escaped = False
390
+ continue
391
+ if char == "\\":
392
+ escaped = True
393
+ continue
394
+ if char == "'" and not in_double_quote:
395
+ in_single_quote = not in_single_quote
396
+ continue
397
+ if char == '"' and not in_single_quote:
398
+ in_double_quote = not in_double_quote
399
+ continue
400
+ if (
401
+ char == "#"
402
+ and not in_single_quote
403
+ and not in_double_quote
404
+ and not line[:index].strip()
405
+ ):
406
+ return line[:index]
407
+
408
+ return line
409
+
410
+
411
+ def _module_interpolation_context(
412
+ module_doc: dict[str, Any],
413
+ module_instance: dict[str, Any],
414
+ ) -> dict[str, Any] | None:
415
+ config_schema = module_doc.get("spec", {}).get("configSchema")
416
+ if not isinstance(config_schema, dict):
417
+ return None
418
+
419
+ raw_config = module_instance.get("config", {})
420
+ if raw_config is None:
421
+ raw_config = {}
422
+ if not isinstance(raw_config, dict):
423
+ return None
424
+
425
+ try:
426
+ normalized_config = apply_defaults(raw_config, config_schema)
427
+ except MaxNestingDepthExceeded:
428
+ return None
429
+
430
+ return {
431
+ "config": normalized_config,
432
+ "bindings": {},
433
+ "service": {},
434
+ "secrets": {},
435
+ }
436
+
437
+
438
+ def _resolve_template_value(
439
+ value: str,
440
+ interpolation_context: dict[str, Any] | None,
441
+ ) -> Any:
442
+ if interpolation_context is None or not _contains_template(value):
443
+ return value
444
+ return substitute_string(value, interpolation_context)
445
+
446
+
447
+ def _resolve_existing_local_path(path_value: str, bases: list[Path]) -> Path | None:
448
+ candidate_path = Path(path_value)
449
+ if candidate_path.is_absolute():
450
+ return candidate_path.resolve() if candidate_path.exists() else None
451
+
452
+ normalized = candidate_path
453
+ if normalized.parts and normalized.parts[0] == ".":
454
+ normalized = normalized.relative_to(".")
455
+
456
+ for base in bases:
457
+ resolved = (base / normalized).resolve()
458
+ if resolved.exists():
459
+ return resolved
460
+ return None
461
+
462
+
463
+ def _require_within_repo(path_value: Path, source_repo: Path, label: str) -> None:
464
+ try:
465
+ path_value.resolve().relative_to(source_repo.resolve())
466
+ except ValueError as exc:
467
+ raise GetError(f"{label} resolves outside the source repository") from exc
468
+
469
+
470
+ def _build_copy_plan(
471
+ source_repo: Path,
472
+ asset_roots: list[Path],
473
+ destination_root: Path,
474
+ ) -> list[CopyAction]:
475
+ actions_by_destination: dict[Path, CopyAction] = {}
476
+
477
+ for asset_root in asset_roots:
478
+ if asset_root.is_file():
479
+ _add_copy_action(
480
+ source_repo=source_repo,
481
+ source_file=asset_root,
482
+ destination_root=destination_root,
483
+ actions_by_destination=actions_by_destination,
484
+ )
485
+ continue
486
+
487
+ for root, dirs, files in os.walk(asset_root):
488
+ dirs[:] = [name for name in dirs if name not in _SKIP_DIRS]
489
+ for file_name in sorted(files):
490
+ if file_name in _SKIP_FILES:
491
+ continue
492
+ source_file = Path(root) / file_name
493
+ _add_copy_action(
494
+ source_repo=source_repo,
495
+ source_file=source_file,
496
+ destination_root=destination_root,
497
+ actions_by_destination=actions_by_destination,
498
+ )
499
+
500
+ return sorted(actions_by_destination.values(), key=lambda action: action.repo_relative_path)
501
+
502
+
503
+ def _add_copy_action(
504
+ *,
505
+ source_repo: Path,
506
+ source_file: Path,
507
+ destination_root: Path,
508
+ actions_by_destination: dict[Path, CopyAction],
509
+ ) -> None:
510
+ repo_relative = source_file.resolve().relative_to(source_repo.resolve())
511
+ destination = (destination_root / repo_relative).resolve()
512
+ existing = actions_by_destination.get(destination)
513
+ if existing is None:
514
+ actions_by_destination[destination] = CopyAction(
515
+ source=source_file.resolve(),
516
+ destination=destination,
517
+ repo_relative_path=repo_relative.as_posix(),
518
+ )
519
+ return
520
+ if existing.source.resolve() != source_file.resolve():
521
+ raise GetError(
522
+ f"Multiple source files would map to the same destination: {destination}"
523
+ )
524
+
525
+
526
+ def _find_conflicts(actions: list[CopyAction]) -> list[str]:
527
+ conflicts: list[str] = []
528
+ for action in actions:
529
+ destination = action.destination
530
+ if not destination.exists():
531
+ continue
532
+ if destination.is_dir():
533
+ conflicts.append(action.repo_relative_path)
534
+ continue
535
+ if destination.read_bytes() != action.source.read_bytes():
536
+ conflicts.append(action.repo_relative_path)
537
+ return conflicts
538
+
539
+
540
+ def _write_tracking_manifest(
541
+ *,
542
+ target_root: Path,
543
+ requested_profile: str,
544
+ source_repo: Path,
545
+ profile_path: Path,
546
+ remote: str | None,
547
+ actions: list[CopyAction],
548
+ asset_roots: list[Path],
549
+ ) -> None:
550
+ manifest_path = target_root / _TRACKING_FILE
551
+ manifest = _read_tracking_manifest(manifest_path)
552
+
553
+ entry = {
554
+ "requestedProfile": requested_profile,
555
+ "sourceProfile": profile_path.relative_to(source_repo).as_posix(),
556
+ "remote": remote or str(source_repo),
557
+ "fetchedAt": datetime.now(UTC).isoformat(),
558
+ "assetRoots": [
559
+ _asset_root_relative_path(asset_root, source_repo) for asset_root in asset_roots
560
+ ],
561
+ "files": [action.repo_relative_path for action in actions],
562
+ }
563
+
564
+ profiles = manifest.setdefault("profiles", {})
565
+ if not isinstance(profiles, dict):
566
+ profiles = {}
567
+ manifest["profiles"] = profiles
568
+ profiles[requested_profile] = entry
569
+
570
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
571
+ manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
572
+
573
+
574
+ def _read_tracking_manifest(path: Path) -> dict[str, Any]:
575
+ if not path.exists():
576
+ return {"version": 1, "profiles": {}}
577
+ try:
578
+ data = json.loads(path.read_text(encoding="utf-8"))
579
+ except (OSError, json.JSONDecodeError):
580
+ return {"version": 1, "profiles": {}}
581
+ if not isinstance(data, dict):
582
+ return {"version": 1, "profiles": {}}
583
+ data.setdefault("version", 1)
584
+ data.setdefault("profiles", {})
585
+ return data
586
+
587
+
588
+ def _asset_root_relative_path(asset_root: Path, source_repo: Path) -> str:
589
+ try:
590
+ return asset_root.resolve().relative_to(source_repo.resolve()).as_posix()
591
+ except ValueError:
592
+ return asset_root.resolve().as_posix()
@@ -7,7 +7,8 @@ publication and attestation flow in .github/workflows/publish-images.yml:
7
7
 
8
8
  - Static policy checks over rendered Compose service images: trusted
9
9
  registry allowlist, digest pinning for production, and no floating
10
- ":latest" tags (the intent of the deferred CDS-SEC-050/051/052/054 rules).
10
+ ":latest" tags (reported as CDS-SEC-050/051/052 findings; these are not
11
+ rule-engine rules).
11
12
  - Cosign-compatible signature and provenance verification, pluggable via
12
13
  CDS_COSIGN_BIN. Keyless by default (OIDC issuer + certificate identity
13
14
  constraints); key-managed when CDS_COSIGN_KEY points at a key file.