composable-data-stack 0.6.0__tar.gz → 0.7.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.6.0/composable_data_stack.egg-info → composable_data_stack-0.7.0}/PKG-INFO +30 -5
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/README.md +28 -3
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/main.py +191 -45
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/renderer.py +32 -1
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/security.py +4 -1
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0/composable_data_stack.egg-info}/PKG-INFO +30 -5
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/requires.txt +1 -1
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/pyproject.toml +2 -2
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_environment_cli.py +39 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_main.py +81 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_publish_images_workflow.py +59 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_renderer.py +86 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/LICENSE +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/__init__.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/constants.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/diagnostics.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/getter.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/graph.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/image_updates.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/image_verification.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/loader.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/overlay.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/planner.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/preflight.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resolver.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/__init__.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/contract.schema.json +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/module.schema.json +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/profile.schema.json +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/rule-schema.json +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/rule-set.json +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/secrets.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/security_common.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/state.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/up_runner.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/utils.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/validator.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/SOURCES.txt +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/top_level.txt +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/setup.cfg +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_cds_workflow.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_completion.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_compose_runtime_smoke.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_config_generation.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_definitions_uri.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_hardening.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_environment_classification.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_getter.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_graph.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_image_security_scan_workflow.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_image_updates.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_image_verification.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_module_isolation.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_modules_no_committed_secrets.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_mvp_proof_workflow.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_overlay.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_planner.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_preflight.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_python_version_alignment.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_release_version_check.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_render_example_profile.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_resolve_profile_path.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_resolver.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_secrets.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_security.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_smoke_example_profile.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_state.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_superset_hardening.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_trivyignore.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_up_runner.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_upstream_image_hardening.py +0 -0
- {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_validator.py +0 -0
{composable_data_stack-0.6.0/composable_data_stack.egg-info → composable_data_stack-0.7.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: composable-data-stack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: CLI for validating and composing composable data stack profiles
|
|
5
5
|
Author: Ronald Hensbergen
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -31,7 +31,7 @@ Provides-Extra: dev
|
|
|
31
31
|
Requires-Dist: coverage>=7.6; extra == "dev"
|
|
32
32
|
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
33
33
|
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
34
|
-
Requires-Dist: ruff==0.16.
|
|
34
|
+
Requires-Dist: ruff==0.16.5; extra == "dev"
|
|
35
35
|
Dynamic: license-file
|
|
36
36
|
|
|
37
37
|
# 🚀 Composable Data Stack (CDS)
|
|
@@ -633,8 +633,9 @@ Differences from 'dev' to 'prod':
|
|
|
633
633
|
~ spec.modules[postgres].config.storage.size: "2Gi" -> "20Gi"
|
|
634
634
|
```
|
|
635
635
|
|
|
636
|
-
|
|
637
|
-
|
|
636
|
+
Persist an environment default for a project with `cds config set environment
|
|
637
|
+
prod`; commands without `--environment` then use that overlay. An explicit
|
|
638
|
+
`--environment` always takes precedence.
|
|
638
639
|
|
|
639
640
|
---
|
|
640
641
|
|
|
@@ -654,6 +655,7 @@ before and never look for an `environments/` directory.
|
|
|
654
655
|
|cds security [profile]|Run rule-based security validation on a profile|
|
|
655
656
|
|cds diff [profile] --from \<env\> --to \<env\>|Show effective configuration differences between two environment overlays, secrets never included|
|
|
656
657
|
|cds use [profile] [--clear]|Save (show/clear) a default profile so it doesn't have to be passed to other commands|
|
|
658
|
+
|cds config get\|set\|unset\|list|Manage persisted project defaults in `.cds/config.json`|
|
|
657
659
|
|cds completion \<bash\|zsh\|powershell\>|Print shell setup instructions for tab-completion|
|
|
658
660
|
|
|
659
661
|
`init`, `validate`, `preflight`, `plan`, `render`, `up`, `test`, and `security`
|
|
@@ -674,6 +676,29 @@ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
|
|
|
674
676
|
replace conflicting local files. Successful fetches record tracking metadata
|
|
675
677
|
in `.cds/get-manifest.json` for future update workflows.
|
|
676
678
|
|
|
679
|
+
### Project defaults
|
|
680
|
+
|
|
681
|
+
`cds config` manages the gitignored `.cds/config.json` file (or the path in
|
|
682
|
+
`CDS_CONFIG_PATH`). Supported settings are `profile`, `environment`, and
|
|
683
|
+
`security.strict`:
|
|
684
|
+
|
|
685
|
+
```bash
|
|
686
|
+
cds config set profile my-profile
|
|
687
|
+
cds config set environment prod
|
|
688
|
+
cds config set security.strict true
|
|
689
|
+
cds config list
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
`profile` is stored as its resolved path, and `environment` is validated
|
|
693
|
+
against that profile's `environments/` directory. `security.strict true`
|
|
694
|
+
applies the existing production security rules even if the profile declares a
|
|
695
|
+
local environment. `cds use` remains a shortcut for setting, showing, or
|
|
696
|
+
clearing `profile`.
|
|
697
|
+
|
|
698
|
+
CLI flags take precedence over these defaults. In particular,
|
|
699
|
+
`--environment` overrides `config environment`; `CDS_PROFILE_PATH` continues
|
|
700
|
+
to override the saved profile.
|
|
701
|
+
|
|
677
702
|
`[profile]` accepts:
|
|
678
703
|
|
|
679
704
|
| Form | Example |
|
|
@@ -682,7 +707,7 @@ in `.cds/get-manifest.json` for future update workflows.
|
|
|
682
707
|
| Path to a `profile.yaml` file | `profiles/local-dagster-postgres-superset/profile.yaml` |
|
|
683
708
|
| Path to a profiles root directory | `profiles/` |
|
|
684
709
|
|
|
685
|
-
When `[profile]` is omitted, resolution falls back in order to: `CDS_PROFILE_PATH` if set (accepts the same three forms), then the default profile saved via `cds use <profile
|
|
710
|
+
When `[profile]` is omitted, resolution falls back in order to: `CDS_PROFILE_PATH` if set (accepts the same three forms), then the default profile saved via `cds config set profile` (or `cds use <profile>`), then the single profile under `profiles/` if there is exactly one. An explicitly-set env var takes precedence over the persisted project default, matching common CLI convention (env vars are per-invocation and reflect the current session more reliably than a saved, gitignored default that's easy to forget about).
|
|
686
711
|
|
|
687
712
|
To view the full list of options for any command, use the `--help` flag:
|
|
688
713
|
|
|
@@ -597,8 +597,9 @@ Differences from 'dev' to 'prod':
|
|
|
597
597
|
~ spec.modules[postgres].config.storage.size: "2Gi" -> "20Gi"
|
|
598
598
|
```
|
|
599
599
|
|
|
600
|
-
|
|
601
|
-
|
|
600
|
+
Persist an environment default for a project with `cds config set environment
|
|
601
|
+
prod`; commands without `--environment` then use that overlay. An explicit
|
|
602
|
+
`--environment` always takes precedence.
|
|
602
603
|
|
|
603
604
|
---
|
|
604
605
|
|
|
@@ -618,6 +619,7 @@ before and never look for an `environments/` directory.
|
|
|
618
619
|
|cds security [profile]|Run rule-based security validation on a profile|
|
|
619
620
|
|cds diff [profile] --from \<env\> --to \<env\>|Show effective configuration differences between two environment overlays, secrets never included|
|
|
620
621
|
|cds use [profile] [--clear]|Save (show/clear) a default profile so it doesn't have to be passed to other commands|
|
|
622
|
+
|cds config get\|set\|unset\|list|Manage persisted project defaults in `.cds/config.json`|
|
|
621
623
|
|cds completion \<bash\|zsh\|powershell\>|Print shell setup instructions for tab-completion|
|
|
622
624
|
|
|
623
625
|
`init`, `validate`, `preflight`, `plan`, `render`, `up`, `test`, and `security`
|
|
@@ -638,6 +640,29 @@ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
|
|
|
638
640
|
replace conflicting local files. Successful fetches record tracking metadata
|
|
639
641
|
in `.cds/get-manifest.json` for future update workflows.
|
|
640
642
|
|
|
643
|
+
### Project defaults
|
|
644
|
+
|
|
645
|
+
`cds config` manages the gitignored `.cds/config.json` file (or the path in
|
|
646
|
+
`CDS_CONFIG_PATH`). Supported settings are `profile`, `environment`, and
|
|
647
|
+
`security.strict`:
|
|
648
|
+
|
|
649
|
+
```bash
|
|
650
|
+
cds config set profile my-profile
|
|
651
|
+
cds config set environment prod
|
|
652
|
+
cds config set security.strict true
|
|
653
|
+
cds config list
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
`profile` is stored as its resolved path, and `environment` is validated
|
|
657
|
+
against that profile's `environments/` directory. `security.strict true`
|
|
658
|
+
applies the existing production security rules even if the profile declares a
|
|
659
|
+
local environment. `cds use` remains a shortcut for setting, showing, or
|
|
660
|
+
clearing `profile`.
|
|
661
|
+
|
|
662
|
+
CLI flags take precedence over these defaults. In particular,
|
|
663
|
+
`--environment` overrides `config environment`; `CDS_PROFILE_PATH` continues
|
|
664
|
+
to override the saved profile.
|
|
665
|
+
|
|
641
666
|
`[profile]` accepts:
|
|
642
667
|
|
|
643
668
|
| Form | Example |
|
|
@@ -646,7 +671,7 @@ in `.cds/get-manifest.json` for future update workflows.
|
|
|
646
671
|
| Path to a `profile.yaml` file | `profiles/local-dagster-postgres-superset/profile.yaml` |
|
|
647
672
|
| Path to a profiles root directory | `profiles/` |
|
|
648
673
|
|
|
649
|
-
When `[profile]` is omitted, resolution falls back in order to: `CDS_PROFILE_PATH` if set (accepts the same three forms), then the default profile saved via `cds use <profile
|
|
674
|
+
When `[profile]` is omitted, resolution falls back in order to: `CDS_PROFILE_PATH` if set (accepts the same three forms), then the default profile saved via `cds config set profile` (or `cds use <profile>`), then the single profile under `profiles/` if there is exactly one. An explicitly-set env var takes precedence over the persisted project default, matching common CLI convention (env vars are per-invocation and reflect the current session more reliably than a saved, gitignored default that's easy to forget about).
|
|
650
675
|
|
|
651
676
|
To view the full list of options for any command, use the `--help` flag:
|
|
652
677
|
|
|
@@ -132,7 +132,7 @@ def find_project_root(start: Path | None = None) -> Path:
|
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
def get_config_path() -> Path:
|
|
135
|
-
"""Location of the per-project CDS config file
|
|
135
|
+
"""Location of the per-project CDS config file."""
|
|
136
136
|
override = os.getenv("CDS_CONFIG_PATH")
|
|
137
137
|
if override:
|
|
138
138
|
return Path(override).expanduser()
|
|
@@ -140,7 +140,7 @@ def get_config_path() -> Path:
|
|
|
140
140
|
|
|
141
141
|
|
|
142
142
|
class ConfigIOError(RuntimeError):
|
|
143
|
-
"""Raised when the
|
|
143
|
+
"""Raised when the CDS config file cannot be read or written."""
|
|
144
144
|
|
|
145
145
|
|
|
146
146
|
|
|
@@ -154,7 +154,7 @@ def _read_config() -> dict:
|
|
|
154
154
|
except json.JSONDecodeError:
|
|
155
155
|
print(
|
|
156
156
|
f"WARNING {config_path} is not valid JSON; treating it as empty. "
|
|
157
|
-
"It will be overwritten by the next
|
|
157
|
+
"It will be overwritten by the next config update.",
|
|
158
158
|
file=sys.stderr,
|
|
159
159
|
)
|
|
160
160
|
return {}
|
|
@@ -164,13 +164,45 @@ def _read_config() -> dict:
|
|
|
164
164
|
if not isinstance(data, dict):
|
|
165
165
|
print(
|
|
166
166
|
f"WARNING {config_path} contains valid JSON but not a mapping; treating it as empty. "
|
|
167
|
-
"It will be overwritten by the next
|
|
167
|
+
"It will be overwritten by the next config update.",
|
|
168
168
|
file=sys.stderr,
|
|
169
169
|
)
|
|
170
170
|
return {}
|
|
171
171
|
return data
|
|
172
172
|
|
|
173
173
|
|
|
174
|
+
def _write_config(data: dict) -> Path:
|
|
175
|
+
config_path = get_config_path()
|
|
176
|
+
try:
|
|
177
|
+
if data:
|
|
178
|
+
_atomic_write(config_path, json.dumps(data, indent=2) + "\n")
|
|
179
|
+
else:
|
|
180
|
+
config_path.unlink()
|
|
181
|
+
except OSError as exc:
|
|
182
|
+
raise ConfigIOError(f"Could not update config file {config_path}: {exc}") from exc
|
|
183
|
+
return config_path
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _config_value(key: str) -> Any:
|
|
187
|
+
value: Any = _read_config()
|
|
188
|
+
for part in key.split("."):
|
|
189
|
+
if not isinstance(value, dict) or part not in value:
|
|
190
|
+
return None
|
|
191
|
+
value = value[part]
|
|
192
|
+
return value
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def load_saved_environment() -> str | None:
|
|
196
|
+
"""Return the environment overlay saved for this project, if any."""
|
|
197
|
+
environment = _config_value("environment")
|
|
198
|
+
return environment if isinstance(environment, str) and environment else None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def load_saved_security_strict() -> bool:
|
|
202
|
+
"""Return whether this project always uses production security rules."""
|
|
203
|
+
return _config_value("security.strict") is True
|
|
204
|
+
|
|
205
|
+
|
|
174
206
|
def load_saved_profile() -> str | None:
|
|
175
207
|
"""Return the profile name saved via `cds use`, if any."""
|
|
176
208
|
profile = _read_config().get("profile")
|
|
@@ -182,14 +214,9 @@ def save_profile(profile: str) -> Path:
|
|
|
182
214
|
|
|
183
215
|
Raises ConfigIOError if the config file cannot be written.
|
|
184
216
|
"""
|
|
185
|
-
config_path = get_config_path()
|
|
186
217
|
data = _read_config()
|
|
187
218
|
data["profile"] = profile
|
|
188
|
-
|
|
189
|
-
_atomic_write(config_path, json.dumps(data, indent=2) + "\n")
|
|
190
|
-
except OSError as exc:
|
|
191
|
-
raise ConfigIOError(f"Could not update config file {config_path}: {exc}") from exc
|
|
192
|
-
return config_path
|
|
219
|
+
return _write_config(data)
|
|
193
220
|
|
|
194
221
|
|
|
195
222
|
def clear_saved_profile() -> bool:
|
|
@@ -197,21 +224,88 @@ def clear_saved_profile() -> bool:
|
|
|
197
224
|
|
|
198
225
|
Raises ConfigIOError if the config file cannot be updated/removed.
|
|
199
226
|
"""
|
|
200
|
-
config_path = get_config_path()
|
|
201
227
|
data = _read_config()
|
|
202
228
|
if "profile" not in data:
|
|
203
229
|
return False
|
|
204
230
|
del data["profile"]
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
231
|
+
_write_config(data)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def set_config_value(key: str, value: str) -> Path:
|
|
236
|
+
"""Set a supported project setting and return the config file path."""
|
|
237
|
+
data = _read_config()
|
|
238
|
+
if key == "profile":
|
|
239
|
+
data["profile"] = value
|
|
240
|
+
elif key == "environment":
|
|
241
|
+
data["environment"] = value
|
|
242
|
+
elif key == "security.strict":
|
|
243
|
+
security = data.setdefault("security", {})
|
|
244
|
+
if not isinstance(security, dict):
|
|
245
|
+
raise ConfigIOError("Config key 'security' must be a mapping.")
|
|
246
|
+
security["strict"] = value == "true"
|
|
247
|
+
else:
|
|
248
|
+
raise ValueError(f"Unknown config key '{key}'.")
|
|
249
|
+
return _write_config(data)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def unset_config_value(key: str) -> bool:
|
|
253
|
+
"""Remove a supported project setting."""
|
|
254
|
+
data = _read_config()
|
|
255
|
+
if key == "profile" or key == "environment":
|
|
256
|
+
if key not in data:
|
|
257
|
+
return False
|
|
258
|
+
del data[key]
|
|
259
|
+
elif key == "security.strict":
|
|
260
|
+
security = data.get("security")
|
|
261
|
+
if not isinstance(security, dict) or "strict" not in security:
|
|
262
|
+
return False
|
|
263
|
+
del security["strict"]
|
|
264
|
+
if not security:
|
|
265
|
+
del data["security"]
|
|
266
|
+
else:
|
|
267
|
+
raise ValueError(f"Unknown config key '{key}'.")
|
|
268
|
+
_write_config(data)
|
|
212
269
|
return True
|
|
213
270
|
|
|
214
271
|
|
|
272
|
+
def _resolve_profile_for_config(profile: str) -> str:
|
|
273
|
+
resolved = resolve_profile_path(profile)
|
|
274
|
+
if not Path(resolved).is_file():
|
|
275
|
+
raise ValueError(f"Profile '{profile}' could not be found (looked for {resolved}).")
|
|
276
|
+
|
|
277
|
+
profile_root = get_profiles_root()
|
|
278
|
+
if profile_root.is_file() and Path(resolved).resolve() == profile_root.resolve():
|
|
279
|
+
expected_names = {profile_root.stem, profile_root.parent.name}
|
|
280
|
+
given_matches_file = Path(profile).resolve() == profile_root.resolve()
|
|
281
|
+
if profile not in expected_names and not given_matches_file:
|
|
282
|
+
raise ValueError(
|
|
283
|
+
f"CDS_PROFILE_PATH points to a single profile file ({profile_root}); "
|
|
284
|
+
f"'{profile}' does not identify it. Pass the file path directly, "
|
|
285
|
+
f"or use '{profile_root.stem}' or '{profile_root.parent.name}'."
|
|
286
|
+
)
|
|
287
|
+
return resolved
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _validate_environment_setting(environment: str) -> None:
|
|
291
|
+
saved_profile = load_saved_profile()
|
|
292
|
+
if not saved_profile:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
"Cannot set environment without a saved default profile. "
|
|
295
|
+
"Run `cds use <profile>` or `cds config set profile <profile>` first."
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
profile_path = resolve_profile_path(saved_profile)
|
|
299
|
+
except ValueError as exc:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
"Cannot set environment: the saved default profile could not be resolved."
|
|
302
|
+
) from exc
|
|
303
|
+
profile, _, diagnostics = resolve_profile(profile_path, environment)
|
|
304
|
+
if profile is None or has_errors(diagnostics):
|
|
305
|
+
details = "; ".join(d.message for d in diagnostics if d.level == "error")
|
|
306
|
+
raise ValueError(details or "The environment could not be resolved for the saved profile.")
|
|
307
|
+
|
|
308
|
+
|
|
215
309
|
def _resolve_profile_root(profile_root: Path) -> str | None:
|
|
216
310
|
"""
|
|
217
311
|
Resolve an ambient profiles root (CDS_PROFILE_PATH, or the default
|
|
@@ -436,7 +530,7 @@ def _add_environment_arg(subparser: argparse.ArgumentParser) -> None:
|
|
|
436
530
|
subparser.add_argument(
|
|
437
531
|
"--environment",
|
|
438
532
|
"-e",
|
|
439
|
-
default=
|
|
533
|
+
default=argparse.SUPPRESS,
|
|
440
534
|
help=(
|
|
441
535
|
"Environment overlay to apply, e.g. dev or prod. Merges "
|
|
442
536
|
"profiles/<name>/environments/<environment>.yaml over the base "
|
|
@@ -977,6 +1071,20 @@ def main() -> int:
|
|
|
977
1071
|
if argcomplete is not None:
|
|
978
1072
|
use_action.completer = profile_completer # type: ignore[attr-defined]
|
|
979
1073
|
|
|
1074
|
+
config_parser = subparsers.add_parser(
|
|
1075
|
+
"config",
|
|
1076
|
+
help="Get, set, unset, or list persisted project defaults",
|
|
1077
|
+
)
|
|
1078
|
+
config_subparsers = config_parser.add_subparsers(dest="config_command", required=True)
|
|
1079
|
+
config_get_parser = config_subparsers.add_parser("get", help="Print a persisted setting")
|
|
1080
|
+
config_get_parser.add_argument("key", choices=["profile", "environment", "security.strict"])
|
|
1081
|
+
config_set_parser = config_subparsers.add_parser("set", help="Persist a setting")
|
|
1082
|
+
config_set_parser.add_argument("key", choices=["profile", "environment", "security.strict"])
|
|
1083
|
+
config_set_parser.add_argument("value")
|
|
1084
|
+
config_unset_parser = config_subparsers.add_parser("unset", help="Remove a persisted setting")
|
|
1085
|
+
config_unset_parser.add_argument("key", choices=["profile", "environment", "security.strict"])
|
|
1086
|
+
config_subparsers.add_parser("list", help="Print all persisted settings as JSON")
|
|
1087
|
+
|
|
980
1088
|
completion_parser = subparsers.add_parser(
|
|
981
1089
|
"completion",
|
|
982
1090
|
help="Print shell setup instructions for cds tab-completion",
|
|
@@ -991,6 +1099,9 @@ def main() -> int:
|
|
|
991
1099
|
argcomplete.autocomplete(parser)
|
|
992
1100
|
|
|
993
1101
|
args = parser.parse_args()
|
|
1102
|
+
environment_explicit = hasattr(args, "environment")
|
|
1103
|
+
if args.command in {"validate", "plan", "render", "up", "test", "preflight", "init", "security"}:
|
|
1104
|
+
args.environment = getattr(args, "environment", None) or load_saved_environment()
|
|
994
1105
|
|
|
995
1106
|
if args.command == "validate":
|
|
996
1107
|
try:
|
|
@@ -1076,7 +1187,7 @@ def main() -> int:
|
|
|
1076
1187
|
pass
|
|
1077
1188
|
|
|
1078
1189
|
if is_plan_file:
|
|
1079
|
-
if
|
|
1190
|
+
if environment_explicit:
|
|
1080
1191
|
print(
|
|
1081
1192
|
"ERROR --environment is not supported when rendering a saved Plan file; "
|
|
1082
1193
|
"the environment overlay was already applied when the Plan was built."
|
|
@@ -1391,6 +1502,7 @@ def main() -> int:
|
|
|
1391
1502
|
profile_path=Path(profile_path),
|
|
1392
1503
|
env_file=env_file,
|
|
1393
1504
|
environment=args.environment,
|
|
1505
|
+
strict=load_saved_security_strict(),
|
|
1394
1506
|
redact_values=not args.reveal_secrets,
|
|
1395
1507
|
precomputed_render=PrecomputedRender(
|
|
1396
1508
|
plan=plan if plan_ok else None,
|
|
@@ -1649,9 +1761,11 @@ def main() -> int:
|
|
|
1649
1761
|
precomputed_render = PrecomputedRender(
|
|
1650
1762
|
plan=plan, rendered_compose_yaml=compose_yaml
|
|
1651
1763
|
)
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1764
|
+
strict = load_saved_security_strict()
|
|
1765
|
+
if profile is not None:
|
|
1766
|
+
profile_class = "prod" if strict else infer_profile_class(profile)
|
|
1767
|
+
else:
|
|
1768
|
+
profile_class = "prod" if strict else "local"
|
|
1655
1769
|
image_verification_kwargs = {
|
|
1656
1770
|
"compose_yaml": compose_yaml,
|
|
1657
1771
|
"profile_class": profile_class,
|
|
@@ -1663,6 +1777,7 @@ def main() -> int:
|
|
|
1663
1777
|
profile_path=Path(profile_path),
|
|
1664
1778
|
env_file=str(resolve_env_file_path(profile_path)),
|
|
1665
1779
|
environment=args.environment,
|
|
1780
|
+
strict=load_saved_security_strict(),
|
|
1666
1781
|
redact_values=not args.reveal_secrets,
|
|
1667
1782
|
precomputed_render=precomputed_render,
|
|
1668
1783
|
)
|
|
@@ -1745,40 +1860,71 @@ def main() -> int:
|
|
|
1745
1860
|
return 0
|
|
1746
1861
|
|
|
1747
1862
|
try:
|
|
1748
|
-
resolved =
|
|
1863
|
+
resolved = _resolve_profile_for_config(args.profile)
|
|
1749
1864
|
except ValueError as exc:
|
|
1750
1865
|
print(f"ERROR {exc}")
|
|
1751
1866
|
return 1
|
|
1752
1867
|
|
|
1753
|
-
|
|
1754
|
-
|
|
1868
|
+
try:
|
|
1869
|
+
config_path = save_profile(resolved)
|
|
1870
|
+
except ConfigIOError as exc:
|
|
1871
|
+
print(f"ERROR {exc}")
|
|
1755
1872
|
return 1
|
|
1873
|
+
print(f"Saved default profile: {args.profile} (resolves to {resolved})")
|
|
1874
|
+
print(f"Stored in {config_path}")
|
|
1875
|
+
return 0
|
|
1756
1876
|
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
given_matches_file = Path(args.profile).resolve() == profile_root.resolve()
|
|
1767
|
-
if args.profile not in expected_names and not given_matches_file:
|
|
1768
|
-
print(
|
|
1769
|
-
f"ERROR CDS_PROFILE_PATH points to a single profile file ({profile_root}); "
|
|
1770
|
-
f"'{args.profile}' does not identify it. Pass the file path directly, "
|
|
1771
|
-
f"or use '{profile_root.stem}' or '{profile_root.parent.name}'."
|
|
1772
|
-
)
|
|
1877
|
+
if args.command == "config":
|
|
1878
|
+
if args.config_command == "list":
|
|
1879
|
+
print(json.dumps(_read_config(), indent=2, sort_keys=True))
|
|
1880
|
+
return 0
|
|
1881
|
+
|
|
1882
|
+
if args.config_command == "get":
|
|
1883
|
+
value = _config_value(args.key)
|
|
1884
|
+
if value is None:
|
|
1885
|
+
print(f"ERROR Config key '{args.key}' is not set.")
|
|
1773
1886
|
return 1
|
|
1887
|
+
print(json.dumps(value) if isinstance(value, bool) else value)
|
|
1888
|
+
return 0
|
|
1889
|
+
|
|
1890
|
+
if args.config_command == "set":
|
|
1891
|
+
if args.key == "profile":
|
|
1892
|
+
try:
|
|
1893
|
+
value = _resolve_profile_for_config(args.value)
|
|
1894
|
+
except ValueError as exc:
|
|
1895
|
+
print(f"ERROR {exc}")
|
|
1896
|
+
return 1
|
|
1897
|
+
elif args.key == "environment":
|
|
1898
|
+
value = args.value
|
|
1899
|
+
try:
|
|
1900
|
+
_validate_environment_setting(value)
|
|
1901
|
+
except ValueError as exc:
|
|
1902
|
+
print(f"ERROR {exc}")
|
|
1903
|
+
return 1
|
|
1904
|
+
elif args.key == "security.strict":
|
|
1905
|
+
if args.value not in {"true", "false"}:
|
|
1906
|
+
print("ERROR Config key 'security.strict' must be true or false.")
|
|
1907
|
+
return 1
|
|
1908
|
+
value = args.value
|
|
1909
|
+
else:
|
|
1910
|
+
value = args.value
|
|
1911
|
+
try:
|
|
1912
|
+
config_path = set_config_value(args.key, value)
|
|
1913
|
+
except ConfigIOError as exc:
|
|
1914
|
+
print(f"ERROR {exc}")
|
|
1915
|
+
return 1
|
|
1916
|
+
print(f"Set {args.key} = {value} ({config_path})")
|
|
1917
|
+
return 0
|
|
1774
1918
|
|
|
1775
1919
|
try:
|
|
1776
|
-
|
|
1920
|
+
removed = unset_config_value(args.key)
|
|
1777
1921
|
except ConfigIOError as exc:
|
|
1778
1922
|
print(f"ERROR {exc}")
|
|
1779
1923
|
return 1
|
|
1780
|
-
|
|
1781
|
-
|
|
1924
|
+
if removed:
|
|
1925
|
+
print(f"Unset {args.key} ({get_config_path()})")
|
|
1926
|
+
else:
|
|
1927
|
+
print(f"Config key '{args.key}' is not set.")
|
|
1782
1928
|
return 0
|
|
1783
1929
|
|
|
1784
1930
|
if args.command == "completion":
|
|
@@ -244,7 +244,8 @@ def _render_services(
|
|
|
244
244
|
project_root=project_root,
|
|
245
245
|
compose_dir=compose_dir,
|
|
246
246
|
)
|
|
247
|
-
|
|
247
|
+
service_copy = _apply_image_source(service_copy, module)
|
|
248
|
+
|
|
248
249
|
# Attach to the network if network_name is provided
|
|
249
250
|
if network_name:
|
|
250
251
|
service_copy["networks"] = [network_name]
|
|
@@ -661,6 +662,36 @@ def _rewrite_depends_on(
|
|
|
661
662
|
return {**service_def, "depends_on": rewritten}
|
|
662
663
|
|
|
663
664
|
|
|
665
|
+
def _apply_image_source(
|
|
666
|
+
service_def: dict[str, Any],
|
|
667
|
+
module: dict[str, Any],
|
|
668
|
+
) -> dict[str, Any]:
|
|
669
|
+
"""
|
|
670
|
+
Swap a rendered service's build: block for a registry image: reference
|
|
671
|
+
when the module's config.image.source is "registry".
|
|
672
|
+
|
|
673
|
+
When config.image.source is "registry", any "build" key is dropped and
|
|
674
|
+
"image" is rewritten to
|
|
675
|
+
"docker.io/ronaldsoeverein/<module id>:<config.image.tag>". Emitting
|
|
676
|
+
a registry reference without a tag would silently produce an untagged
|
|
677
|
+
image, so the service is left unchanged (with the build: block intact)
|
|
678
|
+
if no tag is configured; validator enforcement of a required tag is
|
|
679
|
+
tracked separately (issue #533).
|
|
680
|
+
"""
|
|
681
|
+
image_config = module.get("config", {}).get("image", {})
|
|
682
|
+
if not isinstance(image_config, dict) or image_config.get("source") != "registry":
|
|
683
|
+
return service_def
|
|
684
|
+
|
|
685
|
+
tag = image_config.get("tag")
|
|
686
|
+
if not tag:
|
|
687
|
+
return service_def
|
|
688
|
+
|
|
689
|
+
service_copy = dict(service_def)
|
|
690
|
+
service_copy.pop("build", None)
|
|
691
|
+
service_copy["image"] = f"docker.io/ronaldsoeverein/{module.get('id')}:{tag}"
|
|
692
|
+
return service_copy
|
|
693
|
+
|
|
694
|
+
|
|
664
695
|
def _rewrite_build_context(
|
|
665
696
|
service_def: dict[str, Any],
|
|
666
697
|
module: dict[str, Any],
|
|
@@ -659,6 +659,7 @@ def run_security_validation(
|
|
|
659
659
|
env_file: str | None = None,
|
|
660
660
|
redact_values: bool = False,
|
|
661
661
|
environment: str | None = None,
|
|
662
|
+
strict: bool = False,
|
|
662
663
|
precomputed_render: PrecomputedRender | None = None,
|
|
663
664
|
) -> tuple[list[dict[str, Any]], list[Diagnostic]]:
|
|
664
665
|
"""
|
|
@@ -679,6 +680,8 @@ def run_security_validation(
|
|
|
679
680
|
profile's declared metadata.environment (and therefore the
|
|
680
681
|
production security policy applied below) reflects the overlay,
|
|
681
682
|
not just the base profile.
|
|
683
|
+
strict: Apply the production security rule class regardless
|
|
684
|
+
of the profile's declared environment.
|
|
682
685
|
precomputed_render: Optional `PrecomputedRender` used by
|
|
683
686
|
"rendered-compose"-scoped rules (e.g. CDS-SEC-070). Callers that
|
|
684
687
|
already planned and/or rendered the profile for their own
|
|
@@ -707,7 +710,7 @@ def run_security_validation(
|
|
|
707
710
|
overlay_diags = []
|
|
708
711
|
rule_set = _validate_rule_set(rule_schema_path, rule_set_path)
|
|
709
712
|
|
|
710
|
-
profile_class = infer_profile_class(profile)
|
|
713
|
+
profile_class = "prod" if strict else infer_profile_class(profile)
|
|
711
714
|
|
|
712
715
|
secrets, secret_diags = load_secrets_from_env(env_file)
|
|
713
716
|
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0/composable_data_stack.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: composable-data-stack
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
4
4
|
Summary: CLI for validating and composing composable data stack profiles
|
|
5
5
|
Author: Ronald Hensbergen
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -31,7 +31,7 @@ Provides-Extra: dev
|
|
|
31
31
|
Requires-Dist: coverage>=7.6; extra == "dev"
|
|
32
32
|
Requires-Dist: bandit>=1.7; extra == "dev"
|
|
33
33
|
Requires-Dist: pip-audit>=2.7; extra == "dev"
|
|
34
|
-
Requires-Dist: ruff==0.16.
|
|
34
|
+
Requires-Dist: ruff==0.16.5; extra == "dev"
|
|
35
35
|
Dynamic: license-file
|
|
36
36
|
|
|
37
37
|
# 🚀 Composable Data Stack (CDS)
|
|
@@ -633,8 +633,9 @@ Differences from 'dev' to 'prod':
|
|
|
633
633
|
~ spec.modules[postgres].config.storage.size: "2Gi" -> "20Gi"
|
|
634
634
|
```
|
|
635
635
|
|
|
636
|
-
|
|
637
|
-
|
|
636
|
+
Persist an environment default for a project with `cds config set environment
|
|
637
|
+
prod`; commands without `--environment` then use that overlay. An explicit
|
|
638
|
+
`--environment` always takes precedence.
|
|
638
639
|
|
|
639
640
|
---
|
|
640
641
|
|
|
@@ -654,6 +655,7 @@ before and never look for an `environments/` directory.
|
|
|
654
655
|
|cds security [profile]|Run rule-based security validation on a profile|
|
|
655
656
|
|cds diff [profile] --from \<env\> --to \<env\>|Show effective configuration differences between two environment overlays, secrets never included|
|
|
656
657
|
|cds use [profile] [--clear]|Save (show/clear) a default profile so it doesn't have to be passed to other commands|
|
|
658
|
+
|cds config get\|set\|unset\|list|Manage persisted project defaults in `.cds/config.json`|
|
|
657
659
|
|cds completion \<bash\|zsh\|powershell\>|Print shell setup instructions for tab-completion|
|
|
658
660
|
|
|
659
661
|
`init`, `validate`, `preflight`, `plan`, `render`, `up`, `test`, and `security`
|
|
@@ -674,6 +676,29 @@ destination root, `--dry-run` to inspect the copy plan first, and `--force` to
|
|
|
674
676
|
replace conflicting local files. Successful fetches record tracking metadata
|
|
675
677
|
in `.cds/get-manifest.json` for future update workflows.
|
|
676
678
|
|
|
679
|
+
### Project defaults
|
|
680
|
+
|
|
681
|
+
`cds config` manages the gitignored `.cds/config.json` file (or the path in
|
|
682
|
+
`CDS_CONFIG_PATH`). Supported settings are `profile`, `environment`, and
|
|
683
|
+
`security.strict`:
|
|
684
|
+
|
|
685
|
+
```bash
|
|
686
|
+
cds config set profile my-profile
|
|
687
|
+
cds config set environment prod
|
|
688
|
+
cds config set security.strict true
|
|
689
|
+
cds config list
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
`profile` is stored as its resolved path, and `environment` is validated
|
|
693
|
+
against that profile's `environments/` directory. `security.strict true`
|
|
694
|
+
applies the existing production security rules even if the profile declares a
|
|
695
|
+
local environment. `cds use` remains a shortcut for setting, showing, or
|
|
696
|
+
clearing `profile`.
|
|
697
|
+
|
|
698
|
+
CLI flags take precedence over these defaults. In particular,
|
|
699
|
+
`--environment` overrides `config environment`; `CDS_PROFILE_PATH` continues
|
|
700
|
+
to override the saved profile.
|
|
701
|
+
|
|
677
702
|
`[profile]` accepts:
|
|
678
703
|
|
|
679
704
|
| Form | Example |
|
|
@@ -682,7 +707,7 @@ in `.cds/get-manifest.json` for future update workflows.
|
|
|
682
707
|
| Path to a `profile.yaml` file | `profiles/local-dagster-postgres-superset/profile.yaml` |
|
|
683
708
|
| Path to a profiles root directory | `profiles/` |
|
|
684
709
|
|
|
685
|
-
When `[profile]` is omitted, resolution falls back in order to: `CDS_PROFILE_PATH` if set (accepts the same three forms), then the default profile saved via `cds use <profile
|
|
710
|
+
When `[profile]` is omitted, resolution falls back in order to: `CDS_PROFILE_PATH` if set (accepts the same three forms), then the default profile saved via `cds config set profile` (or `cds use <profile>`), then the single profile under `profiles/` if there is exactly one. An explicitly-set env var takes precedence over the persisted project default, matching common CLI convention (env vars are per-invocation and reflect the current session more reliably than a saved, gitignored default that's easy to forget about).
|
|
686
711
|
|
|
687
712
|
To view the full list of options for any command, use the `--help` flag:
|
|
688
713
|
|
|
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|
|
5
5
|
|
|
6
6
|
[project]
|
|
7
7
|
name = "composable-data-stack"
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.7.0"
|
|
9
9
|
description = "CLI for validating and composing composable data stack profiles"
|
|
10
10
|
readme = "README.md"
|
|
11
11
|
license = "Apache-2.0"
|
|
@@ -52,7 +52,7 @@ dev = [
|
|
|
52
52
|
"coverage>=7.6",
|
|
53
53
|
"bandit>=1.7",
|
|
54
54
|
"pip-audit>=2.7",
|
|
55
|
-
"ruff==0.16.
|
|
55
|
+
"ruff==0.16.5",
|
|
56
56
|
]
|
|
57
57
|
|
|
58
58
|
[project.scripts]
|
|
@@ -89,6 +89,45 @@ class EnvironmentOverlayFixture(unittest.TestCase):
|
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
class ValidatePlanRenderEnvironmentTest(EnvironmentOverlayFixture):
|
|
92
|
+
def test_configured_environment_is_used_when_flag_is_omitted(self):
|
|
93
|
+
self._write_overlay("prod", {"spec": {"modules": [{"id": "db", "config": {"replicas": 3}}]}})
|
|
94
|
+
config_path = self.root / ".cds" / "config.json"
|
|
95
|
+
config_path.parent.mkdir()
|
|
96
|
+
config_path.write_text(
|
|
97
|
+
json.dumps({"profile": str(self.profile_path), "environment": "prod"})
|
|
98
|
+
)
|
|
99
|
+
env_vars = {**self.env_vars, "CDS_CONFIG_PATH": str(config_path)}
|
|
100
|
+
|
|
101
|
+
stdout = io.StringIO()
|
|
102
|
+
with patch.dict(os.environ, env_vars, clear=False), patch.object(
|
|
103
|
+
sys, "argv", ["cds", "plan"]
|
|
104
|
+
), contextlib.redirect_stdout(stdout):
|
|
105
|
+
result = main()
|
|
106
|
+
|
|
107
|
+
self.assertEqual(result, 0, stdout.getvalue())
|
|
108
|
+
self.assertEqual(json.loads(stdout.getvalue())["environment"], "prod")
|
|
109
|
+
|
|
110
|
+
def test_explicit_environment_overrides_configured_default(self):
|
|
111
|
+
self._write_overlay("dev", {"spec": {"modules": [{"id": "db", "config": {"replicas": 2}}]}})
|
|
112
|
+
self._write_overlay("prod", {"spec": {"modules": [{"id": "db", "config": {"replicas": 3}}]}})
|
|
113
|
+
config_path = self.root / ".cds" / "config.json"
|
|
114
|
+
config_path.parent.mkdir()
|
|
115
|
+
config_path.write_text(
|
|
116
|
+
json.dumps({"profile": str(self.profile_path), "environment": "prod"})
|
|
117
|
+
)
|
|
118
|
+
env_vars = {**self.env_vars, "CDS_CONFIG_PATH": str(config_path)}
|
|
119
|
+
|
|
120
|
+
stdout = io.StringIO()
|
|
121
|
+
with patch.dict(os.environ, env_vars, clear=False), patch.object(
|
|
122
|
+
sys, "argv", ["cds", "plan", "--environment", "dev"]
|
|
123
|
+
), contextlib.redirect_stdout(stdout):
|
|
124
|
+
result = main()
|
|
125
|
+
|
|
126
|
+
self.assertEqual(result, 0, stdout.getvalue())
|
|
127
|
+
plan = json.loads(stdout.getvalue())
|
|
128
|
+
self.assertEqual(plan["environment"], "dev")
|
|
129
|
+
self.assertEqual(plan["modules"][0]["config"]["replicas"], 2)
|
|
130
|
+
|
|
92
131
|
def test_validate_uses_overlay_when_environment_given(self):
|
|
93
132
|
self._write_overlay("prod", {"spec": {"modules": [{"id": "db", "config": {"replicas": 3}}]}})
|
|
94
133
|
result, output = self._run(["cds", "validate", "--environment", "prod"])
|
|
@@ -1711,6 +1711,87 @@ class UseCommandCLITest(unittest.TestCase):
|
|
|
1711
1711
|
self.assertIn("Profile is valid", captured.getvalue())
|
|
1712
1712
|
|
|
1713
1713
|
|
|
1714
|
+
class ConfigCommandCLITest(unittest.TestCase):
|
|
1715
|
+
def setUp(self):
|
|
1716
|
+
self.repo_root = Path(__file__).resolve().parent.parent
|
|
1717
|
+
self.profiles_root = self.repo_root / "profiles"
|
|
1718
|
+
self.tmpdir = tempfile.TemporaryDirectory()
|
|
1719
|
+
self.config_path = Path(self.tmpdir.name) / "config.json"
|
|
1720
|
+
self.env_patch = patch.dict(
|
|
1721
|
+
os.environ,
|
|
1722
|
+
{"CDS_PROFILE_PATH": str(self.profiles_root), "CDS_CONFIG_PATH": str(self.config_path)},
|
|
1723
|
+
clear=False,
|
|
1724
|
+
)
|
|
1725
|
+
self.env_patch.start()
|
|
1726
|
+
|
|
1727
|
+
def tearDown(self):
|
|
1728
|
+
self.env_patch.stop()
|
|
1729
|
+
self.tmpdir.cleanup()
|
|
1730
|
+
|
|
1731
|
+
def _run(self, argv_extra):
|
|
1732
|
+
captured = io.StringIO()
|
|
1733
|
+
with patch.object(sys, "argv", ["cds", "config"] + argv_extra), contextlib.redirect_stdout(captured):
|
|
1734
|
+
result = main()
|
|
1735
|
+
return result, captured.getvalue()
|
|
1736
|
+
|
|
1737
|
+
def test_config_sets_gets_and_unsets_profile(self):
|
|
1738
|
+
result, output = self._run(["set", "profile", "local-dagster-postgres-superset"])
|
|
1739
|
+
self.assertEqual(result, 0, output)
|
|
1740
|
+
expected = str((self.profiles_root / "local-dagster-postgres-superset" / "profile.yaml").resolve())
|
|
1741
|
+
self.assertEqual(json.loads(self.config_path.read_text())["profile"], expected)
|
|
1742
|
+
|
|
1743
|
+
result, output = self._run(["get", "profile"])
|
|
1744
|
+
self.assertEqual(result, 0, output)
|
|
1745
|
+
self.assertEqual(output.strip(), expected)
|
|
1746
|
+
|
|
1747
|
+
result, output = self._run(["unset", "profile"])
|
|
1748
|
+
self.assertEqual(result, 0, output)
|
|
1749
|
+
self.assertFalse(self.config_path.exists())
|
|
1750
|
+
|
|
1751
|
+
def test_config_environment_requires_existing_overlay_for_default_profile(self):
|
|
1752
|
+
self._run(["set", "profile", "local-dagster-postgres-superset"])
|
|
1753
|
+
|
|
1754
|
+
result, output = self._run(["set", "environment", "does-not-exist"])
|
|
1755
|
+
self.assertEqual(result, 1)
|
|
1756
|
+
self.assertIn("Unknown environment", output)
|
|
1757
|
+
self.assertNotIn("environment", json.loads(self.config_path.read_text()))
|
|
1758
|
+
|
|
1759
|
+
def test_config_list_and_security_strict(self):
|
|
1760
|
+
self._run(["set", "profile", "local-dagster-postgres-superset"])
|
|
1761
|
+
result, output = self._run(["set", "security.strict", "true"])
|
|
1762
|
+
self.assertEqual(result, 0, output)
|
|
1763
|
+
|
|
1764
|
+
result, output = self._run(["get", "security.strict"])
|
|
1765
|
+
self.assertEqual(result, 0, output)
|
|
1766
|
+
self.assertEqual(output.strip(), "true")
|
|
1767
|
+
|
|
1768
|
+
result, output = self._run(["list"])
|
|
1769
|
+
self.assertEqual(result, 0, output)
|
|
1770
|
+
self.assertTrue(json.loads(output)["security"]["strict"])
|
|
1771
|
+
|
|
1772
|
+
def test_config_rejects_invalid_security_strict_value(self):
|
|
1773
|
+
result, output = self._run(["set", "security.strict", "yes"])
|
|
1774
|
+
self.assertEqual(result, 1)
|
|
1775
|
+
self.assertIn("must be true or false", output)
|
|
1776
|
+
self.assertFalse(self.config_path.exists())
|
|
1777
|
+
|
|
1778
|
+
@patch("cli.main.run_security_validation", return_value=([], []))
|
|
1779
|
+
@patch("cli.main.validate_profile", return_value=[])
|
|
1780
|
+
def test_configured_security_strict_is_passed_to_security_checks(
|
|
1781
|
+
self, mock_validate, mock_run_security
|
|
1782
|
+
):
|
|
1783
|
+
self._run(["set", "security.strict", "true"])
|
|
1784
|
+
captured = io.StringIO()
|
|
1785
|
+
with patch.object(
|
|
1786
|
+
sys, "argv", ["cds", "security", "local-dagster-postgres-superset"]
|
|
1787
|
+
), contextlib.redirect_stdout(captured):
|
|
1788
|
+
result = main()
|
|
1789
|
+
|
|
1790
|
+
self.assertEqual(result, 0, captured.getvalue())
|
|
1791
|
+
mock_validate.assert_called_once()
|
|
1792
|
+
self.assertTrue(mock_run_security.call_args.kwargs["strict"])
|
|
1793
|
+
|
|
1794
|
+
|
|
1714
1795
|
class CompletionCommandCLITest(unittest.TestCase):
|
|
1715
1796
|
def _run(self, shell):
|
|
1716
1797
|
captured = io.StringIO()
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_publish_images_workflow.py
RENAMED
|
@@ -104,6 +104,65 @@ class PublishImagesWorkflowTest(unittest.TestCase):
|
|
|
104
104
|
names.index("Push image"),
|
|
105
105
|
)
|
|
106
106
|
|
|
107
|
+
def test_dockerhub_job_has_id_token_permission_for_keyless_signing(self) -> None:
|
|
108
|
+
perms = self.jobs["publish-dockerhub"]["permissions"]
|
|
109
|
+
self.assertEqual(perms.get("id-token"), "write")
|
|
110
|
+
|
|
111
|
+
def test_dockerhub_job_signs_and_attests_by_digest(self) -> None:
|
|
112
|
+
job = self.jobs["publish-dockerhub"]
|
|
113
|
+
push_step = next(s for s in job["steps"] if s.get("id") == "push")
|
|
114
|
+
self.assertIn("$(docker inspect", push_step["run"])
|
|
115
|
+
self.assertIn('echo "digest=$digest" >> "$GITHUB_OUTPUT"', push_step["run"])
|
|
116
|
+
self.assertIn('"$ref:${prefix}sha-${GITHUB_SHA::12}"', push_step["run"])
|
|
117
|
+
|
|
118
|
+
for step_name in ("Sign image (keyless)", "Attest SBOM", "Attest provenance"):
|
|
119
|
+
with self.subTest(step=step_name):
|
|
120
|
+
step = next(s for s in job["steps"] if s.get("name") == step_name)
|
|
121
|
+
self.assertIn("steps.push.outputs.digest", step["run"])
|
|
122
|
+
|
|
123
|
+
def test_dockerhub_job_verifies_signature_and_attestations(self) -> None:
|
|
124
|
+
job = self.jobs["publish-dockerhub"]
|
|
125
|
+
verify_step = next(
|
|
126
|
+
s for s in job["steps"] if s.get("name") == "Verify signed image (self-check)"
|
|
127
|
+
)
|
|
128
|
+
verify_run = verify_step["run"]
|
|
129
|
+
self.assertIn("cosign verify \\", verify_run)
|
|
130
|
+
self.assertIn("cosign verify-attestation \\", verify_run)
|
|
131
|
+
self.assertIn("--type cyclonedx", verify_run)
|
|
132
|
+
self.assertIn("--type slsaprovenance", verify_run)
|
|
133
|
+
self.assertIn("steps.push.outputs.digest", verify_run)
|
|
134
|
+
self.assertIn(
|
|
135
|
+
"'^https://github\\.com/${{ github.repository }}/\\.github/workflows/"
|
|
136
|
+
"publish-images\\.yml@${{ github.ref }}$'",
|
|
137
|
+
verify_run,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def test_dockerhub_job_sbom_and_provenance_are_both_attested(self) -> None:
|
|
141
|
+
job = self.jobs["publish-dockerhub"]
|
|
142
|
+
names = {s.get("name") for s in job["steps"]}
|
|
143
|
+
self.assertIn("Attest SBOM", names)
|
|
144
|
+
self.assertIn("Attest provenance", names)
|
|
145
|
+
sbom_attest = next(s for s in job["steps"] if s.get("name") == "Attest SBOM")
|
|
146
|
+
self.assertIn("--type cyclonedx", sbom_attest["run"])
|
|
147
|
+
provenance_attest = next(
|
|
148
|
+
s for s in job["steps"] if s.get("name") == "Attest provenance"
|
|
149
|
+
)
|
|
150
|
+
self.assertIn("--type slsaprovenance", provenance_attest["run"])
|
|
151
|
+
|
|
152
|
+
def test_dockerhub_job_uses_cosign_installer(self) -> None:
|
|
153
|
+
job = self.jobs["publish-dockerhub"]
|
|
154
|
+
uses = [str(s.get("uses", "")) for s in job["steps"]]
|
|
155
|
+
self.assertTrue(any(u.startswith("sigstore/cosign-installer@") for u in uses))
|
|
156
|
+
|
|
157
|
+
def test_dockerhub_gate_runs_before_sign_and_push(self) -> None:
|
|
158
|
+
job = self.jobs["publish-dockerhub"]
|
|
159
|
+
names = [s.get("name") for s in job["steps"]]
|
|
160
|
+
gate_idx = names.index("Gate on HIGH/CRITICAL vulnerabilities before push")
|
|
161
|
+
push_idx = names.index("Push image")
|
|
162
|
+
sign_idx = names.index("Sign image (keyless)")
|
|
163
|
+
self.assertLess(gate_idx, push_idx)
|
|
164
|
+
self.assertLess(push_idx, sign_idx)
|
|
165
|
+
|
|
107
166
|
def test_sbom_and_provenance_are_both_attested(self) -> None:
|
|
108
167
|
names = {s.get("name") for s in self.publish_steps}
|
|
109
168
|
self.assertIn("Attest SBOM", names)
|
|
@@ -852,5 +852,91 @@ class RendererRegressionTest(unittest.TestCase):
|
|
|
852
852
|
self.assertEqual(len(errors), 1)
|
|
853
853
|
self.assertNotIn("web-app", output)
|
|
854
854
|
|
|
855
|
+
|
|
856
|
+
class ImageSourceRenderingTest(unittest.TestCase):
|
|
857
|
+
def _plan(self, image_config):
|
|
858
|
+
return {
|
|
859
|
+
"metadata": {"name": "cds-test"},
|
|
860
|
+
"modules": [
|
|
861
|
+
{
|
|
862
|
+
"id": "dagster",
|
|
863
|
+
"config": {"image": image_config},
|
|
864
|
+
"implementation": {
|
|
865
|
+
"kind": "docker-compose",
|
|
866
|
+
"compose": {
|
|
867
|
+
"services": {
|
|
868
|
+
"user-code": {
|
|
869
|
+
"build": {
|
|
870
|
+
"context": ".",
|
|
871
|
+
"dockerfile": "images/dagster/${config.image.variant}/Dockerfile",
|
|
872
|
+
},
|
|
873
|
+
"image": "local/dagster:custom",
|
|
874
|
+
},
|
|
875
|
+
"webserver": {
|
|
876
|
+
"image": "local/dagster:custom",
|
|
877
|
+
},
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
},
|
|
881
|
+
}
|
|
882
|
+
],
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
def test_default_source_build_leaves_build_block_intact(self):
|
|
886
|
+
plan = self._plan({"variant": "base", "source": "build"})
|
|
887
|
+
|
|
888
|
+
output, diagnostics = render_compose(plan)
|
|
889
|
+
|
|
890
|
+
self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0)
|
|
891
|
+
compose = yaml.safe_load(output)
|
|
892
|
+
service = compose["services"]["dagster-user-code"]
|
|
893
|
+
self.assertIn("build", service)
|
|
894
|
+
self.assertEqual(service["image"], "local/dagster:custom")
|
|
895
|
+
|
|
896
|
+
def test_source_registry_with_tag_drops_build_and_rewrites_image(self):
|
|
897
|
+
plan = self._plan({"variant": "base", "source": "registry", "tag": "1.8.0"})
|
|
898
|
+
|
|
899
|
+
output, diagnostics = render_compose(plan)
|
|
900
|
+
|
|
901
|
+
self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0)
|
|
902
|
+
compose = yaml.safe_load(output)
|
|
903
|
+
service = compose["services"]["dagster-user-code"]
|
|
904
|
+
self.assertNotIn("build", service)
|
|
905
|
+
self.assertEqual(service["image"], "docker.io/ronaldsoeverein/dagster:1.8.0")
|
|
906
|
+
|
|
907
|
+
def test_source_registry_without_tag_falls_back_to_build(self):
|
|
908
|
+
plan = self._plan({"variant": "base", "source": "registry"})
|
|
909
|
+
|
|
910
|
+
output, diagnostics = render_compose(plan)
|
|
911
|
+
|
|
912
|
+
self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0)
|
|
913
|
+
compose = yaml.safe_load(output)
|
|
914
|
+
service = compose["services"]["dagster-user-code"]
|
|
915
|
+
self.assertIn("build", service)
|
|
916
|
+
self.assertEqual(service["image"], "local/dagster:custom")
|
|
917
|
+
|
|
918
|
+
def test_source_registry_rewrites_services_without_build(self):
|
|
919
|
+
plan = self._plan({"variant": "base", "source": "registry", "tag": "1.8.0"})
|
|
920
|
+
|
|
921
|
+
output, diagnostics = render_compose(plan)
|
|
922
|
+
|
|
923
|
+
self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0)
|
|
924
|
+
compose = yaml.safe_load(output)
|
|
925
|
+
service = compose["services"]["dagster-webserver"]
|
|
926
|
+
self.assertNotIn("build", service)
|
|
927
|
+
self.assertEqual(service["image"], "docker.io/ronaldsoeverein/dagster:1.8.0")
|
|
928
|
+
|
|
929
|
+
def test_source_registry_with_variant_prefixed_tag(self):
|
|
930
|
+
plan = self._plan({"variant": "hardened", "source": "registry", "tag": "hardened-1.8.0"})
|
|
931
|
+
|
|
932
|
+
output, diagnostics = render_compose(plan)
|
|
933
|
+
|
|
934
|
+
self.assertEqual(len([d for d in diagnostics if d.level == "error"]), 0)
|
|
935
|
+
compose = yaml.safe_load(output)
|
|
936
|
+
service = compose["services"]["dagster-user-code"]
|
|
937
|
+
self.assertNotIn("build", service)
|
|
938
|
+
self.assertEqual(service["image"], "docker.io/ronaldsoeverein/dagster:hardened-1.8.0")
|
|
939
|
+
|
|
940
|
+
|
|
855
941
|
if __name__ == "__main__":
|
|
856
942
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/contract.schema.json
RENAMED
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/module.schema.json
RENAMED
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/profile.schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_compose_runtime_smoke.py
RENAMED
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_config_generation.py
RENAMED
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_definitions_uri.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_environment_classification.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_image_verification.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_mvp_proof_workflow.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_python_version_alignment.py
RENAMED
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_release_version_check.py
RENAMED
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_render_example_profile.py
RENAMED
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_resolve_profile_path.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_smoke_example_profile.py
RENAMED
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_superset_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_upstream_image_hardening.py
RENAMED
|
File without changes
|
|
File without changes
|