composable-data-stack 0.13.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.
- {composable_data_stack-0.13.0/composable_data_stack.egg-info → composable_data_stack-0.14.0}/PKG-INFO +15 -1
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/README.md +14 -0
- composable_data_stack-0.14.0/cli/confinement.py +219 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/k8s_renderer.py +62 -4
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/planner.py +69 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/preflight.py +10 -7
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/renderer.py +485 -11
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resolver.py +19 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/module.schema.json +5 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/profile.schema.json +151 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/rule-set.json +50 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/security.py +173 -1
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/validator.py +691 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0/composable_data_stack.egg-info}/PKG-INFO +15 -1
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/SOURCES.txt +10 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/pyproject.toml +1 -1
- composable_data_stack-0.14.0/tests/test_audit_trivyignore.py +121 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_cds_workflow.py +19 -37
- composable_data_stack-0.14.0/tests/test_certificates.py +224 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_completion.py +1 -2
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_dagster_entrypoint_backend_guard.py +49 -3
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_environment_cli.py +15 -1
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_k8s_renderer.py +31 -1
- composable_data_stack-0.14.0/tests/test_preflight_rootless.py +50 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_render_example_profile.py +64 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_renderer.py +156 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_report.py +21 -43
- composable_data_stack-0.14.0/tests/test_reverse_proxy_contract.py +67 -0
- composable_data_stack-0.14.0/tests/test_reverse_proxy_routes.py +786 -0
- composable_data_stack-0.14.0/tests/test_runtime_confinement.py +234 -0
- composable_data_stack-0.14.0/tests/test_runtime_policy.py +188 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_security.py +2 -0
- composable_data_stack-0.14.0/tests/test_shipped_profiles.py +40 -0
- composable_data_stack-0.14.0/tests/test_team_profile.py +123 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_validator.py +167 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_validator_extra.py +396 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/LICENSE +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/__init__.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/audit_log.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/composer.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/constants.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/diagnostics.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/getter.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/graph.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/image_digest_check.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/image_updates.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/image_verification.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/k8s_runner.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/k8s_security.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/loader.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/main.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/overlay.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/report.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/__init__.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/compatibility-registry.json +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/compatibility-registry.schema.json +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/contract.schema.json +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/resources/rule-schema.json +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/secret_leak.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/secrets.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/security_common.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/state.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/up_runner.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/update_check.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/cli/utils.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/requires.txt +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/composable_data_stack.egg-info/top_level.txt +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/setup.cfg +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_ai_profile_review.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_audit_log.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_build_cra_release_evidence_manifest.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_bump_changelog.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_check_cra_release_gate.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_check_roadmap_freshness.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_compose_runtime_smoke.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_compose_to_module.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_composer.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_composer_extra.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_cra_traceability.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_dagster_config_generation.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_dagster_definitions_uri.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_dagster_hardening.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_dbt_hardening.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_dlt_postgres_wiring.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_duckdb_hardening.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_environment_classification.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_generate_release_inventory.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_getter.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_getter_extra.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_graph.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_image_digest_check.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_image_locks.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_image_security_scan_workflow.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_image_updates.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_image_verification.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_k3d_e2e_profile.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_k3d_exposure.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_k8s_runner.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_k8s_security.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_keycloak_postgres_wiring.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_loader.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_main.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_main_extra.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_main_extra2.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_main_extra3.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_markdown_mermaid_blocks.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_module_isolation.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_modules_no_committed_secrets.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_mvp_proof_workflow.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_overlay.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_planner.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_planner_extra.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_preflight.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_preflight_extra.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_publish_images_workflow.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_python_version_alignment.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_release_notes_support.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_release_version_check.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_release_workflow.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_resolve_profile_path.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_resolver.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_secret_file_permissions.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_secret_output_scanning.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_secrets.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_smoke_example_profile.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_standalone_module_profile.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_state.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_superset_hardening.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_tender_analytics.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_tender_dashboard.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_test_runner_timeout.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_trivyignore.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_up_runner.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_update_check.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_update_check_extra.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_upstream_image_hardening.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_utils.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_validate_cli_sbom.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_vault_init_db.py +0 -0
- {composable_data_stack-0.13.0 → composable_data_stack-0.14.0}/tests/test_vault_init_db_runtime.py +0 -0
- {composable_data_stack-0.13.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.
|
|
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
|
|
@@ -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)
|
|
@@ -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,7 @@ from .renderer import (
|
|
|
37
38
|
_resolve_profile_dir,
|
|
38
39
|
_resolve_project_root,
|
|
39
40
|
_substitute_values,
|
|
41
|
+
apply_production_gates,
|
|
40
42
|
)
|
|
41
43
|
from .secret_leak import (
|
|
42
44
|
check_generated_text,
|
|
@@ -110,6 +112,21 @@ def render_helm(
|
|
|
110
112
|
path=f"module:{module_id}.implementation.kubernetes",
|
|
111
113
|
)
|
|
112
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
|
+
|
|
113
130
|
if unsupported:
|
|
114
131
|
return {}, diagnostics
|
|
115
132
|
|
|
@@ -117,6 +134,28 @@ def render_helm(
|
|
|
117
134
|
# ${k8s.service.*} can point forward to a workload declared later.
|
|
118
135
|
service_names = _collect_service_names(renderable)
|
|
119
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
|
+
|
|
120
159
|
if any(diagnostic.level == "error" for diagnostic in diagnostics):
|
|
121
160
|
return {}, diagnostics
|
|
122
161
|
|
|
@@ -141,8 +180,13 @@ def render_helm(
|
|
|
141
180
|
"service": _service_context(module, service_names)
|
|
142
181
|
}
|
|
143
182
|
_release_scope_binding_hosts(context, service_names)
|
|
144
|
-
|
|
145
|
-
|
|
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,
|
|
146
190
|
)
|
|
147
191
|
_merge_init_db_env(compose_services, module, secrets)
|
|
148
192
|
|
|
@@ -162,6 +206,7 @@ def render_helm(
|
|
|
162
206
|
service_names=service_names,
|
|
163
207
|
profile_dir=profile_dir,
|
|
164
208
|
project_root=project_root,
|
|
209
|
+
is_production=True,
|
|
165
210
|
)
|
|
166
211
|
values["modules"][module_id] = {
|
|
167
212
|
"config": _values_config(module.get("config", {})),
|
|
@@ -219,6 +264,7 @@ def _render_module(
|
|
|
219
264
|
service_names: dict[str, str],
|
|
220
265
|
profile_dir: Path | None,
|
|
221
266
|
project_root: Path | None,
|
|
267
|
+
is_production: bool = False,
|
|
222
268
|
) -> tuple[
|
|
223
269
|
dict[str, Any],
|
|
224
270
|
list[tuple[str, dict[str, Any]]],
|
|
@@ -232,12 +278,16 @@ def _render_module(
|
|
|
232
278
|
secret_keys: set[str] = set()
|
|
233
279
|
module_values: dict[str, Any] = {}
|
|
234
280
|
|
|
235
|
-
volume_specs =
|
|
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
|
+
)
|
|
236
287
|
for volume_spec in volume_specs.values():
|
|
237
288
|
enabled_from = volume_spec.get("enabledFrom")
|
|
238
289
|
if isinstance(enabled_from, str):
|
|
239
290
|
volume_spec["enabledFrom"] = _resolve_expr(enabled_from, context)
|
|
240
|
-
configmap_specs = _substitute_values(deepcopy(k8s.get("configMaps", {})), context)
|
|
241
291
|
|
|
242
292
|
# ConfigMaps are module-scoped: one manifest, mounted by whichever
|
|
243
293
|
# containers name it.
|
|
@@ -305,6 +355,7 @@ def _render_module(
|
|
|
305
355
|
pod_volumes,
|
|
306
356
|
pvcs,
|
|
307
357
|
is_init=True,
|
|
358
|
+
is_production=is_production,
|
|
308
359
|
)
|
|
309
360
|
secret_keys |= keys
|
|
310
361
|
init_containers.append(container)
|
|
@@ -328,6 +379,7 @@ def _render_module(
|
|
|
328
379
|
pod_volumes,
|
|
329
380
|
pvcs,
|
|
330
381
|
is_init=False,
|
|
382
|
+
is_production=is_production,
|
|
331
383
|
)
|
|
332
384
|
secret_keys |= keys
|
|
333
385
|
containers.append(container)
|
|
@@ -432,6 +484,7 @@ def _translate_container(
|
|
|
432
484
|
pod_volumes: dict[str, dict[str, Any]],
|
|
433
485
|
pvcs: dict[str, dict[str, Any]],
|
|
434
486
|
is_init: bool,
|
|
487
|
+
is_production: bool = False,
|
|
435
488
|
) -> tuple[dict[str, Any], dict, dict, set[str]]:
|
|
436
489
|
spec = _substitute_values(deepcopy(spec), context)
|
|
437
490
|
spec.pop("enabledFrom", None)
|
|
@@ -495,6 +548,7 @@ def _translate_container(
|
|
|
495
548
|
pvcs,
|
|
496
549
|
overrides.get("dropVolumeMounts", []) or [],
|
|
497
550
|
context=context,
|
|
551
|
+
is_production=is_production,
|
|
498
552
|
)
|
|
499
553
|
if mounts:
|
|
500
554
|
container["volumeMounts"] = mounts
|
|
@@ -730,6 +784,7 @@ def _translate_volumes(
|
|
|
730
784
|
pvcs: dict[str, dict[str, Any]],
|
|
731
785
|
drop_mounts: list[str],
|
|
732
786
|
context: dict[str, Any] | None = None,
|
|
787
|
+
is_production: bool = False,
|
|
733
788
|
) -> list[dict[str, Any]]:
|
|
734
789
|
mounts: list[dict[str, Any]] = []
|
|
735
790
|
dropped = {str(d).rstrip("/") for d in drop_mounts}
|
|
@@ -745,6 +800,9 @@ def _translate_volumes(
|
|
|
745
800
|
mounts.append({"name": name, "mountPath": path})
|
|
746
801
|
|
|
747
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
|
|
748
806
|
source, target, read_only, kind = _parse_volume_entry(entry)
|
|
749
807
|
if target is None or target.rstrip("/") in dropped:
|
|
750
808
|
continue
|
|
@@ -14,7 +14,9 @@ from .loader import load_yaml_file, resolve_module_file
|
|
|
14
14
|
from .resolver import (
|
|
15
15
|
RequiredIfSyntaxError,
|
|
16
16
|
evaluate_required_if,
|
|
17
|
+
is_certificate_ref,
|
|
17
18
|
is_secret_ref,
|
|
19
|
+
parse_certificate_ref,
|
|
18
20
|
parse_contract_ref,
|
|
19
21
|
resolve_path,
|
|
20
22
|
secret_name_from_ref,
|
|
@@ -170,6 +172,7 @@ def build_plan_from_profile(
|
|
|
170
172
|
spec = profile.get("spec", {})
|
|
171
173
|
secrets, secret_diags = load_profile_secrets(spec.get("secrets"), env_file)
|
|
172
174
|
diagnostics.extend(secret_diags)
|
|
175
|
+
certificates = build_certificate_env_map(spec.get("certificates"))
|
|
173
176
|
|
|
174
177
|
modules = spec.get("modules", [])
|
|
175
178
|
if not isinstance(modules, list):
|
|
@@ -270,6 +273,8 @@ def build_plan_from_profile(
|
|
|
270
273
|
|
|
271
274
|
# Validate secrets exist but leave "secrets.VAR" strings intact
|
|
272
275
|
resolve_secret_refs(normalized_config, secrets, f"spec.modules[{i}].config", diagnostics)
|
|
276
|
+
# Same for certificate refs (issue #578): env names only, never material
|
|
277
|
+
resolve_certificate_refs(normalized_config, certificates, f"spec.modules[{i}].config", diagnostics)
|
|
273
278
|
|
|
274
279
|
loaded = {
|
|
275
280
|
"index": i,
|
|
@@ -310,6 +315,7 @@ def build_plan_from_profile(
|
|
|
310
315
|
"version": inst["version"],
|
|
311
316
|
"dependsOn": inst["dependsOn"],
|
|
312
317
|
"config": inst["config"],
|
|
318
|
+
"security": deepcopy(inst["instance"].get("security") or {}),
|
|
313
319
|
"consumes": resolve_consumed_contracts(inst, module_instances_by_id, resolved_contracts_by_module, diagnostics, secrets),
|
|
314
320
|
"provides": resolved_contracts_by_module[inst["id"]],
|
|
315
321
|
"implementation": inst["module"].get("spec", {}).get("implementation", {}),
|
|
@@ -324,7 +330,9 @@ def build_plan_from_profile(
|
|
|
324
330
|
"environment": environment,
|
|
325
331
|
"provenance": provenance,
|
|
326
332
|
"runtime": spec.get("runtime", {}),
|
|
333
|
+
"security": deepcopy(spec.get("security") or {}),
|
|
327
334
|
"secrets": secrets,
|
|
335
|
+
"certificates": certificates,
|
|
328
336
|
"outputs": resolve_outputs(spec.get("outputs", {}), resolved_contracts_by_module, diagnostics),
|
|
329
337
|
"modules": planned_modules,
|
|
330
338
|
}
|
|
@@ -682,6 +690,67 @@ def resolve_consumed_contracts(
|
|
|
682
690
|
|
|
683
691
|
return resolved
|
|
684
692
|
|
|
693
|
+
def build_certificate_env_map(spec_certificates: Any) -> dict[str, dict[str, str]]:
|
|
694
|
+
"""
|
|
695
|
+
Map each certificate alias to its two runtime environment variable NAMES
|
|
696
|
+
(issue #578): alias -> {"cert": certEnv, "key": keyEnv}.
|
|
697
|
+
|
|
698
|
+
Only names ever flow through CDS -- certificate/key material is supplied
|
|
699
|
+
by the external secret/cert provider at runtime and is never read,
|
|
700
|
+
planned, or rendered here.
|
|
701
|
+
"""
|
|
702
|
+
if not isinstance(spec_certificates, dict):
|
|
703
|
+
return {}
|
|
704
|
+
values = spec_certificates.get("values")
|
|
705
|
+
if not isinstance(values, dict):
|
|
706
|
+
return {}
|
|
707
|
+
certificates: dict[str, dict[str, str]] = {}
|
|
708
|
+
for alias, definition in values.items():
|
|
709
|
+
if not isinstance(definition, dict):
|
|
710
|
+
continue
|
|
711
|
+
cert_env = definition.get("certEnv")
|
|
712
|
+
key_env = definition.get("keyEnv")
|
|
713
|
+
if isinstance(cert_env, str) and cert_env and isinstance(key_env, str) and key_env:
|
|
714
|
+
certificates[alias] = {"cert": cert_env, "key": key_env}
|
|
715
|
+
return certificates
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def resolve_certificate_refs(
|
|
719
|
+
obj: Any, certificates: dict[str, dict[str, str]], current_path: str, diagnostics: list[Diagnostic]
|
|
720
|
+
) -> Any:
|
|
721
|
+
"""
|
|
722
|
+
Validate that `certificates.<alias>.cert|.key` refs in planned config name
|
|
723
|
+
a declared alias, but leave the ref strings intact -- the renderer turns
|
|
724
|
+
them into ${VAR} runtime placeholders, exactly like secrets refs.
|
|
725
|
+
"""
|
|
726
|
+
if isinstance(obj, dict):
|
|
727
|
+
return {
|
|
728
|
+
key: resolve_certificate_refs(value, certificates, f"{current_path}.{key}", diagnostics)
|
|
729
|
+
for key, value in obj.items()
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if isinstance(obj, list):
|
|
733
|
+
return [
|
|
734
|
+
resolve_certificate_refs(value, certificates, f"{current_path}[{index}]", diagnostics)
|
|
735
|
+
for index, value in enumerate(obj)
|
|
736
|
+
]
|
|
737
|
+
|
|
738
|
+
if isinstance(obj, str) and is_certificate_ref(obj):
|
|
739
|
+
parsed = parse_certificate_ref(obj)
|
|
740
|
+
alias = parsed[0] if parsed else None
|
|
741
|
+
if alias is None or alias not in certificates:
|
|
742
|
+
diagnostics.append(
|
|
743
|
+
Diagnostic(
|
|
744
|
+
level="error",
|
|
745
|
+
code="E129",
|
|
746
|
+
message=f'Certificate ref "{obj}" could not be resolved.',
|
|
747
|
+
path=current_path,
|
|
748
|
+
)
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
return obj # always return unchanged
|
|
752
|
+
|
|
753
|
+
|
|
685
754
|
def resolve_secret_refs(obj: Any, secrets: dict[str, str], current_path: str, diagnostics: list[Diagnostic]) -> Any:
|
|
686
755
|
"""
|
|
687
756
|
Validate that all secrets.* references in obj exist in the secrets dict.
|
|
@@ -47,7 +47,7 @@ def run_preflight(
|
|
|
47
47
|
strict: bool = False,
|
|
48
48
|
) -> list[PreflightCheck]:
|
|
49
49
|
checks: list[PreflightCheck] = []
|
|
50
|
-
checks.extend(_check_runtime(plan.get("runtime", {})))
|
|
50
|
+
checks.extend(_check_runtime(plan.get("runtime", {}), plan))
|
|
51
51
|
checks.extend(_check_environment(compose_yaml, env_file))
|
|
52
52
|
checks.extend(_check_ports(compose_yaml))
|
|
53
53
|
checks.extend(_check_images(plan, compose_yaml, strict=strict))
|
|
@@ -58,7 +58,7 @@ def preflight_passed(checks: list[PreflightCheck]) -> bool:
|
|
|
58
58
|
return not any(check.status == "FAIL" for check in checks)
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def _check_runtime(runtime: dict[str, Any]) -> list[PreflightCheck]:
|
|
61
|
+
def _check_runtime(runtime: dict[str, Any], plan: dict[str, Any] | None = None) -> list[PreflightCheck]:
|
|
62
62
|
runtime_type = runtime.get("type")
|
|
63
63
|
if runtime_type != "docker-compose":
|
|
64
64
|
return [
|
|
@@ -93,7 +93,7 @@ def _check_runtime(runtime: dict[str, Any]) -> list[PreflightCheck]:
|
|
|
93
93
|
daemon_check, docker_info = _check_daemon()
|
|
94
94
|
checks.append(daemon_check)
|
|
95
95
|
if docker_info is not None:
|
|
96
|
-
checks.extend(_check_host_hardening(docker_info))
|
|
96
|
+
checks.extend(_check_host_hardening(docker_info, production=infer_profile_class(plan or {}) == "prod"))
|
|
97
97
|
return checks
|
|
98
98
|
|
|
99
99
|
|
|
@@ -127,7 +127,7 @@ def _check_daemon() -> tuple[PreflightCheck, dict[str, Any] | None]:
|
|
|
127
127
|
return PreflightCheck("PASS", "runtime.daemon", "Docker daemon is reachable."), info
|
|
128
128
|
|
|
129
129
|
|
|
130
|
-
def _check_host_hardening(info: dict[str, Any]) -> list[PreflightCheck]:
|
|
130
|
+
def _check_host_hardening(info: dict[str, Any], production: bool = True) -> list[PreflightCheck]:
|
|
131
131
|
"""
|
|
132
132
|
Inspect host/daemon settings from `docker info` without privileged access.
|
|
133
133
|
|
|
@@ -144,10 +144,13 @@ def _check_host_hardening(info: dict[str, Any]) -> list[PreflightCheck]:
|
|
|
144
144
|
rootless = "name=rootless" in options
|
|
145
145
|
checks = [
|
|
146
146
|
_host_check(
|
|
147
|
-
rootless,
|
|
147
|
+
rootless or not production,
|
|
148
148
|
"host.rootless",
|
|
149
|
-
"Docker daemon runs in rootless mode."
|
|
150
|
-
|
|
149
|
+
"Docker daemon runs in rootless mode."
|
|
150
|
+
if rootless
|
|
151
|
+
else "Docker daemon is not rootless (recommended for production hosts, HOST-001).",
|
|
152
|
+
"Docker daemon runs as root: a container breakout yields host root. "
|
|
153
|
+
"Use rootless Docker for production hosts (HOST-001, see docs/rootless-docker.md).",
|
|
151
154
|
),
|
|
152
155
|
_host_check(
|
|
153
156
|
rootless or "name=userns" in options,
|