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.
- {composable_data_stack-0.4.0/composable_data_stack.egg-info → composable_data_stack-0.5.0}/PKG-INFO +12 -2
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/README.md +10 -0
- composable_data_stack-0.5.0/cli/constants.py +8 -0
- composable_data_stack-0.5.0/cli/getter.py +592 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/image_verification.py +2 -1
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/main.py +174 -48
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/planner.py +1 -7
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/renderer.py +3 -23
- composable_data_stack-0.5.0/cli/resources/contract.schema.json +71 -0
- composable_data_stack-0.5.0/cli/resources/module.schema.json +204 -0
- composable_data_stack-0.5.0/cli/resources/profile.schema.json +227 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resources/rule-set.json +2 -111
- composable_data_stack-0.5.0/cli/utils.py +21 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/validator.py +82 -28
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0/composable_data_stack.egg-info}/PKG-INFO +12 -2
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/SOURCES.txt +8 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/requires.txt +1 -1
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/pyproject.toml +2 -2
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_cds_workflow.py +37 -0
- composable_data_stack-0.5.0/tests/test_dagster_definitions_uri.py +135 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_environment_cli.py +9 -2
- composable_data_stack-0.5.0/tests/test_getter.py +534 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_image_security_scan_workflow.py +4 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_image_verification.py +31 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_main.py +256 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_overlay.py +9 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_planner.py +16 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_preflight.py +22 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_publish_images_workflow.py +1 -1
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_render_example_profile.py +9 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_security.py +18 -5
- composable_data_stack-0.5.0/tests/test_validator.py +496 -0
- composable_data_stack-0.4.0/tests/test_validator.py +0 -253
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/LICENSE +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/__init__.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/diagnostics.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/graph.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/image_updates.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/loader.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/overlay.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/preflight.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resolver.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resources/__init__.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/resources/rule-schema.json +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/secrets.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/security.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/security_common.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/state.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/cli/up_runner.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/composable_data_stack.egg-info/top_level.txt +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/setup.cfg +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_completion.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_compose_runtime_smoke.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_dagster_config_generation.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_dagster_hardening.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_environment_classification.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_graph.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_image_updates.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_module_isolation.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_modules_no_committed_secrets.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_mvp_proof_workflow.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_python_version_alignment.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_release_version_check.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_renderer.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_resolve_profile_path.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_resolver.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_secrets.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_smoke_example_profile.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_state.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_superset_hardening.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_trivyignore.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_up_runner.py +0 -0
- {composable_data_stack-0.4.0 → composable_data_stack-0.5.0}/tests/test_upstream_image_hardening.py +0 -0
{composable_data_stack-0.4.0/composable_data_stack.egg-info → composable_data_stack-0.5.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: composable-data-stack
|
|
3
|
-
Version: 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.
|
|
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 (
|
|
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.
|