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.
Files changed (75) hide show
  1. {composable_data_stack-0.6.0/composable_data_stack.egg-info → composable_data_stack-0.7.0}/PKG-INFO +30 -5
  2. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/README.md +28 -3
  3. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/main.py +191 -45
  4. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/renderer.py +32 -1
  5. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/security.py +4 -1
  6. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0/composable_data_stack.egg-info}/PKG-INFO +30 -5
  7. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/requires.txt +1 -1
  8. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/pyproject.toml +2 -2
  9. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_environment_cli.py +39 -0
  10. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_main.py +81 -0
  11. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_publish_images_workflow.py +59 -0
  12. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_renderer.py +86 -0
  13. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/LICENSE +0 -0
  14. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/__init__.py +0 -0
  15. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/constants.py +0 -0
  16. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/diagnostics.py +0 -0
  17. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/getter.py +0 -0
  18. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/graph.py +0 -0
  19. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/image_updates.py +0 -0
  20. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/image_verification.py +0 -0
  21. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/loader.py +0 -0
  22. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/overlay.py +0 -0
  23. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/planner.py +0 -0
  24. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/preflight.py +0 -0
  25. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resolver.py +0 -0
  26. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/__init__.py +0 -0
  27. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/contract.schema.json +0 -0
  28. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/module.schema.json +0 -0
  29. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/profile.schema.json +0 -0
  30. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/rule-schema.json +0 -0
  31. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/resources/rule-set.json +0 -0
  32. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/secrets.py +0 -0
  33. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/security_common.py +0 -0
  34. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/state.py +0 -0
  35. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/up_runner.py +0 -0
  36. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/utils.py +0 -0
  37. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/cli/validator.py +0 -0
  38. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/SOURCES.txt +0 -0
  39. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  40. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
  41. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/composable_data_stack.egg-info/top_level.txt +0 -0
  42. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/setup.cfg +0 -0
  43. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_cds_workflow.py +0 -0
  44. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_completion.py +0 -0
  45. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_compose_runtime_smoke.py +0 -0
  46. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_config_generation.py +0 -0
  47. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_definitions_uri.py +0 -0
  48. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  49. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_dagster_hardening.py +0 -0
  50. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_environment_classification.py +0 -0
  51. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_getter.py +0 -0
  52. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_graph.py +0 -0
  53. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_image_security_scan_workflow.py +0 -0
  54. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_image_updates.py +0 -0
  55. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_image_verification.py +0 -0
  56. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_module_isolation.py +0 -0
  57. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_modules_no_committed_secrets.py +0 -0
  58. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_mvp_proof_workflow.py +0 -0
  59. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_overlay.py +0 -0
  60. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_planner.py +0 -0
  61. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_preflight.py +0 -0
  62. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_python_version_alignment.py +0 -0
  63. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_release_version_check.py +0 -0
  64. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_render_example_profile.py +0 -0
  65. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_resolve_profile_path.py +0 -0
  66. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_resolver.py +0 -0
  67. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_secrets.py +0 -0
  68. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_security.py +0 -0
  69. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_smoke_example_profile.py +0 -0
  70. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_state.py +0 -0
  71. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_superset_hardening.py +0 -0
  72. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_trivyignore.py +0 -0
  73. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_up_runner.py +0 -0
  74. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_upstream_image_hardening.py +0 -0
  75. {composable_data_stack-0.6.0 → composable_data_stack-0.7.0}/tests/test_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: composable-data-stack
3
- Version: 0.6.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.4; extra == "dev"
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
- Commands without `--environment` are unaffected they behave exactly as
637
- before and never look for an `environments/` directory.
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>`, then the single profile under `profiles/` if there is exactly one. An explicitly-set env var takes precedence over the persisted `cds use` 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).
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
- Commands without `--environment` are unaffected they behave exactly as
601
- before and never look for an `environments/` directory.
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>`, then the single profile under `profiles/` if there is exactly one. An explicitly-set env var takes precedence over the persisted `cds use` 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).
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 used by `cds use`."""
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 `cds use` config file cannot be read or written."""
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 `cds use <profile>`.",
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 `cds use <profile>`.",
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
- try:
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
- try:
206
- if data:
207
- _atomic_write(config_path, json.dumps(data, indent=2) + "\n")
208
- else:
209
- config_path.unlink()
210
- except OSError as exc:
211
- raise ConfigIOError(f"Could not update config file {config_path}: {exc}") from exc
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=None,
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 args.environment is not None:
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
- profile_class = (
1653
- infer_profile_class(profile) if profile is not None else "local"
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 = resolve_profile_path(args.profile)
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
- if not Path(resolved).is_file():
1754
- print(f"ERROR Profile '{args.profile}' could not be found (looked for {resolved}).")
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
- # When CDS_PROFILE_PATH points directly at a single profile.yaml
1758
- # file, resolve_profile_path() returns that file for *any* name
1759
- # argument (there's no profiles directory to look names up under),
1760
- # which would otherwise let `cds use <typo>` succeed silently and
1761
- # save a bogus name as if it had been validated. Require the given
1762
- # name to plausibly identify this profile before accepting it.
1763
- profile_root = get_profiles_root()
1764
- if profile_root.is_file() and Path(resolved).resolve() == profile_root.resolve():
1765
- expected_names = {profile_root.stem, profile_root.parent.name}
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
- config_path = save_profile(resolved)
1920
+ removed = unset_config_value(args.key)
1777
1921
  except ConfigIOError as exc:
1778
1922
  print(f"ERROR {exc}")
1779
1923
  return 1
1780
- print(f"Saved default profile: {args.profile} (resolves to {resolved})")
1781
- print(f"Stored in {config_path}")
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: composable-data-stack
3
- Version: 0.6.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.4; extra == "dev"
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
- Commands without `--environment` are unaffected they behave exactly as
637
- before and never look for an `environments/` directory.
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>`, then the single profile under `profiles/` if there is exactly one. An explicitly-set env var takes precedence over the persisted `cds use` 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).
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
 
@@ -11,4 +11,4 @@ argcomplete<4,>=3.0.0
11
11
  coverage>=7.6
12
12
  bandit>=1.7
13
13
  pip-audit>=2.7
14
- ruff==0.16.4
14
+ ruff==0.16.5
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "composable-data-stack"
8
- version = "0.6.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.4",
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()
@@ -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()