composable-data-stack 0.7.0__tar.gz → 0.9.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.7.0 → composable_data_stack-0.9.0}/PKG-INFO +128 -11
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/README.md +124 -8
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/getter.py +114 -18
- composable_data_stack-0.9.0/cli/k8s_renderer.py +1471 -0
- composable_data_stack-0.9.0/cli/k8s_runner.py +211 -0
- composable_data_stack-0.9.0/cli/k8s_security.py +125 -0
- composable_data_stack-0.9.0/cli/loader.py +341 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/main.py +563 -85
- composable_data_stack-0.9.0/cli/overlay.py +427 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/planner.py +102 -9
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/preflight.py +17 -5
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/renderer.py +81 -9
- composable_data_stack-0.9.0/cli/resolver.py +71 -0
- composable_data_stack-0.9.0/cli/resources/module.schema.json +705 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/profile.schema.json +9 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/security.py +9 -7
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/state.py +56 -2
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/up_runner.py +50 -7
- composable_data_stack-0.9.0/cli/utils.py +35 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/validator.py +91 -18
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/PKG-INFO +128 -11
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/SOURCES.txt +22 -1
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/requires.txt +4 -2
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/pyproject.toml +6 -4
- composable_data_stack-0.9.0/tests/test_ai_profile_review.py +113 -0
- composable_data_stack-0.9.0/tests/test_compose_to_module.py +310 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_entrypoint_backend_guard.py +48 -0
- composable_data_stack-0.9.0/tests/test_dbt_hardening.py +132 -0
- composable_data_stack-0.9.0/tests/test_dlt_postgres_wiring.py +145 -0
- composable_data_stack-0.9.0/tests/test_duckdb_hardening.py +123 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_getter.py +215 -8
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_image_verification.py +130 -0
- composable_data_stack-0.9.0/tests/test_k3d_e2e_profile.py +52 -0
- composable_data_stack-0.9.0/tests/test_k3d_exposure.py +134 -0
- composable_data_stack-0.9.0/tests/test_k8s_renderer.py +577 -0
- composable_data_stack-0.9.0/tests/test_k8s_runner.py +124 -0
- composable_data_stack-0.9.0/tests/test_k8s_security.py +127 -0
- composable_data_stack-0.9.0/tests/test_loader.py +210 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_main.py +924 -0
- composable_data_stack-0.9.0/tests/test_markdown_mermaid_blocks.py +56 -0
- composable_data_stack-0.9.0/tests/test_overlay.py +732 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_planner.py +503 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_preflight.py +39 -1
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_publish_images_workflow.py +68 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_render_example_profile.py +4 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_renderer.py +300 -0
- composable_data_stack-0.9.0/tests/test_resolver.py +87 -0
- composable_data_stack-0.9.0/tests/test_secrets.py +160 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_security.py +190 -0
- composable_data_stack-0.9.0/tests/test_standalone_module_profile.py +102 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_state.py +52 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_superset_hardening.py +56 -14
- composable_data_stack-0.9.0/tests/test_tender_analytics.py +186 -0
- composable_data_stack-0.9.0/tests/test_tender_dashboard.py +175 -0
- composable_data_stack-0.9.0/tests/test_test_runner_timeout.py +69 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_up_runner.py +27 -0
- composable_data_stack-0.9.0/tests/test_utils.py +101 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_validator.py +164 -0
- composable_data_stack-0.9.0/tests/test_vendored_llm_client.py +89 -0
- composable_data_stack-0.7.0/cli/loader.py +0 -179
- composable_data_stack-0.7.0/cli/overlay.py +0 -239
- composable_data_stack-0.7.0/cli/resolver.py +0 -28
- composable_data_stack-0.7.0/cli/resources/module.schema.json +0 -204
- composable_data_stack-0.7.0/cli/utils.py +0 -22
- composable_data_stack-0.7.0/tests/test_overlay.py +0 -280
- composable_data_stack-0.7.0/tests/test_resolver.py +0 -53
- composable_data_stack-0.7.0/tests/test_secrets.py +0 -48
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/LICENSE +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/__init__.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/constants.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/diagnostics.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/graph.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/image_updates.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/image_verification.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/__init__.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/contract.schema.json +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/rule-schema.json +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/rule-set.json +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/secrets.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/security_common.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/top_level.txt +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/setup.cfg +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_cds_workflow.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_completion.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_compose_runtime_smoke.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_config_generation.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_definitions_uri.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_hardening.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_environment_classification.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_environment_cli.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_graph.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_image_security_scan_workflow.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_image_updates.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_module_isolation.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_modules_no_committed_secrets.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_mvp_proof_workflow.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_python_version_alignment.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_release_version_check.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_resolve_profile_path.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_smoke_example_profile.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_trivyignore.py +0 -0
- {composable_data_stack-0.7.0 → composable_data_stack-0.9.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.
|
|
3
|
+
Version: 0.9.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
|
|
@@ -22,16 +22,17 @@ Description-Content-Type: text/markdown
|
|
|
22
22
|
License-File: LICENSE
|
|
23
23
|
Requires-Dist: PyYAML>=6.0
|
|
24
24
|
Requires-Dist: jsonschema>=4.22.0
|
|
25
|
-
Requires-Dist: jinja2>=3.0
|
|
26
25
|
Requires-Dist: packaging>=23.0
|
|
27
26
|
Requires-Dist: rich>=15.0
|
|
28
27
|
Provides-Extra: completion
|
|
29
28
|
Requires-Dist: argcomplete<4,>=3.0.0; extra == "completion"
|
|
29
|
+
Provides-Extra: test
|
|
30
|
+
Requires-Dist: jinja2>=3.0; extra == "test"
|
|
30
31
|
Provides-Extra: dev
|
|
31
32
|
Requires-Dist: coverage>=7.6; extra == "dev"
|
|
32
33
|
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
33
34
|
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
34
|
-
Requires-Dist: ruff==0.16.
|
|
35
|
+
Requires-Dist: ruff==0.16.7; extra == "dev"
|
|
35
36
|
Dynamic: license-file
|
|
36
37
|
|
|
37
38
|
# 🚀 Composable Data Stack (CDS)
|
|
@@ -41,6 +42,31 @@ Dynamic: license-file
|
|
|
41
42
|
|
|
42
43
|

|
|
43
44
|
|
|
45
|
+
Project:
|
|
46
|
+
|
|
47
|
+
[](https://github.com/RonaldHensbergen/composable-data-stack/actions/workflows/ci.yml)
|
|
48
|
+
[](https://pypi.org/project/composable-data-stack/)
|
|
49
|
+
[](LICENSE)
|
|
50
|
+
|
|
51
|
+
SonarCloud — Quality Gate & Ratings:
|
|
52
|
+
|
|
53
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
54
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
55
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
56
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
57
|
+
|
|
58
|
+
SonarCloud — Issues:
|
|
59
|
+
|
|
60
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
61
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
62
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
63
|
+
|
|
64
|
+
SonarCloud — Metrics:
|
|
65
|
+
|
|
66
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
67
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
68
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
69
|
+
|
|
44
70
|
---
|
|
45
71
|
|
|
46
72
|
## 🧠 What Is CDS (In 1 Minute)
|
|
@@ -135,7 +161,6 @@ CDS wires modules through **contracts**, not direct dependencies. This section h
|
|
|
135
161
|
Below, `local-dagster-postgres-superset` wires Dagster to Postgres to Superset through contracts:
|
|
136
162
|
|
|
137
163
|
```mermaid
|
|
138
|
-
---
|
|
139
164
|
flowchart TD
|
|
140
165
|
Dagster[Dagster]
|
|
141
166
|
Postgres[(Postgres)]
|
|
@@ -174,7 +199,6 @@ consumption in Superset), see
|
|
|
174
199
|
[docs/profile-testing/failure-path-and-ci.md](docs/profile-testing/failure-path-and-ci.md).
|
|
175
200
|
|
|
176
201
|
```mermaid
|
|
177
|
-
---
|
|
178
202
|
flowchart TD
|
|
179
203
|
subgraph compile["Compile-time (cds)"]
|
|
180
204
|
direction TB
|
|
@@ -254,6 +278,7 @@ When you run CDS:
|
|
|
254
278
|
- resolved contract bindings
|
|
255
279
|
- dependency-aware execution plan
|
|
256
280
|
- generated Docker Compose configuration
|
|
281
|
+
- generated Helm chart for Kubernetes
|
|
257
282
|
- reproducible stack definition
|
|
258
283
|
|
|
259
284
|
This allows you to go from a declarative profile to a runnable local data stack.
|
|
@@ -286,6 +311,10 @@ The `cds up` command and generated local profiles additionally require:
|
|
|
286
311
|
- Write access to the checkout for `.env`, `docker-compose.yml`, and workdir
|
|
287
312
|
data
|
|
288
313
|
|
|
314
|
+
For Kubernetes, install Helm and kubectl and provide an explicit kube context.
|
|
315
|
+
The local k3s workflow uses k3d and is documented in
|
|
316
|
+
[docs/kubernetes.md](docs/kubernetes.md).
|
|
317
|
+
|
|
289
318
|
Docker Desktop on Windows must use the WSL 2 backend. See the
|
|
290
319
|
[support policy](docs/support-policy.md) for supported operating systems and
|
|
291
320
|
runtime versions. For platform-specific setup from an empty machine, follow
|
|
@@ -450,6 +479,12 @@ cds render local-dagster-postgres-superset
|
|
|
450
479
|
|
|
451
480
|
By default, this writes `docker-compose.yml` to the project root.
|
|
452
481
|
|
|
482
|
+
Render the same plan as a Helm chart with:
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
cds render local-dagster-postgres-superset --target helm
|
|
486
|
+
```
|
|
487
|
+
|
|
453
488
|
Use a custom location when needed:
|
|
454
489
|
|
|
455
490
|
```bash
|
|
@@ -639,18 +674,90 @@ prod`; commands without `--environment` then use that overlay. An explicit
|
|
|
639
674
|
|
|
640
675
|
---
|
|
641
676
|
|
|
677
|
+
### Profile Composition (`extends`)
|
|
678
|
+
|
|
679
|
+
A profile can also factor out shared configuration into one or more parent
|
|
680
|
+
profiles instead of duplicating it, using a top-level `extends` field:
|
|
681
|
+
|
|
682
|
+
```yaml
|
|
683
|
+
# profiles/analytics-prod/profile.yaml
|
|
684
|
+
apiVersion: cds/v1alpha1
|
|
685
|
+
kind: Profile
|
|
686
|
+
metadata:
|
|
687
|
+
name: analytics-prod
|
|
688
|
+
environment: production
|
|
689
|
+
extends:
|
|
690
|
+
- analytics-base # a bare name resolves to profiles/analytics-base/profile.yaml
|
|
691
|
+
spec:
|
|
692
|
+
modules:
|
|
693
|
+
- id: postgres
|
|
694
|
+
config:
|
|
695
|
+
storage:
|
|
696
|
+
size: 20Gi
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
`extends` accepts a non-empty list of parent references, each either a bare
|
|
700
|
+
profile name (resolved under the profiles root) or a path relative to the
|
|
701
|
+
child profile's directory (e.g. `../shared/profile.yaml`). Parents are
|
|
702
|
+
resolved and merged left-to-right — later parents win over earlier ones —
|
|
703
|
+
and then the child profile's own document is merged on top of all parents.
|
|
704
|
+
Composition uses the exact same deep-merge/module-merge-by-id engine as
|
|
705
|
+
environment overlays: mappings merge recursively, `spec.modules` entries
|
|
706
|
+
merge by stable `id`, and any other array is replaced wholesale rather than
|
|
707
|
+
concatenated. Parent profiles may themselves use `extends` (chains are
|
|
708
|
+
resolved transitively).
|
|
709
|
+
|
|
710
|
+
`extends` is not a CLI flag — it's read directly from `profile.yaml`, so
|
|
711
|
+
every profile-consuming command resolves it automatically, with no new
|
|
712
|
+
syntax to learn:
|
|
713
|
+
|
|
714
|
+
```bash
|
|
715
|
+
cds validate analytics-prod
|
|
716
|
+
cds plan analytics-prod
|
|
717
|
+
cds up analytics-prod
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
A profile can extend more than one parent, which is merged in the order
|
|
721
|
+
listed (later entries win over earlier ones):
|
|
722
|
+
|
|
723
|
+
```yaml
|
|
724
|
+
extends:
|
|
725
|
+
- networking-base
|
|
726
|
+
- observability-base
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
`extends` and `--environment` compose together: parents are merged first,
|
|
730
|
+
then the child, then the selected environment overlay is applied on top of
|
|
731
|
+
that fully-composed result — so a shared base profile and environment
|
|
732
|
+
promotion can both be used without duplicating configuration in either
|
|
733
|
+
dimension.
|
|
734
|
+
|
|
735
|
+
A malformed `extends` chain fails validation/planning before anything else
|
|
736
|
+
runs, with a dedicated diagnostic code:
|
|
737
|
+
|
|
738
|
+
|Code|Meaning|
|
|
739
|
+
|---|---|
|
|
740
|
+
|E110|`extends` is missing, not a list, empty, or contains a non-string/empty entry|
|
|
741
|
+
|E111|A parent reference resolves outside the profiles root|
|
|
742
|
+
|E112|A referenced parent profile does not exist|
|
|
743
|
+
|E113|A cycle was detected in the `extends` chain|
|
|
744
|
+
|
|
745
|
+
---
|
|
746
|
+
|
|
642
747
|
## ⚙️ CLI
|
|
643
748
|
|
|
644
749
|
|Command|Description|
|
|
645
750
|
|---|---|
|
|
646
751
|
|cds get \<profile\> [--remote \<owner/repo\>] [--ref \<ref\>] [--local \<dir\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets from GitHub into a local CDS layout|
|
|
752
|
+
|cds list profiles\|modules\|images [--remote \<owner/repo\>] [--ref \<ref\>] [--local \<dir\>]|List available profiles, module sources, or module images and check for newer versions; add `--remote`/`--local` to inspect another repository before fetching from it|
|
|
647
753
|
|cds init [profile]|Generate a project `.env` template from profile secret definitions|
|
|
648
|
-
|cds validate [profile]|Validate modules and contracts|
|
|
754
|
+
|cds validate [profile]|Validate modules and contracts; use `--target helm` for Kubernetes checks|
|
|
649
755
|
|cds preflight [profile]|Check runtime tools, required environment values, and host ports without starting services|
|
|
650
756
|
|cds plan [profile]|Resolve dependencies and generate an execution plan|
|
|
651
|
-
|cds render [profile]|Generate Docker Compose
|
|
652
|
-
|cds up [profile]|Validate, plan, render,
|
|
653
|
-
|cds
|
|
757
|
+
|cds render [profile]|Generate Docker Compose or a Helm chart from a resolved plan|
|
|
758
|
+
|cds up [profile]|Validate, plan, render, and start the Compose or Helm target; use `--target helm` for Kubernetes|
|
|
759
|
+
|cds down [profile]|Stop Compose or uninstall a Helm release; Helm PVCs are retained by default|
|
|
760
|
+
|cds state [profile]|Show Compose services or Kubernetes workloads grouped by health|
|
|
654
761
|
|cds test [profile]|One-shot smoke validation: validate, security, plan, and render|
|
|
655
762
|
|cds security [profile]|Run rule-based security validation on a profile|
|
|
656
763
|
|cds diff [profile] --from \<env\> --to \<env\>|Show effective configuration differences between two environment overlays, secrets never included|
|
|
@@ -676,6 +783,12 @@ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
|
|
|
676
783
|
replace conflicting local files. Successful fetches record tracking metadata
|
|
677
784
|
in `.cds/get-manifest.json` for future update workflows.
|
|
678
785
|
|
|
786
|
+
`cds list profiles`, `cds list modules`, and `cds list images` accept the same
|
|
787
|
+
`--remote`/`--ref`/`--local` source-repository selection as `cds get`, so you
|
|
788
|
+
can discover what's available in another repository (a fork, or an existing
|
|
789
|
+
local checkout) before running `cds get` against it. Without these flags,
|
|
790
|
+
`cds list` inspects the local project as before.
|
|
791
|
+
|
|
679
792
|
### Project defaults
|
|
680
793
|
|
|
681
794
|
`cds config` manages the gitignored `.cds/config.json` file (or the path in
|
|
@@ -755,6 +868,8 @@ Common errors from `cds validate`, `cds plan`, and `cds render`, and how to fix
|
|
|
755
868
|
| `[E041] ... Contract ref "x.y" points to unknown module "x"` | A `consumes` binding's `contractRef` refers to a module ID that isn't defined in the profile. | Check `spec.modules` for the correct module `id`, and confirm the contract ref follows `<module-id>.<contract-name>`. |
|
|
756
869
|
| `[E041] ... but it does not provide "<contract-name>"` | The referenced module exists, but its `spec.provides` list doesn't expose that contract name. | Check the producing module's `module.yaml` for the contracts it actually provides, and fix the consumer's `contractRef` to match. |
|
|
757
870
|
| `[E042] ... Contract kind mismatch` | The consumer expects one contract kind (e.g. `sql-database`) but the producer exposes a different kind. | Point the binding at a module that provides the expected contract kind, or update the consumer's expected kind if the mismatch is intentional. |
|
|
871
|
+
| `[E103] ... config.image.tag is required ... when config.image.source is "registry"` | A module instance sets `config.image.source: registry` without also setting `config.image.tag`. | Set `config.image.tag` to a version, or switch back to `config.image.source: build`. |
|
|
872
|
+
| `[W097] ... config.image.tag is "latest" with config.image.source "registry"` | `config.image.tag: latest` under `source: registry` still validates, but drifts silently between deploys instead of pinning a reproducible version. | Pin an explicit tag from `publish-images.yml`'s output (or `tests/fixtures/signed-images.json`) instead of `latest`. |
|
|
758
873
|
|
|
759
874
|
All diagnostics print with their error code and YAML path (e.g. `spec.modules[1].config`), so search the profile file for that path to find the exact line to fix.
|
|
760
875
|
|
|
@@ -860,11 +975,11 @@ MVP ready:
|
|
|
860
975
|
- security checks
|
|
861
976
|
- profile composition
|
|
862
977
|
- Docker Compose rendering
|
|
978
|
+
- Kubernetes Helm rendering and local k3s lifecycle
|
|
979
|
+
- runtime startup, health state, and shutdown
|
|
863
980
|
|
|
864
981
|
Next:
|
|
865
982
|
|
|
866
|
-
- runtime orchestration
|
|
867
|
-
- Kubernetes support
|
|
868
983
|
- advanced secret providers
|
|
869
984
|
- stack bootstrap and health checks
|
|
870
985
|
|
|
@@ -899,6 +1014,8 @@ Good first contributions:
|
|
|
899
1014
|
## 📖 Documentation
|
|
900
1015
|
|
|
901
1016
|
- [Quickstart](README.md#-quickstart) — get running in 5 minutes
|
|
1017
|
+
- [How CDS Works](docs/how-it-works.md) — C4 + sequence diagrams of the validate → plan → render engine
|
|
1018
|
+
- [Kubernetes Target](docs/kubernetes.md): Helm rendering and isolated local k3s workflow
|
|
902
1019
|
- [From Docker Compose to CDS Profile](docs/from-docker-to-cds-profile.md) — complete transformation guide
|
|
903
1020
|
- [Architecture](docs/architecture.md) — design and core concepts
|
|
904
1021
|
- [Modules](docs/modules.md) — how to structure reusable components
|
|
@@ -5,6 +5,31 @@
|
|
|
5
5
|
|
|
6
6
|

|
|
7
7
|
|
|
8
|
+
Project:
|
|
9
|
+
|
|
10
|
+
[](https://github.com/RonaldHensbergen/composable-data-stack/actions/workflows/ci.yml)
|
|
11
|
+
[](https://pypi.org/project/composable-data-stack/)
|
|
12
|
+
[](LICENSE)
|
|
13
|
+
|
|
14
|
+
SonarCloud — Quality Gate & Ratings:
|
|
15
|
+
|
|
16
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
17
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
18
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
19
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
20
|
+
|
|
21
|
+
SonarCloud — Issues:
|
|
22
|
+
|
|
23
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
24
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
25
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
26
|
+
|
|
27
|
+
SonarCloud — Metrics:
|
|
28
|
+
|
|
29
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
30
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
31
|
+
[](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
|
|
32
|
+
|
|
8
33
|
---
|
|
9
34
|
|
|
10
35
|
## 🧠 What Is CDS (In 1 Minute)
|
|
@@ -99,7 +124,6 @@ CDS wires modules through **contracts**, not direct dependencies. This section h
|
|
|
99
124
|
Below, `local-dagster-postgres-superset` wires Dagster to Postgres to Superset through contracts:
|
|
100
125
|
|
|
101
126
|
```mermaid
|
|
102
|
-
---
|
|
103
127
|
flowchart TD
|
|
104
128
|
Dagster[Dagster]
|
|
105
129
|
Postgres[(Postgres)]
|
|
@@ -138,7 +162,6 @@ consumption in Superset), see
|
|
|
138
162
|
[docs/profile-testing/failure-path-and-ci.md](docs/profile-testing/failure-path-and-ci.md).
|
|
139
163
|
|
|
140
164
|
```mermaid
|
|
141
|
-
---
|
|
142
165
|
flowchart TD
|
|
143
166
|
subgraph compile["Compile-time (cds)"]
|
|
144
167
|
direction TB
|
|
@@ -218,6 +241,7 @@ When you run CDS:
|
|
|
218
241
|
- resolved contract bindings
|
|
219
242
|
- dependency-aware execution plan
|
|
220
243
|
- generated Docker Compose configuration
|
|
244
|
+
- generated Helm chart for Kubernetes
|
|
221
245
|
- reproducible stack definition
|
|
222
246
|
|
|
223
247
|
This allows you to go from a declarative profile to a runnable local data stack.
|
|
@@ -250,6 +274,10 @@ The `cds up` command and generated local profiles additionally require:
|
|
|
250
274
|
- Write access to the checkout for `.env`, `docker-compose.yml`, and workdir
|
|
251
275
|
data
|
|
252
276
|
|
|
277
|
+
For Kubernetes, install Helm and kubectl and provide an explicit kube context.
|
|
278
|
+
The local k3s workflow uses k3d and is documented in
|
|
279
|
+
[docs/kubernetes.md](docs/kubernetes.md).
|
|
280
|
+
|
|
253
281
|
Docker Desktop on Windows must use the WSL 2 backend. See the
|
|
254
282
|
[support policy](docs/support-policy.md) for supported operating systems and
|
|
255
283
|
runtime versions. For platform-specific setup from an empty machine, follow
|
|
@@ -414,6 +442,12 @@ cds render local-dagster-postgres-superset
|
|
|
414
442
|
|
|
415
443
|
By default, this writes `docker-compose.yml` to the project root.
|
|
416
444
|
|
|
445
|
+
Render the same plan as a Helm chart with:
|
|
446
|
+
|
|
447
|
+
```bash
|
|
448
|
+
cds render local-dagster-postgres-superset --target helm
|
|
449
|
+
```
|
|
450
|
+
|
|
417
451
|
Use a custom location when needed:
|
|
418
452
|
|
|
419
453
|
```bash
|
|
@@ -603,18 +637,90 @@ prod`; commands without `--environment` then use that overlay. An explicit
|
|
|
603
637
|
|
|
604
638
|
---
|
|
605
639
|
|
|
640
|
+
### Profile Composition (`extends`)
|
|
641
|
+
|
|
642
|
+
A profile can also factor out shared configuration into one or more parent
|
|
643
|
+
profiles instead of duplicating it, using a top-level `extends` field:
|
|
644
|
+
|
|
645
|
+
```yaml
|
|
646
|
+
# profiles/analytics-prod/profile.yaml
|
|
647
|
+
apiVersion: cds/v1alpha1
|
|
648
|
+
kind: Profile
|
|
649
|
+
metadata:
|
|
650
|
+
name: analytics-prod
|
|
651
|
+
environment: production
|
|
652
|
+
extends:
|
|
653
|
+
- analytics-base # a bare name resolves to profiles/analytics-base/profile.yaml
|
|
654
|
+
spec:
|
|
655
|
+
modules:
|
|
656
|
+
- id: postgres
|
|
657
|
+
config:
|
|
658
|
+
storage:
|
|
659
|
+
size: 20Gi
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
`extends` accepts a non-empty list of parent references, each either a bare
|
|
663
|
+
profile name (resolved under the profiles root) or a path relative to the
|
|
664
|
+
child profile's directory (e.g. `../shared/profile.yaml`). Parents are
|
|
665
|
+
resolved and merged left-to-right — later parents win over earlier ones —
|
|
666
|
+
and then the child profile's own document is merged on top of all parents.
|
|
667
|
+
Composition uses the exact same deep-merge/module-merge-by-id engine as
|
|
668
|
+
environment overlays: mappings merge recursively, `spec.modules` entries
|
|
669
|
+
merge by stable `id`, and any other array is replaced wholesale rather than
|
|
670
|
+
concatenated. Parent profiles may themselves use `extends` (chains are
|
|
671
|
+
resolved transitively).
|
|
672
|
+
|
|
673
|
+
`extends` is not a CLI flag — it's read directly from `profile.yaml`, so
|
|
674
|
+
every profile-consuming command resolves it automatically, with no new
|
|
675
|
+
syntax to learn:
|
|
676
|
+
|
|
677
|
+
```bash
|
|
678
|
+
cds validate analytics-prod
|
|
679
|
+
cds plan analytics-prod
|
|
680
|
+
cds up analytics-prod
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
A profile can extend more than one parent, which is merged in the order
|
|
684
|
+
listed (later entries win over earlier ones):
|
|
685
|
+
|
|
686
|
+
```yaml
|
|
687
|
+
extends:
|
|
688
|
+
- networking-base
|
|
689
|
+
- observability-base
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
`extends` and `--environment` compose together: parents are merged first,
|
|
693
|
+
then the child, then the selected environment overlay is applied on top of
|
|
694
|
+
that fully-composed result — so a shared base profile and environment
|
|
695
|
+
promotion can both be used without duplicating configuration in either
|
|
696
|
+
dimension.
|
|
697
|
+
|
|
698
|
+
A malformed `extends` chain fails validation/planning before anything else
|
|
699
|
+
runs, with a dedicated diagnostic code:
|
|
700
|
+
|
|
701
|
+
|Code|Meaning|
|
|
702
|
+
|---|---|
|
|
703
|
+
|E110|`extends` is missing, not a list, empty, or contains a non-string/empty entry|
|
|
704
|
+
|E111|A parent reference resolves outside the profiles root|
|
|
705
|
+
|E112|A referenced parent profile does not exist|
|
|
706
|
+
|E113|A cycle was detected in the `extends` chain|
|
|
707
|
+
|
|
708
|
+
---
|
|
709
|
+
|
|
606
710
|
## ⚙️ CLI
|
|
607
711
|
|
|
608
712
|
|Command|Description|
|
|
609
713
|
|---|---|
|
|
610
714
|
|cds get \<profile\> [--remote \<owner/repo\>] [--ref \<ref\>] [--local \<dir\>] [--into \<dir\>]|Fetch a profile plus its dependent module/runtime assets from GitHub into a local CDS layout|
|
|
715
|
+
|cds list profiles\|modules\|images [--remote \<owner/repo\>] [--ref \<ref\>] [--local \<dir\>]|List available profiles, module sources, or module images and check for newer versions; add `--remote`/`--local` to inspect another repository before fetching from it|
|
|
611
716
|
|cds init [profile]|Generate a project `.env` template from profile secret definitions|
|
|
612
|
-
|cds validate [profile]|Validate modules and contracts|
|
|
717
|
+
|cds validate [profile]|Validate modules and contracts; use `--target helm` for Kubernetes checks|
|
|
613
718
|
|cds preflight [profile]|Check runtime tools, required environment values, and host ports without starting services|
|
|
614
719
|
|cds plan [profile]|Resolve dependencies and generate an execution plan|
|
|
615
|
-
|cds render [profile]|Generate Docker Compose
|
|
616
|
-
|cds up [profile]|Validate, plan, render,
|
|
617
|
-
|cds
|
|
720
|
+
|cds render [profile]|Generate Docker Compose or a Helm chart from a resolved plan|
|
|
721
|
+
|cds up [profile]|Validate, plan, render, and start the Compose or Helm target; use `--target helm` for Kubernetes|
|
|
722
|
+
|cds down [profile]|Stop Compose or uninstall a Helm release; Helm PVCs are retained by default|
|
|
723
|
+
|cds state [profile]|Show Compose services or Kubernetes workloads grouped by health|
|
|
618
724
|
|cds test [profile]|One-shot smoke validation: validate, security, plan, and render|
|
|
619
725
|
|cds security [profile]|Run rule-based security validation on a profile|
|
|
620
726
|
|cds diff [profile] --from \<env\> --to \<env\>|Show effective configuration differences between two environment overlays, secrets never included|
|
|
@@ -640,6 +746,12 @@ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
|
|
|
640
746
|
replace conflicting local files. Successful fetches record tracking metadata
|
|
641
747
|
in `.cds/get-manifest.json` for future update workflows.
|
|
642
748
|
|
|
749
|
+
`cds list profiles`, `cds list modules`, and `cds list images` accept the same
|
|
750
|
+
`--remote`/`--ref`/`--local` source-repository selection as `cds get`, so you
|
|
751
|
+
can discover what's available in another repository (a fork, or an existing
|
|
752
|
+
local checkout) before running `cds get` against it. Without these flags,
|
|
753
|
+
`cds list` inspects the local project as before.
|
|
754
|
+
|
|
643
755
|
### Project defaults
|
|
644
756
|
|
|
645
757
|
`cds config` manages the gitignored `.cds/config.json` file (or the path in
|
|
@@ -719,6 +831,8 @@ Common errors from `cds validate`, `cds plan`, and `cds render`, and how to fix
|
|
|
719
831
|
| `[E041] ... Contract ref "x.y" points to unknown module "x"` | A `consumes` binding's `contractRef` refers to a module ID that isn't defined in the profile. | Check `spec.modules` for the correct module `id`, and confirm the contract ref follows `<module-id>.<contract-name>`. |
|
|
720
832
|
| `[E041] ... but it does not provide "<contract-name>"` | The referenced module exists, but its `spec.provides` list doesn't expose that contract name. | Check the producing module's `module.yaml` for the contracts it actually provides, and fix the consumer's `contractRef` to match. |
|
|
721
833
|
| `[E042] ... Contract kind mismatch` | The consumer expects one contract kind (e.g. `sql-database`) but the producer exposes a different kind. | Point the binding at a module that provides the expected contract kind, or update the consumer's expected kind if the mismatch is intentional. |
|
|
834
|
+
| `[E103] ... config.image.tag is required ... when config.image.source is "registry"` | A module instance sets `config.image.source: registry` without also setting `config.image.tag`. | Set `config.image.tag` to a version, or switch back to `config.image.source: build`. |
|
|
835
|
+
| `[W097] ... config.image.tag is "latest" with config.image.source "registry"` | `config.image.tag: latest` under `source: registry` still validates, but drifts silently between deploys instead of pinning a reproducible version. | Pin an explicit tag from `publish-images.yml`'s output (or `tests/fixtures/signed-images.json`) instead of `latest`. |
|
|
722
836
|
|
|
723
837
|
All diagnostics print with their error code and YAML path (e.g. `spec.modules[1].config`), so search the profile file for that path to find the exact line to fix.
|
|
724
838
|
|
|
@@ -824,11 +938,11 @@ MVP ready:
|
|
|
824
938
|
- security checks
|
|
825
939
|
- profile composition
|
|
826
940
|
- Docker Compose rendering
|
|
941
|
+
- Kubernetes Helm rendering and local k3s lifecycle
|
|
942
|
+
- runtime startup, health state, and shutdown
|
|
827
943
|
|
|
828
944
|
Next:
|
|
829
945
|
|
|
830
|
-
- runtime orchestration
|
|
831
|
-
- Kubernetes support
|
|
832
946
|
- advanced secret providers
|
|
833
947
|
- stack bootstrap and health checks
|
|
834
948
|
|
|
@@ -863,6 +977,8 @@ Good first contributions:
|
|
|
863
977
|
## 📖 Documentation
|
|
864
978
|
|
|
865
979
|
- [Quickstart](README.md#-quickstart) — get running in 5 minutes
|
|
980
|
+
- [How CDS Works](docs/how-it-works.md) — C4 + sequence diagrams of the validate → plan → render engine
|
|
981
|
+
- [Kubernetes Target](docs/kubernetes.md): Helm rendering and isolated local k3s workflow
|
|
866
982
|
- [From Docker Compose to CDS Profile](docs/from-docker-to-cds-profile.md) — complete transformation guide
|
|
867
983
|
- [Architecture](docs/architecture.md) — design and core concepts
|
|
868
984
|
- [Modules](docs/modules.md) — how to structure reusable components
|
|
@@ -18,6 +18,7 @@ from urllib.error import HTTPError, URLError
|
|
|
18
18
|
from urllib.request import Request, urlopen
|
|
19
19
|
|
|
20
20
|
from .loader import load_yaml_file, resolve_module_dir
|
|
21
|
+
from .overlay import _derive_profiles_root, _resolve_extends_ref, resolve_extends
|
|
21
22
|
from .planner import MaxNestingDepthExceeded, apply_defaults, substitute_string
|
|
22
23
|
|
|
23
24
|
# The upstream repository `cds get` downloads from when no `--remote` is
|
|
@@ -71,7 +72,7 @@ def fetch_profile(
|
|
|
71
72
|
destination_root: Path | None = None,
|
|
72
73
|
force: bool = False,
|
|
73
74
|
dry_run: bool = False,
|
|
74
|
-
) -> tuple[list[CopyAction], Path]:
|
|
75
|
+
) -> tuple[list[CopyAction], Path, list[str]]:
|
|
75
76
|
target_root = (destination_root or Path.cwd()).expanduser().resolve()
|
|
76
77
|
|
|
77
78
|
with _prepare_source_repository(remote, ref, local) as source_repo:
|
|
@@ -79,10 +80,15 @@ def fetch_profile(
|
|
|
79
80
|
asset_roots = _collect_asset_roots(source_repo, profile_path)
|
|
80
81
|
|
|
81
82
|
actions = _build_copy_plan(source_repo, asset_roots, target_root)
|
|
83
|
+
# Conflict detection must happen while `source_repo` (which may be a
|
|
84
|
+
# temporary directory holding a downloaded tarball) is still alive,
|
|
85
|
+
# since `_find_conflicts()` reads each action's source file to
|
|
86
|
+
# compare content. Compute it up front so both the dry-run and
|
|
87
|
+
# real-run paths can report/enforce conflicts consistently (#452).
|
|
88
|
+
conflicts = _find_conflicts(actions)
|
|
82
89
|
if dry_run:
|
|
83
|
-
return actions, target_root / _TRACKING_FILE
|
|
90
|
+
return actions, target_root / _TRACKING_FILE, conflicts
|
|
84
91
|
|
|
85
|
-
conflicts = _find_conflicts(actions)
|
|
86
92
|
if conflicts and not force:
|
|
87
93
|
rendered = ", ".join(conflicts[:5])
|
|
88
94
|
extra = "" if len(conflicts) <= 5 else f" (+{len(conflicts) - 5} more)"
|
|
@@ -104,18 +110,37 @@ def fetch_profile(
|
|
|
104
110
|
actions=actions,
|
|
105
111
|
asset_roots=asset_roots,
|
|
106
112
|
)
|
|
107
|
-
return actions, target_root / _TRACKING_FILE
|
|
113
|
+
return actions, target_root / _TRACKING_FILE, conflicts
|
|
108
114
|
|
|
109
115
|
|
|
110
|
-
def format_get_plan(
|
|
116
|
+
def format_get_plan(
|
|
117
|
+
actions: list[CopyAction],
|
|
118
|
+
*,
|
|
119
|
+
destination_root: Path,
|
|
120
|
+
conflicts: list[str] | None = None,
|
|
121
|
+
) -> str:
|
|
122
|
+
conflicts = conflicts or []
|
|
123
|
+
conflict_set = set(conflicts)
|
|
124
|
+
clean_actions = [a for a in actions if a.repo_relative_path not in conflict_set]
|
|
125
|
+
|
|
111
126
|
if not actions:
|
|
112
127
|
return f"No file changes required under {destination_root}."
|
|
113
128
|
|
|
114
129
|
lines = [f"Planned {len(actions)} file(s) under {destination_root}:"]
|
|
115
|
-
|
|
116
|
-
lines.append(f"
|
|
117
|
-
|
|
118
|
-
|
|
130
|
+
if clean_actions:
|
|
131
|
+
lines.append(f" Would copy ({len(clean_actions)}):")
|
|
132
|
+
for action in clean_actions[:20]:
|
|
133
|
+
lines.append(f" - {action.repo_relative_path}")
|
|
134
|
+
if len(clean_actions) > 20:
|
|
135
|
+
lines.append(f" - ... {len(clean_actions) - 20} more")
|
|
136
|
+
|
|
137
|
+
if conflicts:
|
|
138
|
+
lines.append(f" Would conflict without --force ({len(conflicts)}):")
|
|
139
|
+
for path in conflicts[:20]:
|
|
140
|
+
lines.append(f" - {path}")
|
|
141
|
+
if len(conflicts) > 20:
|
|
142
|
+
lines.append(f" - ... {len(conflicts) - 20} more")
|
|
143
|
+
|
|
119
144
|
return "\n".join(lines)
|
|
120
145
|
|
|
121
146
|
|
|
@@ -233,15 +258,67 @@ def _resolve_source_profile_path(source_repo: Path, profile: str) -> Path:
|
|
|
233
258
|
)
|
|
234
259
|
|
|
235
260
|
|
|
261
|
+
def _collect_extends_profile_dirs(
|
|
262
|
+
source_repo: Path, profile_path: Path, _visited: frozenset[Path] = frozenset()
|
|
263
|
+
) -> set[Path]:
|
|
264
|
+
"""
|
|
265
|
+
Returns every parent profile directory reachable via `extends`, so
|
|
266
|
+
`cds get` copies parent profile.yaml files (and, via
|
|
267
|
+
_collect_asset_roots's module walk, their modules) too instead of
|
|
268
|
+
silently omitting anything only declared in a parent profile. Reuses
|
|
269
|
+
cli.overlay's own extends-ref resolution rather than re-implementing it.
|
|
270
|
+
|
|
271
|
+
`_visited` tracks resolved profile paths already seen along the current
|
|
272
|
+
extends chain so a cyclic `extends` graph raises a clean GetError
|
|
273
|
+
instead of recursing until Python's recursion limit is hit; this
|
|
274
|
+
mirrors cli.overlay._compose_extends's own cycle-detection stack.
|
|
275
|
+
"""
|
|
276
|
+
resolved_profile_path = profile_path.resolve()
|
|
277
|
+
if resolved_profile_path in _visited:
|
|
278
|
+
raise GetError(f"Cycle detected in extends chain at {profile_path}")
|
|
279
|
+
_visited = _visited | {resolved_profile_path}
|
|
280
|
+
|
|
281
|
+
doc, _diagnostics = load_yaml_file(profile_path)
|
|
282
|
+
if doc is None:
|
|
283
|
+
raise GetError(f"Could not load source profile {profile_path}")
|
|
284
|
+
|
|
285
|
+
extends = doc.get("extends")
|
|
286
|
+
if not extends:
|
|
287
|
+
return set()
|
|
288
|
+
|
|
289
|
+
profile_dir = profile_path.parent.resolve()
|
|
290
|
+
profiles_root = _derive_profiles_root(profile_dir)
|
|
291
|
+
if profiles_root is None:
|
|
292
|
+
raise GetError(f'Could not derive a profiles root for extends in {profile_path}')
|
|
293
|
+
|
|
294
|
+
dirs: set[Path] = set()
|
|
295
|
+
for ref in extends:
|
|
296
|
+
parent_path = _resolve_extends_ref(ref, profile_dir, profiles_root)
|
|
297
|
+
if not parent_path.is_file():
|
|
298
|
+
raise GetError(f'Could not resolve extends parent "{ref}" for {profile_path}')
|
|
299
|
+
_require_within_repo(parent_path, source_repo, f'extends parent "{ref}"')
|
|
300
|
+
dirs.add(parent_path.parent)
|
|
301
|
+
dirs.update(_collect_extends_profile_dirs(source_repo, parent_path, _visited))
|
|
302
|
+
|
|
303
|
+
return dirs
|
|
304
|
+
|
|
305
|
+
|
|
236
306
|
def _collect_asset_roots(source_repo: Path, profile_path: Path) -> list[Path]:
|
|
237
307
|
profile_dir = profile_path.parent
|
|
238
308
|
asset_roots: set[Path] = {
|
|
239
309
|
profile_dir if profile_path.name == "profile.yaml" else profile_path
|
|
240
310
|
}
|
|
241
|
-
|
|
242
|
-
|
|
311
|
+
|
|
312
|
+
# Resolve through `extends` so modules/config contributed only by a
|
|
313
|
+
# parent profile (not redeclared in the child) are still collected;
|
|
314
|
+
# cli.overlay is the single source of truth for extends semantics (see
|
|
315
|
+
# cli.planner/cli.validator, which route through it the same way).
|
|
316
|
+
profile_doc, _provenance, diagnostics = resolve_extends(str(profile_path))
|
|
317
|
+
if profile_doc is None or any(d.level == "error" for d in diagnostics):
|
|
243
318
|
raise GetError(f"Could not load source profile {profile_path}")
|
|
244
319
|
|
|
320
|
+
asset_roots.update(_collect_extends_profile_dirs(source_repo, profile_path))
|
|
321
|
+
|
|
245
322
|
spec = profile_doc.get("spec")
|
|
246
323
|
modules = spec.get("modules", []) if isinstance(spec, dict) else []
|
|
247
324
|
if not isinstance(modules, list):
|
|
@@ -595,7 +672,19 @@ def _add_copy_action(
|
|
|
595
672
|
destination_root: Path,
|
|
596
673
|
actions_by_destination: dict[Path, CopyAction],
|
|
597
674
|
) -> None:
|
|
598
|
-
|
|
675
|
+
resolved_source = source_file.resolve()
|
|
676
|
+
try:
|
|
677
|
+
repo_relative = resolved_source.relative_to(source_repo.resolve())
|
|
678
|
+
except ValueError as exc:
|
|
679
|
+
# A symlink (or directory junction) planted under an asset root can
|
|
680
|
+
# resolve to a real path outside the source repository. Without this
|
|
681
|
+
# guard, Path.relative_to() raises a bare ValueError that escapes as
|
|
682
|
+
# an unhandled traceback instead of the stable GetError callers rely
|
|
683
|
+
# on (#454).
|
|
684
|
+
raise GetError(
|
|
685
|
+
f"Asset {source_file} resolves outside the source repository "
|
|
686
|
+
f"({source_repo}): {resolved_source}"
|
|
687
|
+
) from exc
|
|
599
688
|
# Deliberately do NOT call .resolve() on the combined destination path:
|
|
600
689
|
# destination_root is already an absolute, resolved path (see
|
|
601
690
|
# fetch_profile()), and resolving the full path here would follow a
|
|
@@ -607,12 +696,12 @@ def _add_copy_action(
|
|
|
607
696
|
existing = actions_by_destination.get(destination)
|
|
608
697
|
if existing is None:
|
|
609
698
|
actions_by_destination[destination] = CopyAction(
|
|
610
|
-
source=
|
|
699
|
+
source=resolved_source,
|
|
611
700
|
destination=destination,
|
|
612
701
|
repo_relative_path=repo_relative.as_posix(),
|
|
613
702
|
)
|
|
614
703
|
return
|
|
615
|
-
if existing.source.resolve() !=
|
|
704
|
+
if existing.source.resolve() != resolved_source:
|
|
616
705
|
raise GetError(
|
|
617
706
|
f"Multiple source files would map to the same destination: {destination}"
|
|
618
707
|
)
|
|
@@ -649,10 +738,17 @@ def _write_actions(actions: list[CopyAction]) -> None:
|
|
|
649
738
|
(e.g. a symlink planted at profiles/foo/profile.yaml pointing outside
|
|
650
739
|
the destination tree)."""
|
|
651
740
|
for action in actions:
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
action.destination.
|
|
655
|
-
|
|
741
|
+
try:
|
|
742
|
+
action.destination.parent.mkdir(parents=True, exist_ok=True)
|
|
743
|
+
if action.destination.is_symlink():
|
|
744
|
+
action.destination.unlink()
|
|
745
|
+
shutil.copy2(action.source, action.destination)
|
|
746
|
+
except OSError as exc:
|
|
747
|
+
# Surface filesystem failures (permission denied, read-only
|
|
748
|
+
# destination, disk full, ...) as a stable GetError instead of a
|
|
749
|
+
# raw OSError, so callers get a predictable error path and
|
|
750
|
+
# message regardless of the underlying platform/errno (#454).
|
|
751
|
+
raise GetError(f"Could not write {action.destination}: {exc}") from exc
|
|
656
752
|
|
|
657
753
|
|
|
658
754
|
def _write_tracking_manifest(
|