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.
Files changed (104) hide show
  1. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/PKG-INFO +128 -11
  2. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/README.md +124 -8
  3. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/getter.py +114 -18
  4. composable_data_stack-0.9.0/cli/k8s_renderer.py +1471 -0
  5. composable_data_stack-0.9.0/cli/k8s_runner.py +211 -0
  6. composable_data_stack-0.9.0/cli/k8s_security.py +125 -0
  7. composable_data_stack-0.9.0/cli/loader.py +341 -0
  8. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/main.py +563 -85
  9. composable_data_stack-0.9.0/cli/overlay.py +427 -0
  10. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/planner.py +102 -9
  11. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/preflight.py +17 -5
  12. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/renderer.py +81 -9
  13. composable_data_stack-0.9.0/cli/resolver.py +71 -0
  14. composable_data_stack-0.9.0/cli/resources/module.schema.json +705 -0
  15. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/profile.schema.json +9 -0
  16. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/security.py +9 -7
  17. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/state.py +56 -2
  18. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/up_runner.py +50 -7
  19. composable_data_stack-0.9.0/cli/utils.py +35 -0
  20. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/validator.py +91 -18
  21. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/PKG-INFO +128 -11
  22. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/SOURCES.txt +22 -1
  23. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/requires.txt +4 -2
  24. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/pyproject.toml +6 -4
  25. composable_data_stack-0.9.0/tests/test_ai_profile_review.py +113 -0
  26. composable_data_stack-0.9.0/tests/test_compose_to_module.py +310 -0
  27. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_entrypoint_backend_guard.py +48 -0
  28. composable_data_stack-0.9.0/tests/test_dbt_hardening.py +132 -0
  29. composable_data_stack-0.9.0/tests/test_dlt_postgres_wiring.py +145 -0
  30. composable_data_stack-0.9.0/tests/test_duckdb_hardening.py +123 -0
  31. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_getter.py +215 -8
  32. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_image_verification.py +130 -0
  33. composable_data_stack-0.9.0/tests/test_k3d_e2e_profile.py +52 -0
  34. composable_data_stack-0.9.0/tests/test_k3d_exposure.py +134 -0
  35. composable_data_stack-0.9.0/tests/test_k8s_renderer.py +577 -0
  36. composable_data_stack-0.9.0/tests/test_k8s_runner.py +124 -0
  37. composable_data_stack-0.9.0/tests/test_k8s_security.py +127 -0
  38. composable_data_stack-0.9.0/tests/test_loader.py +210 -0
  39. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_main.py +924 -0
  40. composable_data_stack-0.9.0/tests/test_markdown_mermaid_blocks.py +56 -0
  41. composable_data_stack-0.9.0/tests/test_overlay.py +732 -0
  42. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_planner.py +503 -0
  43. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_preflight.py +39 -1
  44. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_publish_images_workflow.py +68 -0
  45. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_render_example_profile.py +4 -0
  46. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_renderer.py +300 -0
  47. composable_data_stack-0.9.0/tests/test_resolver.py +87 -0
  48. composable_data_stack-0.9.0/tests/test_secrets.py +160 -0
  49. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_security.py +190 -0
  50. composable_data_stack-0.9.0/tests/test_standalone_module_profile.py +102 -0
  51. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_state.py +52 -0
  52. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_superset_hardening.py +56 -14
  53. composable_data_stack-0.9.0/tests/test_tender_analytics.py +186 -0
  54. composable_data_stack-0.9.0/tests/test_tender_dashboard.py +175 -0
  55. composable_data_stack-0.9.0/tests/test_test_runner_timeout.py +69 -0
  56. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_up_runner.py +27 -0
  57. composable_data_stack-0.9.0/tests/test_utils.py +101 -0
  58. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_validator.py +164 -0
  59. composable_data_stack-0.9.0/tests/test_vendored_llm_client.py +89 -0
  60. composable_data_stack-0.7.0/cli/loader.py +0 -179
  61. composable_data_stack-0.7.0/cli/overlay.py +0 -239
  62. composable_data_stack-0.7.0/cli/resolver.py +0 -28
  63. composable_data_stack-0.7.0/cli/resources/module.schema.json +0 -204
  64. composable_data_stack-0.7.0/cli/utils.py +0 -22
  65. composable_data_stack-0.7.0/tests/test_overlay.py +0 -280
  66. composable_data_stack-0.7.0/tests/test_resolver.py +0 -53
  67. composable_data_stack-0.7.0/tests/test_secrets.py +0 -48
  68. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/LICENSE +0 -0
  69. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/__init__.py +0 -0
  70. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/constants.py +0 -0
  71. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/diagnostics.py +0 -0
  72. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/graph.py +0 -0
  73. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/image_updates.py +0 -0
  74. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/image_verification.py +0 -0
  75. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/__init__.py +0 -0
  76. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/contract.schema.json +0 -0
  77. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/rule-schema.json +0 -0
  78. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/resources/rule-set.json +0 -0
  79. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/secrets.py +0 -0
  80. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/cli/security_common.py +0 -0
  81. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  82. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
  83. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/composable_data_stack.egg-info/top_level.txt +0 -0
  84. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/setup.cfg +0 -0
  85. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_cds_workflow.py +0 -0
  86. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_completion.py +0 -0
  87. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_compose_runtime_smoke.py +0 -0
  88. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_config_generation.py +0 -0
  89. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_definitions_uri.py +0 -0
  90. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_dagster_hardening.py +0 -0
  91. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_environment_classification.py +0 -0
  92. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_environment_cli.py +0 -0
  93. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_graph.py +0 -0
  94. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_image_security_scan_workflow.py +0 -0
  95. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_image_updates.py +0 -0
  96. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_module_isolation.py +0 -0
  97. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_modules_no_committed_secrets.py +0 -0
  98. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_mvp_proof_workflow.py +0 -0
  99. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_python_version_alignment.py +0 -0
  100. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_release_version_check.py +0 -0
  101. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_resolve_profile_path.py +0 -0
  102. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_smoke_example_profile.py +0 -0
  103. {composable_data_stack-0.7.0 → composable_data_stack-0.9.0}/tests/test_trivyignore.py +0 -0
  104. {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.7.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.5; extra == "dev"
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
  ![Composable Data Stack logo](assets/branding/logo.svg)
43
44
 
45
+ Project:
46
+
47
+ [![CI](https://github.com/RonaldHensbergen/composable-data-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/RonaldHensbergen/composable-data-stack/actions/workflows/ci.yml)
48
+ [![PyPI](https://img.shields.io/pypi/v/composable-data-stack.svg)](https://pypi.org/project/composable-data-stack/)
49
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
50
+
51
+ SonarCloud — Quality Gate & Ratings:
52
+
53
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
54
+ [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
55
+ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
56
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
57
+
58
+ SonarCloud — Issues:
59
+
60
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=bugs)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
61
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
62
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
63
+
64
+ SonarCloud — Metrics:
65
+
66
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=coverage)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
67
+ [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
68
+ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=sqale_index)](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 configuration from a resolved plan|
652
- |cds up [profile]|Validate, plan, render, build, and start services with docker compose; logs output to a file and shows a live `cds state` view until the stack settles (use `--no-build` to skip build, `--detach` to skip the live view, `--log-file`/`--timeout`/`--no-color` to override defaults)|
653
- |cds state [profile]|Show running service status grouped by health (use `--no-color` to disable colored labels)|
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
  ![Composable Data Stack logo](assets/branding/logo.svg)
7
7
 
8
+ Project:
9
+
10
+ [![CI](https://github.com/RonaldHensbergen/composable-data-stack/actions/workflows/ci.yml/badge.svg)](https://github.com/RonaldHensbergen/composable-data-stack/actions/workflows/ci.yml)
11
+ [![PyPI](https://img.shields.io/pypi/v/composable-data-stack.svg)](https://pypi.org/project/composable-data-stack/)
12
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)
13
+
14
+ SonarCloud — Quality Gate & Ratings:
15
+
16
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
17
+ [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=reliability_rating)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
18
+ [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
19
+ [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
20
+
21
+ SonarCloud — Issues:
22
+
23
+ [![Bugs](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=bugs)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
24
+ [![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
25
+ [![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
26
+
27
+ SonarCloud — Metrics:
28
+
29
+ [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=coverage)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
30
+ [![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=RonaldHensbergen_composable-data-stack)
31
+ [![Technical Debt](https://sonarcloud.io/api/project_badges/measure?project=RonaldHensbergen_composable-data-stack&metric=sqale_index)](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 configuration from a resolved plan|
616
- |cds up [profile]|Validate, plan, render, build, and start services with docker compose; logs output to a file and shows a live `cds state` view until the stack settles (use `--no-build` to skip build, `--detach` to skip the live view, `--log-file`/`--timeout`/`--no-color` to override defaults)|
617
- |cds state [profile]|Show running service status grouped by health (use `--no-color` to disable colored labels)|
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(actions: list[CopyAction], *, destination_root: Path) -> str:
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
- for action in actions[:20]:
116
- lines.append(f" - {action.repo_relative_path}")
117
- if len(actions) > 20:
118
- lines.append(f" - ... {len(actions) - 20} more")
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
- profile_doc, profile_diags = load_yaml_file(profile_path)
242
- if profile_diags or profile_doc is None:
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
- repo_relative = source_file.resolve().relative_to(source_repo.resolve())
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=source_file.resolve(),
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() != source_file.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
- action.destination.parent.mkdir(parents=True, exist_ok=True)
653
- if action.destination.is_symlink():
654
- action.destination.unlink()
655
- shutil.copy2(action.source, action.destination)
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(