composable-data-stack 0.12.0__tar.gz → 0.14.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 (143) hide show
  1. {composable_data_stack-0.12.0/composable_data_stack.egg-info → composable_data_stack-0.14.0}/PKG-INFO +16 -2
  2. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/README.md +14 -0
  3. composable_data_stack-0.14.0/cli/confinement.py +219 -0
  4. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/getter.py +1 -1
  5. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/k8s_renderer.py +75 -5
  6. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/main.py +82 -5
  7. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/planner.py +69 -0
  8. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/preflight.py +153 -9
  9. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/renderer.py +503 -16
  10. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resolver.py +19 -0
  11. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/module.schema.json +5 -0
  12. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/profile.schema.json +151 -0
  13. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/rule-schema.json +1 -0
  14. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/rule-set.json +64 -11
  15. composable_data_stack-0.14.0/cli/secret_leak.py +210 -0
  16. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/security.py +310 -2
  17. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/validator.py +691 -0
  18. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0/composable_data_stack.egg-info}/PKG-INFO +16 -2
  19. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/SOURCES.txt +22 -0
  20. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/requires.txt +1 -1
  21. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/pyproject.toml +2 -2
  22. composable_data_stack-0.14.0/tests/test_audit_trivyignore.py +121 -0
  23. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_cds_workflow.py +19 -37
  24. composable_data_stack-0.14.0/tests/test_certificates.py +224 -0
  25. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_completion.py +1 -2
  26. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_compose_runtime_smoke.py +35 -0
  27. composable_data_stack-0.14.0/tests/test_composer_extra.py +295 -0
  28. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_dagster_entrypoint_backend_guard.py +49 -3
  29. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_environment_cli.py +15 -1
  30. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_getter.py +14 -0
  31. composable_data_stack-0.14.0/tests/test_getter_extra.py +657 -0
  32. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_k8s_renderer.py +31 -1
  33. composable_data_stack-0.14.0/tests/test_main_extra.py +446 -0
  34. composable_data_stack-0.14.0/tests/test_main_extra2.py +540 -0
  35. composable_data_stack-0.14.0/tests/test_main_extra3.py +647 -0
  36. composable_data_stack-0.14.0/tests/test_planner_extra.py +525 -0
  37. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_preflight.py +133 -0
  38. composable_data_stack-0.14.0/tests/test_preflight_extra.py +237 -0
  39. composable_data_stack-0.14.0/tests/test_preflight_rootless.py +50 -0
  40. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_render_example_profile.py +64 -0
  41. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_renderer.py +156 -0
  42. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_report.py +21 -43
  43. composable_data_stack-0.14.0/tests/test_reverse_proxy_contract.py +67 -0
  44. composable_data_stack-0.14.0/tests/test_reverse_proxy_routes.py +786 -0
  45. composable_data_stack-0.14.0/tests/test_runtime_confinement.py +234 -0
  46. composable_data_stack-0.14.0/tests/test_runtime_policy.py +188 -0
  47. composable_data_stack-0.14.0/tests/test_secret_file_permissions.py +102 -0
  48. composable_data_stack-0.14.0/tests/test_secret_output_scanning.py +254 -0
  49. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_security.py +2 -1
  50. composable_data_stack-0.14.0/tests/test_shipped_profiles.py +40 -0
  51. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_superset_hardening.py +4 -2
  52. composable_data_stack-0.14.0/tests/test_team_profile.py +123 -0
  53. composable_data_stack-0.14.0/tests/test_update_check_extra.py +102 -0
  54. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_validator.py +167 -0
  55. composable_data_stack-0.14.0/tests/test_validator_extra.py +955 -0
  56. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/LICENSE +0 -0
  57. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/__init__.py +0 -0
  58. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/audit_log.py +0 -0
  59. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/composer.py +0 -0
  60. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/constants.py +0 -0
  61. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/diagnostics.py +0 -0
  62. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/graph.py +0 -0
  63. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/image_digest_check.py +0 -0
  64. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/image_updates.py +0 -0
  65. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/image_verification.py +0 -0
  66. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/k8s_runner.py +0 -0
  67. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/k8s_security.py +0 -0
  68. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/loader.py +0 -0
  69. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/overlay.py +0 -0
  70. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/report.py +0 -0
  71. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/__init__.py +0 -0
  72. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/compatibility-registry.json +0 -0
  73. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/compatibility-registry.schema.json +0 -0
  74. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/resources/contract.schema.json +0 -0
  75. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/secrets.py +0 -0
  76. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/security_common.py +0 -0
  77. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/state.py +0 -0
  78. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/up_runner.py +0 -0
  79. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/update_check.py +0 -0
  80. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/cli/utils.py +0 -0
  81. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  82. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
  83. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/top_level.txt +0 -0
  84. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/setup.cfg +0 -0
  85. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_ai_profile_review.py +0 -0
  86. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_audit_log.py +0 -0
  87. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_build_cra_release_evidence_manifest.py +0 -0
  88. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_bump_changelog.py +0 -0
  89. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_check_cra_release_gate.py +0 -0
  90. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_check_roadmap_freshness.py +0 -0
  91. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_compose_to_module.py +0 -0
  92. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_composer.py +0 -0
  93. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_cra_traceability.py +0 -0
  94. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_dagster_config_generation.py +0 -0
  95. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_dagster_definitions_uri.py +0 -0
  96. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_dagster_hardening.py +0 -0
  97. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_dbt_hardening.py +0 -0
  98. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_dlt_postgres_wiring.py +0 -0
  99. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_duckdb_hardening.py +0 -0
  100. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_environment_classification.py +0 -0
  101. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_generate_release_inventory.py +0 -0
  102. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_graph.py +0 -0
  103. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_image_digest_check.py +0 -0
  104. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_image_locks.py +0 -0
  105. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_image_security_scan_workflow.py +0 -0
  106. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_image_updates.py +0 -0
  107. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_image_verification.py +0 -0
  108. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_k3d_e2e_profile.py +0 -0
  109. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_k3d_exposure.py +0 -0
  110. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_k8s_runner.py +0 -0
  111. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_k8s_security.py +0 -0
  112. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_keycloak_postgres_wiring.py +0 -0
  113. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_loader.py +0 -0
  114. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_main.py +0 -0
  115. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_markdown_mermaid_blocks.py +0 -0
  116. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_module_isolation.py +0 -0
  117. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_modules_no_committed_secrets.py +0 -0
  118. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_mvp_proof_workflow.py +0 -0
  119. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_overlay.py +0 -0
  120. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_planner.py +0 -0
  121. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_publish_images_workflow.py +0 -0
  122. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_python_version_alignment.py +0 -0
  123. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_release_notes_support.py +0 -0
  124. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_release_version_check.py +0 -0
  125. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_release_workflow.py +0 -0
  126. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_resolve_profile_path.py +0 -0
  127. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_resolver.py +0 -0
  128. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_secrets.py +0 -0
  129. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_smoke_example_profile.py +0 -0
  130. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_standalone_module_profile.py +0 -0
  131. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_state.py +0 -0
  132. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_tender_analytics.py +0 -0
  133. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_tender_dashboard.py +0 -0
  134. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_test_runner_timeout.py +0 -0
  135. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_trivyignore.py +0 -0
  136. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_up_runner.py +0 -0
  137. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_update_check.py +0 -0
  138. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_upstream_image_hardening.py +0 -0
  139. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_utils.py +0 -0
  140. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_validate_cli_sbom.py +0 -0
  141. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_vault_init_db.py +0 -0
  142. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_vault_init_db_runtime.py +0 -0
  143. {composable_data_stack-0.12.0 → composable_data_stack-0.14.0}/tests/test_vendored_llm_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: composable-data-stack
3
- Version: 0.12.0
3
+ Version: 0.14.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
@@ -32,7 +32,7 @@ Provides-Extra: dev
32
32
  Requires-Dist: coverage>=7.6; extra == "dev"
33
33
  Requires-Dist: bandit>=1.7; extra == "dev"
34
34
  Requires-Dist: pip-audit>=2.7; extra == "dev"
35
- Requires-Dist: ruff==0.17.0; extra == "dev"
35
+ Requires-Dist: ruff==0.17.1; extra == "dev"
36
36
  Dynamic: license-file
37
37
 
38
38
  # 🚀 Composable Data Stack (CDS)
@@ -813,9 +813,23 @@ Common errors from `cds validate`, `cds plan`, and `cds render`, and how to fix
813
813
  | `[E081] ... Required secret "CDS_X_PASSWORD" not found in environment` | A secret marked `required: true` in the profile's `spec.secrets.values` is missing from the shell environment or the `.env` file in the current working directory. | Run `cds init <profile>` to generate `.env` in the project root, set the missing `CDS_*` variable, or export it directly before running the command. |
814
814
  | `[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>`. |
815
815
  | `[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. |
816
+ | `[E129] ... Certificate ref ... is not defined in spec.certificates.values` (or `malformed`) | A module config value references `certificates.<alias>.cert`/`.key`, but the alias isn't declared under `spec.certificates.values` -- or the reference is bare/has an unknown trailing part instead of `.cert` or `.key`. | Declare the alias under `spec.certificates.values` with its `certEnv`/`keyEnv` names, or fix the reference to name one half of a declared pair. CDS only maps names; the certificate/key material itself is supplied at runtime by the external secret/cert provider. |
816
817
  | `[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. |
817
818
  | `[E043] ... recorded as unsupported in the compatibility registry` | The binding's `kind` matches, but this exact provider/consumer pairing is explicitly recorded as `unsupported` in `cli/resources/compatibility-registry.json` -- it's known NOT to work together despite matching shape. | Use a different provider/consumer pairing for this contract, or if the pairing has since been fixed/verified, update its `status` to `tested` in the registry (see [docs/architecture.md § Secrets and contract resolution](docs/architecture.md#secrets-and-contract-resolution)). |
819
+ | `[E128] ... requires a TLS-protected endpoint (tls: required), but ... does not advertise one` | A module's `consumes` entry declares `tls: required` (issue #577), but the bound provider's contract spec does not set `tls: true` (absent, `false`, or a `${config.<path>}` reference that does not resolve to `true` against the producer's profile config). | Point the binding at a provider that advertises TLS, or have the provider set `tls: true` in its provide entry's contract spec. Drop `tls: required` only if the binding is genuinely allowed to run unencrypted. |
820
+
821
+ | `[E130] ... Duplicate backup policy for workload class "<class>"` | `spec.backup.policies` declares more than one policy for the same workload class. | Keep one policy per workload class. |
822
+ | `[E131] ... Backup encryption key ref ... is not defined in spec.secrets.values` | `spec.backup.encryption.keyFrom` references a `secrets.<alias>` that the profile does not declare. | Declare the alias under `spec.secrets.values` with its `env` name, or fix the reference. |
823
+ | `[E132] ... Backup target contractRef ...` | The pinned `spec.backup.target.contractRef` is malformed, points to an unknown module or contract, is not a `backup-target` provider, or (in production) the provider does not advertise `encrypted: true` / `supportsRestoreVerification: true`. | Point at a module providing a `backup-target` contract that advertises the required capabilities, or drop the pin to leave the storage provider open. |
824
+ | `[E133] ... Production profiles must declare spec.backup ...` / `... missing workload classes: ...` | A `metadata.environment: production` profile has no `spec.backup` block, or lacks a policy for a workload class it runs (warehouse, orchestration/BI/identity/secrets metadata, or configuration). | Declare encrypted, restore-verified backup policies for every workload class the profile runs; see `docs/backup-restore.md`. |
825
+ | `[E134] ... Production backups must be encrypted ...` / `... must be declared required: true` | A production profile has no `spec.backup.encryption.keyFrom`, or the referenced secret alias is declared `required: false` so the key can be missing at runtime. | Declare the encryption key alias with `required: true` so a missing key fails closed at startup. |
826
+ | `[E135] ... Production backup policy "<class>" must declare restoreVerification` | A production backup policy has no `restoreVerification` block. | Add `restoreVerification.frequencyDays` to every production backup policy.|
827
+ | `[E136] ... Reverse-proxy route host ...` (unknown target / no resolvable http-service endpoint / backend address not a rendered compose service / entrypoint not declared by the proxy / undeclared certificate alias / duplicate route host) | A `config.routes` entry on a reverse-proxy provider names a module that is missing, does not provide an `http-service` contract with host/port (or a port outside 1-65535), advertises a backend address that matches no rendered compose service (multi-service modules must advertise `serviceName`), names a `<module-id>.<contract-name>` contract the target does not provide, names an entrypoint the proxy's `config.entryPoints` does not declare, references an undeclared certificate alias, or duplicates the host of an earlier route on the same proxy (#581). | Fix the route's `target`/`certificate`/`host`/`entryPoints` (a bare module id routes to its first http-service contract; `<module-id>.<contract-name>` picks a specific one; entrypoints must match the proxy's declared `config.entryPoints` keys), or advertise the serving compose service via `serviceName` in the target's http-service provide entry. |
818
828
  | `[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`. |
829
+ | `[E139] ... without an explicit host authentication advertisement` / `... host authentication method "trust"` | A `metadata.environment: production` profile runs a `sql-database` provider whose contract does not advertise a host authentication method, or advertises `trust` (issue #206). | Configure the provider's authentication explicitly (e.g. postgres `config.authentication.hostAuth: scram-sha-256`); trust authentication is prohibited in production profiles. |
830
+ | `[E137] ... accepts unauthenticated clients` | A production profile runs a `cache-service` provider that does not advertise `authentication: required` (no password credential configured). | Supply a cache password through a `secrets.<alias>` reference (e.g. keydb `config.passwordFrom`), declared `required: true` in `spec.secrets.values`. |
831
+ | `[E138] ... Credential field ... must reference a secret alias` / `... is empty` / `... uses a ${CDS_*} profile input` / `... contains a plaintext value` | A credential-named config field of a production database/cache provider is not a `secrets.<alias>` reference, or its alias is not declared `required: true` (issue #206). | Supply the credential through a secret reference declared `required: true` so a missing value fails closed at startup. |
832
+ | `[W105] ... legacy md5 host authentication` | A production `sql-database` provider advertises `md5`, which validates but is a deprecated password method. | Prefer `scram-sha-256` for production profiles. |
819
833
  | `[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`. |
820
834
 
821
835
  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.
@@ -776,9 +776,23 @@ Common errors from `cds validate`, `cds plan`, and `cds render`, and how to fix
776
776
  | `[E081] ... Required secret "CDS_X_PASSWORD" not found in environment` | A secret marked `required: true` in the profile's `spec.secrets.values` is missing from the shell environment or the `.env` file in the current working directory. | Run `cds init <profile>` to generate `.env` in the project root, set the missing `CDS_*` variable, or export it directly before running the command. |
777
777
  | `[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>`. |
778
778
  | `[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. |
779
+ | `[E129] ... Certificate ref ... is not defined in spec.certificates.values` (or `malformed`) | A module config value references `certificates.<alias>.cert`/`.key`, but the alias isn't declared under `spec.certificates.values` -- or the reference is bare/has an unknown trailing part instead of `.cert` or `.key`. | Declare the alias under `spec.certificates.values` with its `certEnv`/`keyEnv` names, or fix the reference to name one half of a declared pair. CDS only maps names; the certificate/key material itself is supplied at runtime by the external secret/cert provider. |
779
780
  | `[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. |
780
781
  | `[E043] ... recorded as unsupported in the compatibility registry` | The binding's `kind` matches, but this exact provider/consumer pairing is explicitly recorded as `unsupported` in `cli/resources/compatibility-registry.json` -- it's known NOT to work together despite matching shape. | Use a different provider/consumer pairing for this contract, or if the pairing has since been fixed/verified, update its `status` to `tested` in the registry (see [docs/architecture.md § Secrets and contract resolution](docs/architecture.md#secrets-and-contract-resolution)). |
782
+ | `[E128] ... requires a TLS-protected endpoint (tls: required), but ... does not advertise one` | A module's `consumes` entry declares `tls: required` (issue #577), but the bound provider's contract spec does not set `tls: true` (absent, `false`, or a `${config.<path>}` reference that does not resolve to `true` against the producer's profile config). | Point the binding at a provider that advertises TLS, or have the provider set `tls: true` in its provide entry's contract spec. Drop `tls: required` only if the binding is genuinely allowed to run unencrypted. |
783
+
784
+ | `[E130] ... Duplicate backup policy for workload class "<class>"` | `spec.backup.policies` declares more than one policy for the same workload class. | Keep one policy per workload class. |
785
+ | `[E131] ... Backup encryption key ref ... is not defined in spec.secrets.values` | `spec.backup.encryption.keyFrom` references a `secrets.<alias>` that the profile does not declare. | Declare the alias under `spec.secrets.values` with its `env` name, or fix the reference. |
786
+ | `[E132] ... Backup target contractRef ...` | The pinned `spec.backup.target.contractRef` is malformed, points to an unknown module or contract, is not a `backup-target` provider, or (in production) the provider does not advertise `encrypted: true` / `supportsRestoreVerification: true`. | Point at a module providing a `backup-target` contract that advertises the required capabilities, or drop the pin to leave the storage provider open. |
787
+ | `[E133] ... Production profiles must declare spec.backup ...` / `... missing workload classes: ...` | A `metadata.environment: production` profile has no `spec.backup` block, or lacks a policy for a workload class it runs (warehouse, orchestration/BI/identity/secrets metadata, or configuration). | Declare encrypted, restore-verified backup policies for every workload class the profile runs; see `docs/backup-restore.md`. |
788
+ | `[E134] ... Production backups must be encrypted ...` / `... must be declared required: true` | A production profile has no `spec.backup.encryption.keyFrom`, or the referenced secret alias is declared `required: false` so the key can be missing at runtime. | Declare the encryption key alias with `required: true` so a missing key fails closed at startup. |
789
+ | `[E135] ... Production backup policy "<class>" must declare restoreVerification` | A production backup policy has no `restoreVerification` block. | Add `restoreVerification.frequencyDays` to every production backup policy.|
790
+ | `[E136] ... Reverse-proxy route host ...` (unknown target / no resolvable http-service endpoint / backend address not a rendered compose service / entrypoint not declared by the proxy / undeclared certificate alias / duplicate route host) | A `config.routes` entry on a reverse-proxy provider names a module that is missing, does not provide an `http-service` contract with host/port (or a port outside 1-65535), advertises a backend address that matches no rendered compose service (multi-service modules must advertise `serviceName`), names a `<module-id>.<contract-name>` contract the target does not provide, names an entrypoint the proxy's `config.entryPoints` does not declare, references an undeclared certificate alias, or duplicates the host of an earlier route on the same proxy (#581). | Fix the route's `target`/`certificate`/`host`/`entryPoints` (a bare module id routes to its first http-service contract; `<module-id>.<contract-name>` picks a specific one; entrypoints must match the proxy's declared `config.entryPoints` keys), or advertise the serving compose service via `serviceName` in the target's http-service provide entry. |
781
791
  | `[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`. |
792
+ | `[E139] ... without an explicit host authentication advertisement` / `... host authentication method "trust"` | A `metadata.environment: production` profile runs a `sql-database` provider whose contract does not advertise a host authentication method, or advertises `trust` (issue #206). | Configure the provider's authentication explicitly (e.g. postgres `config.authentication.hostAuth: scram-sha-256`); trust authentication is prohibited in production profiles. |
793
+ | `[E137] ... accepts unauthenticated clients` | A production profile runs a `cache-service` provider that does not advertise `authentication: required` (no password credential configured). | Supply a cache password through a `secrets.<alias>` reference (e.g. keydb `config.passwordFrom`), declared `required: true` in `spec.secrets.values`. |
794
+ | `[E138] ... Credential field ... must reference a secret alias` / `... is empty` / `... uses a ${CDS_*} profile input` / `... contains a plaintext value` | A credential-named config field of a production database/cache provider is not a `secrets.<alias>` reference, or its alias is not declared `required: true` (issue #206). | Supply the credential through a secret reference declared `required: true` so a missing value fails closed at startup. |
795
+ | `[W105] ... legacy md5 host authentication` | A production `sql-database` provider advertises `md5`, which validates but is a deprecated password method. | Prefer `scram-sha-256` for production profiles. |
782
796
  | `[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`. |
783
797
 
784
798
  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.
@@ -0,0 +1,219 @@
1
+ """Declarative runtime confinement (seccomp, AppArmor, SELinux) for Compose services.
2
+
3
+ A profile declares a default under `spec.security.runtime.confinement`; a
4
+ module instance may override individual keys under `security.confinement`.
5
+ The effective value is rendered into each service's `security_opt`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .diagnostics import Diagnostic
15
+
16
+ INVALID_CONFINEMENT_CODE = "E127"
17
+ CONFINEMENT_KEYS = ("seccomp", "apparmor", "selinux")
18
+
19
+ _APPARMOR_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
20
+ _DRIVE_RE = re.compile(r"^[A-Za-z]:")
21
+ _SELINUX_LABEL_RE = re.compile(r"^(user|role|type|level):[A-Za-z0-9_.,:-]+$")
22
+
23
+
24
+ def profile_confinement(profile_or_plan: dict[str, Any] | None) -> dict[str, Any]:
25
+ """Return the profile-wide confinement block from a profile or plan."""
26
+ if not isinstance(profile_or_plan, dict):
27
+ return {}
28
+ security = profile_or_plan.get("security")
29
+ if security is None:
30
+ security = (profile_or_plan.get("spec") or {}).get("security")
31
+ runtime = (security or {}).get("runtime") if isinstance(security, dict) else None
32
+ block = (runtime or {}).get("confinement") if isinstance(runtime, dict) else None
33
+ return block if isinstance(block, dict) else {}
34
+
35
+
36
+ def plan_declares_confinement(plan: dict[str, Any] | None) -> bool:
37
+ """True when the profile or any module instance sets confinement."""
38
+ if profile_confinement(plan):
39
+ return True
40
+ return any(
41
+ isinstance((module.get("security") or {}).get("confinement"), dict)
42
+ and (module.get("security") or {}).get("confinement")
43
+ for module in (plan or {}).get("modules") or []
44
+ if isinstance(module, dict)
45
+ )
46
+
47
+
48
+ def effective_confinement(
49
+ defaults: dict[str, Any] | None, override: dict[str, Any] | None
50
+ ) -> dict[str, Any]:
51
+ """Profile defaults with the module instance's keys layered on top."""
52
+ merged = {key: value for key, value in (defaults or {}).items() if key in CONFINEMENT_KEYS}
53
+ for key, value in (override or {}).items():
54
+ if key in CONFINEMENT_KEYS:
55
+ merged[key] = value
56
+ return merged
57
+
58
+
59
+ def validate_confinement(
60
+ block: Any, path: str, base_dir: Path | None = None
61
+ ) -> list[Diagnostic]:
62
+ """Check one confinement block; `base_dir` anchors `file:` seccomp profiles."""
63
+ if block is None:
64
+ return []
65
+ if not isinstance(block, dict):
66
+ return [_error(f"{path} must be an object.", path)]
67
+ diagnostics: list[Diagnostic] = []
68
+ for key in block:
69
+ if key not in CONFINEMENT_KEYS:
70
+ diagnostics.append(_error(
71
+ f"Unknown confinement key '{key}'; supported keys: {', '.join(CONFINEMENT_KEYS)}.",
72
+ f"{path}.{key}",
73
+ ))
74
+
75
+ seccomp = block.get("seccomp")
76
+ if seccomp is not None:
77
+ diagnostics.extend(_validate_seccomp(seccomp, f"{path}.seccomp", base_dir))
78
+ apparmor = block.get("apparmor")
79
+ if apparmor is not None and not (
80
+ isinstance(apparmor, str)
81
+ and (apparmor in {"runtime/default", "unconfined"} or _APPARMOR_NAME_RE.match(apparmor))
82
+ ):
83
+ diagnostics.append(_error(
84
+ "apparmor must be 'runtime/default', 'unconfined' or the name of a profile "
85
+ "already loaded on the host (letters, digits, '.', '_', '-', '/').",
86
+ f"{path}.apparmor",
87
+ ))
88
+ selinux = block.get("selinux")
89
+ if selinux is not None:
90
+ diagnostics.extend(_validate_selinux(selinux, f"{path}.selinux"))
91
+ return diagnostics
92
+
93
+
94
+ def _validate_seccomp(value: Any, path: str, base_dir: Path | None) -> list[Diagnostic]:
95
+ if value in {"default", "unconfined"}:
96
+ return []
97
+ if not isinstance(value, str) or not value.startswith("file:") or len(value) == 5:
98
+ return [_error(
99
+ "seccomp must be 'default', 'unconfined' or 'file:<path to a JSON profile "
100
+ "relative to the profile directory>'.",
101
+ path,
102
+ )]
103
+ relative = value[5:]
104
+ if base_dir is None:
105
+ return [_error(
106
+ "seccomp file profiles need a known profile directory to resolve against.", path
107
+ )]
108
+ # Reject rooted and drive-qualified paths explicitly: they are not
109
+ # "absolute" to pathlib on every platform (e.g. "/x" on Windows).
110
+ if (
111
+ Path(relative).is_absolute()
112
+ or relative.startswith(("/", "\\"))
113
+ or _DRIVE_RE.match(relative)
114
+ or ".." in Path(relative).parts
115
+ ):
116
+ return [_error(
117
+ "seccomp file profiles must be relative paths inside the profile directory.", path
118
+ )]
119
+ target = (base_dir / relative).resolve()
120
+ if not target.is_relative_to(base_dir.resolve()):
121
+ return [_error(
122
+ "seccomp file profile resolves outside the profile directory.", path
123
+ )]
124
+ if not target.is_file():
125
+ return [_error(f"seccomp profile '{relative}' does not exist.", path)]
126
+ return []
127
+
128
+
129
+ def _validate_selinux(value: Any, path: str) -> list[Diagnostic]:
130
+ if value == "disable":
131
+ return []
132
+ if not isinstance(value, list) or not value or not all(isinstance(v, str) for v in value):
133
+ return [_error(
134
+ "selinux must be 'disable' or a non-empty list of labels such as "
135
+ "'type:container_t' or 'level:s0:c100,c200'.",
136
+ path,
137
+ )]
138
+ bad = [label for label in value if not _SELINUX_LABEL_RE.match(label)]
139
+ if bad:
140
+ return [_error(
141
+ f"Invalid SELinux label(s): {', '.join(bad)}; expected user:, role:, type: or level: values.",
142
+ path,
143
+ )]
144
+ kinds = [label.split(":", 1)[0] for label in value]
145
+ duplicates = sorted({k for k in kinds if kinds.count(k) > 1})
146
+ if duplicates:
147
+ return [_error(
148
+ f"Duplicate SELinux label kind(s): {', '.join(duplicates)}; "
149
+ "each of user:, role:, type: and level: may appear at most once.",
150
+ path,
151
+ )]
152
+ return []
153
+
154
+
155
+ def confinement_security_opts(
156
+ confinement: dict[str, Any], compose_dir: Path, base_dir: Path | None
157
+ ) -> list[str]:
158
+ """Translate a validated confinement block into Compose `security_opt` entries.
159
+
160
+ Raises ValueError when a seccomp file cannot be expressed relative to
161
+ `compose_dir` (for example on a different Windows drive).
162
+ """
163
+ opts: list[str] = []
164
+ seccomp = confinement.get("seccomp")
165
+ if seccomp == "unconfined":
166
+ opts.append("seccomp=unconfined")
167
+ elif isinstance(seccomp, str) and seccomp.startswith("file:"):
168
+ target = (base_dir or compose_dir) / seccomp[5:]
169
+ opts.append(f"seccomp:{Path(os.path.relpath(target, compose_dir)).as_posix()}")
170
+ apparmor = confinement.get("apparmor")
171
+ if apparmor == "runtime/default":
172
+ opts.append("apparmor=docker-default")
173
+ elif isinstance(apparmor, str):
174
+ opts.append(f"apparmor={apparmor}")
175
+ selinux = confinement.get("selinux")
176
+ if selinux == "disable":
177
+ opts.append("label=disable")
178
+ elif isinstance(selinux, list):
179
+ opts.extend(f"label={label}" for label in selinux)
180
+ return opts
181
+
182
+
183
+ def apply_confinement(
184
+ service: dict[str, Any],
185
+ confinement: dict[str, Any],
186
+ compose_dir: Path,
187
+ base_dir: Path | None,
188
+ path: str = "security.confinement",
189
+ ) -> list[Diagnostic]:
190
+ """Set the declared confinement on a service, replacing module-set values per key."""
191
+ if not confinement:
192
+ return []
193
+ try:
194
+ new_opts = confinement_security_opts(confinement, compose_dir, base_dir)
195
+ except ValueError:
196
+ return [_error(
197
+ "seccomp file profile cannot be referenced relative to the generated "
198
+ "docker-compose.yml (different drive); render into the profile's drive.",
199
+ f"{path}.seccomp",
200
+ )]
201
+ existing = [str(opt) for opt in service.get("security_opt") or []]
202
+ replaced = {
203
+ "seccomp": ("seccomp=", "seccomp:"),
204
+ "apparmor": ("apparmor=", "apparmor:"),
205
+ "selinux": ("label=", "label:"),
206
+ }
207
+ prefixes = tuple(p for key in confinement for p in replaced.get(key, ()))
208
+ kept = [opt for opt in existing if not opt.startswith(prefixes)] if prefixes else existing
209
+ # `default` seccomp means "engine default", so it only clears module-set values.
210
+ merged = kept + new_opts
211
+ if merged:
212
+ service["security_opt"] = merged
213
+ else:
214
+ service.pop("security_opt", None)
215
+ return []
216
+
217
+
218
+ def _error(message: str, path: str) -> Diagnostic:
219
+ return Diagnostic(level="error", code=INVALID_CONFINEMENT_CODE, message=message, path=path)
@@ -664,7 +664,7 @@ def _extract_dockerfile_instruction_sources(
664
664
  continue
665
665
  if _contains_template(source):
666
666
  continue
667
- if Path(source).is_absolute():
667
+ if Path(source).is_absolute() or source.startswith(("/", "\\")):
668
668
  continue
669
669
  matches = list(context_path.glob(source))
670
670
  if not matches and "*" not in source and "?" not in source and "[" not in source:
@@ -28,6 +28,7 @@ from typing import Any
28
28
 
29
29
  import yaml
30
30
 
31
+ from .confinement import plan_declares_confinement
31
32
  from .diagnostics import Diagnostic
32
33
  from .renderer import (
33
34
  _build_context,
@@ -37,6 +38,12 @@ from .renderer import (
37
38
  _resolve_profile_dir,
38
39
  _resolve_project_root,
39
40
  _substitute_values,
41
+ apply_production_gates,
42
+ )
43
+ from .secret_leak import (
44
+ check_generated_text,
45
+ declared_secret_env_names,
46
+ resolved_secret_values,
40
47
  )
41
48
  from .utils import _atomic_write
42
49
 
@@ -73,12 +80,14 @@ class K8sRenderError(Exception):
73
80
  def render_helm(
74
81
  plan: dict[str, Any],
75
82
  output_dir: str | None = None,
83
+ env_file: str | None = None,
76
84
  ) -> tuple[dict[str, str], list[Diagnostic]]:
77
85
  """
78
86
  Render a Helm chart from a resolved plan.
79
87
 
80
88
  Returns a mapping of chart-relative path -> file contents, plus diagnostics.
81
- Files are written to `output_dir` when given.
89
+ Files are written to `output_dir` when given, unless an error was found
90
+ (including a resolved secret value in any chart file, CDS-SEC-030).
82
91
  """
83
92
  diagnostics: list[Diagnostic] = []
84
93
  metadata = plan.get("metadata", {})
@@ -103,6 +112,21 @@ def render_helm(
103
112
  path=f"module:{module_id}.implementation.kubernetes",
104
113
  )
105
114
  )
115
+
116
+ if plan_declares_confinement(plan):
117
+ diagnostics.append(
118
+ Diagnostic(
119
+ level="warning",
120
+ code="W127",
121
+ message=(
122
+ "Runtime confinement (seccomp/AppArmor/SELinux) is only rendered for "
123
+ "the compose target; it has no effect on the Helm chart. "
124
+ "Kubernetes security contexts are tracked under #65."
125
+ ),
126
+ path="spec.security.runtime.confinement",
127
+ )
128
+ )
129
+
106
130
  if unsupported:
107
131
  return {}, diagnostics
108
132
 
@@ -110,6 +134,28 @@ def render_helm(
110
134
  # ${k8s.service.*} can point forward to a workload declared later.
111
135
  service_names = _collect_service_names(renderable)
112
136
  diagnostics.extend(_check_binding_services(renderable, service_names))
137
+
138
+ # The built-in backup capability (config.backup.enabled, issue #665) is
139
+ # only implemented for the compose target: there is no Kubernetes
140
+ # sidecar workload yet. Fail closed instead of rendering a chart whose
141
+ # database is silently unprotected.
142
+ for module in renderable:
143
+ backup_config = (module.get("config") or {}).get("backup")
144
+ if isinstance(backup_config, dict) and backup_config.get("enabled") is True:
145
+ diagnostics.append(
146
+ Diagnostic(
147
+ level="error",
148
+ code="E142",
149
+ message=(
150
+ f'Module "{module["id"]}" enables config.backup.enabled, but the '
151
+ 'backup capability is not implemented for the "helm" render target '
152
+ "(no Kubernetes sidecar workload exists yet). Render this profile "
153
+ "with --target=compose, or disable backup for Kubernetes deploys."
154
+ ),
155
+ path=f"module:{module['id']}.config.backup.enabled",
156
+ )
157
+ )
158
+
113
159
  if any(diagnostic.level == "error" for diagnostic in diagnostics):
114
160
  return {}, diagnostics
115
161
 
@@ -134,8 +180,13 @@ def render_helm(
134
180
  "service": _service_context(module, service_names)
135
181
  }
136
182
  _release_scope_binding_hosts(context, service_names)
137
- compose_services = deepcopy(
138
- module["implementation"].get("compose", {}).get("services", {})
183
+ # The helm target is treated as production-only for now: Kubernetes
184
+ # deploys always render the production variants, regardless of the
185
+ # profile's metadata.environment. The learning (easy-install)
186
+ # variants exist for the compose target.
187
+ compose_services = apply_production_gates(
188
+ deepcopy(module["implementation"].get("compose", {}).get("services", {})),
189
+ True,
139
190
  )
140
191
  _merge_init_db_env(compose_services, module, secrets)
141
192
 
@@ -155,6 +206,7 @@ def render_helm(
155
206
  service_names=service_names,
156
207
  profile_dir=profile_dir,
157
208
  project_root=project_root,
209
+ is_production=True,
158
210
  )
159
211
  values["modules"][module_id] = {
160
212
  "config": _values_config(module.get("config", {})),
@@ -183,6 +235,11 @@ def render_helm(
183
235
 
184
236
  diagnostics.extend(_check_unresolved(files))
185
237
  diagnostics.extend(_check_secret_references(files, secret_keys))
238
+ secret_values = resolved_secret_values(declared_secret_env_names(plan), env_file)
239
+ for relative_path, content in files.items():
240
+ diagnostics.extend(check_generated_text(
241
+ content, f"{output_dir or 'chart'}/{relative_path}", secret_values
242
+ ))
186
243
 
187
244
  if output_dir and not any(
188
245
  diagnostic.level == "error" for diagnostic in diagnostics
@@ -207,6 +264,7 @@ def _render_module(
207
264
  service_names: dict[str, str],
208
265
  profile_dir: Path | None,
209
266
  project_root: Path | None,
267
+ is_production: bool = False,
210
268
  ) -> tuple[
211
269
  dict[str, Any],
212
270
  list[tuple[str, dict[str, Any]]],
@@ -220,12 +278,16 @@ def _render_module(
220
278
  secret_keys: set[str] = set()
221
279
  module_values: dict[str, Any] = {}
222
280
 
223
- volume_specs = _substitute_values(deepcopy(k8s.get("volumes", {})), context)
281
+ volume_specs = apply_production_gates(
282
+ _substitute_values(deepcopy(k8s.get("volumes", {})), context), is_production
283
+ )
284
+ configmap_specs = apply_production_gates(
285
+ _substitute_values(deepcopy(k8s.get("configMaps", {})), context), is_production
286
+ )
224
287
  for volume_spec in volume_specs.values():
225
288
  enabled_from = volume_spec.get("enabledFrom")
226
289
  if isinstance(enabled_from, str):
227
290
  volume_spec["enabledFrom"] = _resolve_expr(enabled_from, context)
228
- configmap_specs = _substitute_values(deepcopy(k8s.get("configMaps", {})), context)
229
291
 
230
292
  # ConfigMaps are module-scoped: one manifest, mounted by whichever
231
293
  # containers name it.
@@ -293,6 +355,7 @@ def _render_module(
293
355
  pod_volumes,
294
356
  pvcs,
295
357
  is_init=True,
358
+ is_production=is_production,
296
359
  )
297
360
  secret_keys |= keys
298
361
  init_containers.append(container)
@@ -316,6 +379,7 @@ def _render_module(
316
379
  pod_volumes,
317
380
  pvcs,
318
381
  is_init=False,
382
+ is_production=is_production,
319
383
  )
320
384
  secret_keys |= keys
321
385
  containers.append(container)
@@ -420,6 +484,7 @@ def _translate_container(
420
484
  pod_volumes: dict[str, dict[str, Any]],
421
485
  pvcs: dict[str, dict[str, Any]],
422
486
  is_init: bool,
487
+ is_production: bool = False,
423
488
  ) -> tuple[dict[str, Any], dict, dict, set[str]]:
424
489
  spec = _substitute_values(deepcopy(spec), context)
425
490
  spec.pop("enabledFrom", None)
@@ -483,6 +548,7 @@ def _translate_container(
483
548
  pvcs,
484
549
  overrides.get("dropVolumeMounts", []) or [],
485
550
  context=context,
551
+ is_production=is_production,
486
552
  )
487
553
  if mounts:
488
554
  container["volumeMounts"] = mounts
@@ -718,6 +784,7 @@ def _translate_volumes(
718
784
  pvcs: dict[str, dict[str, Any]],
719
785
  drop_mounts: list[str],
720
786
  context: dict[str, Any] | None = None,
787
+ is_production: bool = False,
721
788
  ) -> list[dict[str, Any]]:
722
789
  mounts: list[dict[str, Any]] = []
723
790
  dropped = {str(d).rstrip("/") for d in drop_mounts}
@@ -733,6 +800,9 @@ def _translate_volumes(
733
800
  mounts.append({"name": name, "mountPath": path})
734
801
 
735
802
  for entry in spec.get("volumes", []) or []:
803
+ if isinstance(entry, dict) and isinstance(entry.get("production"), bool):
804
+ if entry["production"] != is_production:
805
+ continue
736
806
  source, target, read_only, kind = _parse_volume_entry(entry)
737
807
  if target is None or target.rstrip("/") in dropped:
738
808
  continue
@@ -2,6 +2,8 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import argparse
5
+ import contextlib
6
+ import io
5
7
  import json
6
8
  import logging
7
9
  import os
@@ -48,6 +50,11 @@ from .planner import build_plan
48
50
  from .preflight import preflight_passed, run_preflight
49
51
  from .renderer import render_compose
50
52
  from .report import build_compliance_report
53
+ from .secret_leak import (
54
+ CLI_OUTPUT_REDACTED_CODE,
55
+ RedactingStream,
56
+ resolved_secret_values,
57
+ )
51
58
  from .security import PrecomputedRender, run_security_validation
52
59
  from .security_common import (
53
60
  COMPLIANCE_CATEGORIES,
@@ -1016,6 +1023,10 @@ def _write_env_file(
1016
1023
  )
1017
1024
  lines.append("")
1018
1025
  _atomic_write(output_path, "\n".join(lines))
1026
+ # Holds secrets: keep it owner-only even if _atomic_write's temp-file
1027
+ # creation ever changes (CDS-SEC-032). No-op semantics on Windows.
1028
+ if os.name != "nt":
1029
+ output_path.chmod(0o600)
1019
1030
 
1020
1031
 
1021
1032
  def _cds_version() -> str:
@@ -1320,7 +1331,7 @@ def _run_image_verification(
1320
1331
  return [_unverifiable_image_finding(f"Image verification failed unexpectedly: {e}")]
1321
1332
 
1322
1333
 
1323
- def _render_helm_chart(plan, output_dir, force):
1334
+ def _render_helm_chart(plan, output_dir, force, env_file=None):
1324
1335
  """
1325
1336
  Render the Helm chart for a plan and report where it landed.
1326
1337
 
@@ -1341,7 +1352,7 @@ def _render_helm_chart(plan, output_dir, force):
1341
1352
  "like a CDS-generated chart. Pass --force to overwrite it."
1342
1353
  )
1343
1354
  return 1, []
1344
- files, diags = render_helm(plan, output_dir=str(target_dir))
1355
+ files, diags = render_helm(plan, output_dir=str(target_dir), env_file=env_file)
1345
1356
  return 0, diags
1346
1357
 
1347
1358
 
@@ -1366,10 +1377,74 @@ def _maybe_notify_update() -> None:
1366
1377
  _LOGGER.debug("Automatic update notice failed", exc_info=True)
1367
1378
 
1368
1379
 
1380
+ _REDACTED_OUTPUT_COMMANDS = frozenset({"validate", "plan", "test", "security"})
1381
+ _output_redaction: tuple[Any, Any, set[str]] | None = None
1382
+
1383
+
1384
+ def _install_output_redaction(args: argparse.Namespace) -> None:
1385
+ """CDS-SEC-071: redact resolved secret values from this command's stdout/stderr.
1386
+
1387
+ Runs for commands that print plans, diagnostics or findings, unless the
1388
+ user explicitly asked for unredacted output with --reveal-secrets.
1389
+ Failing to work out the secret set must not leave output unprotected, so
1390
+ it falls back to the secret-named CDS_* variables alone.
1391
+ """
1392
+ global _output_redaction
1393
+ if args.command not in _REDACTED_OUTPUT_COMMANDS or getattr(args, "reveal_secrets", False):
1394
+ return
1395
+ declared: set[str] = set()
1396
+ env_file = None
1397
+ try:
1398
+ # The command resolves the profile itself and prints any warning;
1399
+ # don't print it a second time from this pre-pass.
1400
+ with contextlib.redirect_stdout(io.StringIO()):
1401
+ profile_path = resolve_profile_path(args.profile)
1402
+ env_file = str(resolve_env_file_path(profile_path))
1403
+ declared = _collect_profile_env_vars(profile_path, getattr(args, "environment", None))[1]
1404
+ except Exception: # noqa: BLE001 - redaction must never break the command itself
1405
+ _LOGGER.debug("Could not collect declared secrets for output redaction", exc_info=True)
1406
+ secret_values = resolved_secret_values(declared, env_file)
1407
+ if not secret_values:
1408
+ return
1409
+ hits: set[str] = set()
1410
+ _output_redaction = (sys.stdout, sys.stderr, hits)
1411
+ sys.stdout = RedactingStream(sys.stdout, secret_values, hits)
1412
+ sys.stderr = RedactingStream(sys.stderr, secret_values, hits)
1413
+
1414
+
1415
+ def _finish_output_redaction() -> None:
1416
+ global _output_redaction
1417
+ if _output_redaction is None:
1418
+ return
1419
+ original_stdout, original_stderr, hits = _output_redaction
1420
+ _output_redaction = None
1421
+ for stream in (sys.stdout, sys.stderr):
1422
+ if isinstance(stream, RedactingStream):
1423
+ stream.finish()
1424
+ else:
1425
+ stream.flush()
1426
+ sys.stdout, sys.stderr = original_stdout, original_stderr
1427
+ if hits:
1428
+ print(
1429
+ Diagnostic(
1430
+ level="warning",
1431
+ code=CLI_OUTPUT_REDACTED_CODE,
1432
+ message=(
1433
+ "[CDS-SEC-071] Output contained the resolved value of "
1434
+ f"{', '.join(sorted(hits))}; it was redacted. Find where the literal "
1435
+ "value is being copied into config and use a secrets.<alias> reference instead."
1436
+ ),
1437
+ path="cli-output",
1438
+ ).format(),
1439
+ file=sys.stderr,
1440
+ )
1441
+
1442
+
1369
1443
  def main() -> int:
1370
1444
  try:
1371
1445
  return _run()
1372
1446
  finally:
1447
+ _finish_output_redaction()
1373
1448
  _maybe_notify_update()
1374
1449
 
1375
1450
 
@@ -1897,6 +1972,8 @@ def _run() -> int:
1897
1972
  if args.command in {"validate", "render", "up", "down", "test", "state", "security"}:
1898
1973
  args.target = getattr(args, "target", None) or load_saved_target() or "compose"
1899
1974
 
1975
+ _install_output_redaction(args)
1976
+
1900
1977
  if args.command == "validate":
1901
1978
  try:
1902
1979
  profile_path = resolve_profile_path(args.profile)
@@ -2158,7 +2235,7 @@ def _run() -> int:
2158
2235
 
2159
2236
  env_file = str(resolve_env_file_path(str(source_profile)))
2160
2237
  if args.target == "helm":
2161
- code, render_diags = _render_helm_chart(plan, output_path, args.force)
2238
+ code, render_diags = _render_helm_chart(plan, output_path, args.force, env_file=env_file)
2162
2239
  all_diags = render_diags
2163
2240
  if code != 0:
2164
2241
  _record_audit("render", str(source_profile), "failure", plan=plan)
@@ -2222,7 +2299,7 @@ def _run() -> int:
2222
2299
  )
2223
2300
 
2224
2301
  if args.target == "helm":
2225
- code, render_diags = _render_helm_chart(plan, output_path, args.force)
2302
+ code, render_diags = _render_helm_chart(plan, output_path, args.force, env_file=env_file)
2226
2303
  all_diags = all_diags + render_diags
2227
2304
  if code != 0:
2228
2305
  _record_audit("render", profile_or_plan, "failure", environment=args.environment, plan=plan)
@@ -2302,7 +2379,7 @@ def _run() -> int:
2302
2379
  if args.target == "helm":
2303
2380
  project_root = resolve_project_root(profile_path)
2304
2381
  chart_dir = (Path(args.chart_dir) if args.chart_dir else project_root / "chart").resolve()
2305
- code, render_diags = _render_helm_chart(plan, chart_dir, force=True)
2382
+ code, render_diags = _render_helm_chart(plan, chart_dir, force=True, env_file=env_file)
2306
2383
  all_diags.extend(render_diags)
2307
2384
  if code != 0 or has_errors(all_diags):
2308
2385
  print_diagnostics(all_diags)