composable-data-stack 0.5.1__tar.gz → 0.6.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.5.1 → composable_data_stack-0.6.0}/PKG-INFO +1 -1
  2. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/getter.py +79 -13
  3. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/image_updates.py +2 -2
  4. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/image_verification.py +1 -1
  5. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/loader.py +0 -1
  6. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/main.py +61 -26
  7. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/planner.py +42 -10
  8. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/preflight.py +8 -5
  9. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/renderer.py +125 -11
  10. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/secrets.py +1 -0
  11. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/security.py +2 -3
  12. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/security_common.py +0 -1
  13. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/up_runner.py +4 -4
  14. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/utils.py +1 -0
  15. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/validator.py +6 -1
  16. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/PKG-INFO +1 -1
  17. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/pyproject.toml +16 -4
  18. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_environment_classification.py +5 -1
  19. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_getter.py +209 -0
  20. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_image_verification.py +1 -1
  21. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_main.py +143 -14
  22. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_module_isolation.py +43 -18
  23. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_modules_no_committed_secrets.py +3 -1
  24. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_overlay.py +6 -1
  25. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_planner.py +101 -1
  26. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_render_example_profile.py +3 -9
  27. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_renderer.py +214 -0
  28. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_security.py +6 -1
  29. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_state.py +5 -1
  30. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/LICENSE +0 -0
  31. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/README.md +0 -0
  32. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/__init__.py +0 -0
  33. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/constants.py +0 -0
  34. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/diagnostics.py +0 -0
  35. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/graph.py +0 -0
  36. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/overlay.py +0 -0
  37. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resolver.py +0 -0
  38. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/__init__.py +0 -0
  39. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/contract.schema.json +0 -0
  40. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/module.schema.json +0 -0
  41. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/profile.schema.json +0 -0
  42. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/rule-schema.json +0 -0
  43. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/rule-set.json +0 -0
  44. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/state.py +0 -0
  45. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/SOURCES.txt +0 -0
  46. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
  47. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
  48. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/requires.txt +0 -0
  49. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/top_level.txt +0 -0
  50. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/setup.cfg +0 -0
  51. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_cds_workflow.py +0 -0
  52. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_completion.py +0 -0
  53. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_compose_runtime_smoke.py +0 -0
  54. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_config_generation.py +0 -0
  55. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_definitions_uri.py +0 -0
  56. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
  57. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_hardening.py +0 -0
  58. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_environment_cli.py +0 -0
  59. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_graph.py +0 -0
  60. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_image_security_scan_workflow.py +0 -0
  61. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_image_updates.py +0 -0
  62. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_mvp_proof_workflow.py +0 -0
  63. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_preflight.py +0 -0
  64. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_publish_images_workflow.py +0 -0
  65. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_python_version_alignment.py +0 -0
  66. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_release_version_check.py +0 -0
  67. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_resolve_profile_path.py +3 -3
  68. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_resolver.py +0 -0
  69. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_secrets.py +0 -0
  70. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_smoke_example_profile.py +0 -0
  71. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_superset_hardening.py +0 -0
  72. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_trivyignore.py +0 -0
  73. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_up_runner.py +0 -0
  74. {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_upstream_image_hardening.py +0 -0
  75. {composable_data_stack-0.5.1 → composable_data_stack-0.6.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.5.1
3
+ Version: 0.6.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
@@ -5,6 +5,7 @@ import os
5
5
  import re
6
6
  import shlex
7
7
  import shutil
8
+ import sys
8
9
  import tarfile
9
10
  import tempfile
10
11
  from collections.abc import Iterator
@@ -19,7 +20,6 @@ from urllib.request import Request, urlopen
19
20
  from .loader import load_yaml_file, resolve_module_dir
20
21
  from .planner import MaxNestingDepthExceeded, apply_defaults, substitute_string
21
22
 
22
-
23
23
  # The upstream repository `cds get` downloads from when no `--remote` is
24
24
  # given. Keep in sync with the `Repository` URL in pyproject.toml.
25
25
  DEFAULT_REMOTE = "RonaldHensbergen/composable-data-stack"
@@ -91,9 +91,7 @@ def fetch_profile(
91
91
  f"{rendered}{extra}"
92
92
  )
93
93
 
94
- for action in actions:
95
- action.destination.parent.mkdir(parents=True, exist_ok=True)
96
- shutil.copy2(action.source, action.destination)
94
+ _write_actions(actions)
97
95
 
98
96
  _write_tracking_manifest(
99
97
  target_root=target_root,
@@ -168,7 +166,7 @@ def _download_github_repository(owner: str, repo: str, ref: str, work_dir: Path)
168
166
  url = f"https://api.github.com/repos/{owner}/{repo}/tarball/{ref}"
169
167
  request = Request(url, headers={"User-Agent": "composable-data-stack-cds-get"})
170
168
  try:
171
- with urlopen(request, timeout=30) as response: # nosec B310 - fixed https GitHub API host
169
+ with urlopen(request, timeout=30) as response: # nosec B310 - fixed https GitHub API host # noqa: S310
172
170
  archive_bytes = response.read()
173
171
  except HTTPError as exc:
174
172
  raise GetError(
@@ -598,7 +596,14 @@ def _add_copy_action(
598
596
  actions_by_destination: dict[Path, CopyAction],
599
597
  ) -> None:
600
598
  repo_relative = source_file.resolve().relative_to(source_repo.resolve())
601
- destination = (destination_root / repo_relative).resolve()
599
+ # Deliberately do NOT call .resolve() on the combined destination path:
600
+ # destination_root is already an absolute, resolved path (see
601
+ # fetch_profile()), and resolving the full path here would follow a
602
+ # symlink planted at the destination leaf, silently swapping the
603
+ # CopyAction's destination for the symlink's target instead of the
604
+ # symlink path itself. That would defeat the is_symlink() conflict/
605
+ # write-through guards in _find_conflicts()/_write_actions() (#474).
606
+ destination = destination_root / repo_relative
602
607
  existing = actions_by_destination.get(destination)
603
608
  if existing is None:
604
609
  actions_by_destination[destination] = CopyAction(
@@ -617,6 +622,14 @@ def _find_conflicts(actions: list[CopyAction]) -> list[str]:
617
622
  conflicts: list[str] = []
618
623
  for action in actions:
619
624
  destination = action.destination
625
+ if destination.is_symlink():
626
+ # A symlink destination is always a conflict, even if it's
627
+ # dangling (Path.exists() follows symlinks and returns False for
628
+ # a broken link, which would otherwise let a pre-planted symlink
629
+ # silently bypass this check). Never write through a symlink:
630
+ # see _write_actions().
631
+ conflicts.append(action.repo_relative_path)
632
+ continue
620
633
  if not destination.exists():
621
634
  continue
622
635
  if destination.is_dir():
@@ -627,6 +640,21 @@ def _find_conflicts(actions: list[CopyAction]) -> list[str]:
627
640
  return conflicts
628
641
 
629
642
 
643
+ def _write_actions(actions: list[CopyAction]) -> None:
644
+ """Copy planned actions to their destinations, refusing to write through
645
+ a symlinked destination even when --force allowed the conflict check to
646
+ pass. Any pre-existing symlink at a destination path (dangling or not)
647
+ is removed first so shutil.copy2() always creates/overwrites a regular
648
+ file there instead of following the link to some other location on disk
649
+ (e.g. a symlink planted at profiles/foo/profile.yaml pointing outside
650
+ the destination tree)."""
651
+ for action in actions:
652
+ action.destination.parent.mkdir(parents=True, exist_ok=True)
653
+ if action.destination.is_symlink():
654
+ action.destination.unlink()
655
+ shutil.copy2(action.source, action.destination)
656
+
657
+
630
658
  def _write_tracking_manifest(
631
659
  *,
632
660
  target_root: Path,
@@ -661,21 +689,59 @@ def _write_tracking_manifest(
661
689
  profiles[requested_profile] = entry
662
690
 
663
691
  manifest_path.parent.mkdir(parents=True, exist_ok=True)
692
+ if manifest_path.is_symlink():
693
+ # Same write-through-symlink guard as _write_actions(): never follow
694
+ # a symlink planted at the tracking manifest's path.
695
+ manifest_path.unlink()
664
696
  manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
665
697
 
666
698
 
667
699
  def _read_tracking_manifest(path: Path) -> dict[str, Any]:
668
700
  if not path.exists():
669
701
  return {"version": 1, "profiles": {}}
702
+
670
703
  try:
671
- data = json.loads(path.read_text(encoding="utf-8"))
672
- except (OSError, json.JSONDecodeError):
673
- return {"version": 1, "profiles": {}}
674
- if not isinstance(data, dict):
704
+ raw_text = path.read_text(encoding="utf-8")
705
+ except OSError as exc:
706
+ # The file could not be read at all, so it cannot be backed up either;
707
+ # skip the doomed copy attempt and report the read failure directly.
708
+ print(
709
+ f"WARNING {path} could not be read ({exc}); resetting tracking manifest.",
710
+ file=sys.stderr,
711
+ )
675
712
  return {"version": 1, "profiles": {}}
676
- data.setdefault("version", 1)
677
- data.setdefault("profiles", {})
678
- return data
713
+
714
+ malformed_reason: str = ""
715
+ try:
716
+ data = json.loads(raw_text)
717
+ except json.JSONDecodeError as exc:
718
+ malformed_reason = f"invalid JSON: {exc}"
719
+ else:
720
+ if not isinstance(data, dict):
721
+ malformed_reason = "manifest root must be a JSON object"
722
+ else:
723
+ data.setdefault("version", 1)
724
+ data.setdefault("profiles", {})
725
+ return data
726
+
727
+ _backup_malformed_manifest(path, malformed_reason)
728
+ return {"version": 1, "profiles": {}}
729
+
730
+
731
+ def _backup_malformed_manifest(path: Path, reason: str) -> None:
732
+ timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
733
+ backup_path = path.with_name(f"{path.name}.corrupt-{timestamp}")
734
+ try:
735
+ shutil.copy2(path, backup_path)
736
+ backup_message = f"backed up to {backup_path}"
737
+ except OSError as exc:
738
+ backup_message = f"backup failed: {exc}"
739
+
740
+ print(
741
+ f"WARNING {path} is malformed ({reason}); resetting tracking manifest "
742
+ f"({backup_message}).",
743
+ file=sys.stderr,
744
+ )
679
745
 
680
746
 
681
747
  def _asset_root_relative_path(asset_root: Path, source_repo: Path) -> str:
@@ -239,10 +239,10 @@ def fetch_dockerhub_tags(namespace: str, repository: str, page_size: int = 100,
239
239
 
240
240
  while url and pages_read < page_limit:
241
241
  try:
242
- req = Request(url, headers={"User-Agent": "cds-image-check/1.0"})
242
+ req = Request(url, headers={"User-Agent": "cds-image-check/1.0"}) # noqa: S310
243
243
  # url is always http(s), built from the hardcoded DOCKER_HUB_API
244
244
  # constant or the scheme-validated pagination url below.
245
- with urlopen(req, timeout=10) as response: # nosec B310
245
+ with urlopen(req, timeout=10) as response: # nosec B310 # noqa: S310
246
246
  data = json.loads(response.read().decode())
247
247
  except (HTTPError, URLError, ValueError):
248
248
  return None
@@ -246,7 +246,7 @@ def _verify_with_cosign(
246
246
  command.append(image_ref)
247
247
 
248
248
  try:
249
- result = subprocess.run(command, capture_output=True, text=True, timeout=120) # nosec B603
249
+ result = subprocess.run(command, capture_output=True, text=True, timeout=120) # nosec B603 # noqa: S603
250
250
  except (OSError, subprocess.TimeoutExpired) as exc:
251
251
  return False, f"cosign invocation failed: {exc}"
252
252
 
@@ -8,7 +8,6 @@ import yaml
8
8
 
9
9
  from .diagnostics import Diagnostic
10
10
 
11
-
12
11
  _MODULE_ROOT_MARKERS = {"modules", "modules-experimental"}
13
12
 
14
13
 
@@ -7,9 +7,8 @@ import os
7
7
  import re
8
8
  import subprocess # nosec B404
9
9
  import sys
10
- import tempfile
11
- from contextlib import suppress
12
- from importlib.metadata import PackageNotFoundError, version as _package_version
10
+ from importlib.metadata import PackageNotFoundError
11
+ from importlib.metadata import version as _package_version
13
12
  from pathlib import Path
14
13
  from typing import Any
15
14
 
@@ -18,22 +17,24 @@ try:
18
17
  except ImportError:
19
18
  argcomplete = None
20
19
 
21
- from .validator import has_errors, validate_profile
20
+ import yaml
21
+
22
22
  from .diagnostics import Diagnostic
23
- from .utils import _atomic_write
24
- from .planner import build_plan
25
- from .renderer import render_compose
26
- from .image_updates import collect_module_images, check_image_update
27
- from .overlay import resolve_profile
28
- from .preflight import preflight_passed, run_preflight
29
- from .security import PrecomputedRender, run_security_validation
30
- from .security_common import SEVERITY_ORDER, infer_profile_class
23
+ from .getter import GetError, fetch_profile, format_get_plan
24
+ from .image_updates import check_image_update, collect_module_images
31
25
  from .image_verification import (
32
26
  ImagePolicy,
33
27
  default_fixture_path,
34
28
  load_policy_from_env,
35
29
  verify_images,
36
30
  )
31
+ from .loader import load_yaml_file
32
+ from .overlay import resolve_profile
33
+ from .planner import build_plan
34
+ from .preflight import preflight_passed, run_preflight
35
+ from .renderer import render_compose
36
+ from .security import PrecomputedRender, run_security_validation
37
+ from .security_common import SEVERITY_ORDER, infer_profile_class
37
38
  from .state import format_state_output, group_services_by_health, parse_compose_ps_json
38
39
  from .up_runner import (
39
40
  DEFAULT_TIMEOUT_SECONDS,
@@ -44,9 +45,8 @@ from .up_runner import (
44
45
  start_up_in_background,
45
46
  stop_log_tail,
46
47
  )
47
- from .loader import load_yaml_file
48
- from .getter import GetError, fetch_profile, format_get_plan
49
- import yaml
48
+ from .utils import _atomic_write
49
+ from .validator import has_errors, validate_profile
50
50
 
51
51
 
52
52
  def load_env_file(env_file: str = ".env") -> None:
@@ -114,12 +114,19 @@ def get_modules_root() -> Path:
114
114
  def find_project_root(start: Path | None = None) -> Path:
115
115
  """
116
116
  Walk up from `start` (default: current working directory) looking for a
117
- project root marker (pyproject.toml or .git). Falls back to `start` itself
118
- if no marker is found.
117
+ project root marker: `.cds` (CDS's own state directory, created by
118
+ `cds get`/`cds use`), `pyproject.toml`, or `.git`. `.cds` is checked first
119
+ at each level so a CDS working directory takes priority over an unrelated
120
+ ancestor repository (e.g. a dotfiles repo at $HOME) that happens to sit
121
+ further up the tree. Falls back to `start` itself if no marker is found.
119
122
  """
120
123
  current = (start or Path.cwd()).resolve()
121
124
  for directory in [current, *current.parents]:
122
- if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
125
+ if (
126
+ (directory / ".cds").exists()
127
+ or (directory / "pyproject.toml").exists()
128
+ or (directory / ".git").exists()
129
+ ):
123
130
  return directory
124
131
  return current
125
132
 
@@ -335,13 +342,19 @@ def resolve_project_root(profile_path: str) -> Path:
335
342
  """
336
343
  Resolve a project root for output artifacts.
337
344
 
338
- The resolver walks up from the selected profile location and picks the first
339
- directory containing either pyproject.toml or .git. If no marker is found,
340
- it falls back to the current working directory.
345
+ The resolver walks up from the selected profile location and picks the
346
+ first directory containing `.cds` (CDS's own state directory, checked
347
+ first so it takes priority over an unrelated ancestor repository),
348
+ `pyproject.toml`, or `.git`. If no marker is found, it falls back to the
349
+ current working directory.
341
350
  """
342
351
  start = Path(profile_path).resolve().parent
343
352
  for directory in [start, *start.parents]:
344
- if (directory / "pyproject.toml").exists() or (directory / ".git").exists():
353
+ if (
354
+ (directory / ".cds").exists()
355
+ or (directory / "pyproject.toml").exists()
356
+ or (directory / ".git").exists()
357
+ ):
345
358
  return directory
346
359
  return Path.cwd().resolve()
347
360
 
@@ -432,6 +445,19 @@ def _add_environment_arg(subparser: argparse.ArgumentParser) -> None:
432
445
  )
433
446
 
434
447
 
448
+ def _add_hardened_arg(subparser: argparse.ArgumentParser) -> None:
449
+ subparser.add_argument(
450
+ "--hardened",
451
+ action="store_true",
452
+ help=(
453
+ "Override config.image.variant to the Alpine-hardened build for any "
454
+ "module whose configSchema exposes an image.variant option (currently "
455
+ "only modules/orchestration/dagster), without editing the profile YAML. "
456
+ "Modules without that config option are left unchanged."
457
+ ),
458
+ )
459
+
460
+
435
461
  def _collect_profile_env_vars(
436
462
  profile_path: str, environment: str | None = None
437
463
  ) -> tuple[list[str], set[str]]:
@@ -756,6 +782,7 @@ def main() -> int:
756
782
  "-o",
757
783
  help="Output file path for rendered output (default: <project-root>/docker-compose.yml)",
758
784
  )
785
+ _add_hardened_arg(render_parser)
759
786
 
760
787
  up_parser = subparsers.add_parser(
761
788
  "up",
@@ -792,6 +819,7 @@ def main() -> int:
792
819
  action="store_true",
793
820
  help="Disable colored labels in the live state view",
794
821
  )
822
+ _add_hardened_arg(up_parser)
795
823
 
796
824
  test_parser = subparsers.add_parser(
797
825
  "test",
@@ -807,6 +835,7 @@ def main() -> int:
807
835
  "By default, values are redacted to avoid echoing real secrets to stdout/CI logs."
808
836
  ),
809
837
  )
838
+ _add_hardened_arg(test_parser)
810
839
 
811
840
  preflight_parser = subparsers.add_parser(
812
841
  "preflight",
@@ -1091,7 +1120,9 @@ def main() -> int:
1091
1120
  return 1
1092
1121
 
1093
1122
  env_file = str(resolve_env_file_path(profile_path))
1094
- plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=args.environment)
1123
+ plan, plan_diags = build_plan(
1124
+ profile_path, env_file=env_file, environment=args.environment, hardened=args.hardened
1125
+ )
1095
1126
  all_diags = diagnostics + plan_diags
1096
1127
  if has_errors(all_diags):
1097
1128
  print_diagnostics(all_diags)
@@ -1128,7 +1159,9 @@ def main() -> int:
1128
1159
  return 1
1129
1160
 
1130
1161
  env_file = str(resolve_env_file_path(profile_path))
1131
- plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=args.environment)
1162
+ plan, plan_diags = build_plan(
1163
+ profile_path, env_file=env_file, environment=args.environment, hardened=args.hardened
1164
+ )
1132
1165
  all_diags = diagnostics + plan_diags
1133
1166
  if has_errors(all_diags):
1134
1167
  print_diagnostics(all_diags)
@@ -1343,7 +1376,9 @@ def main() -> int:
1343
1376
  render_diags: list[Diagnostic] = []
1344
1377
  render_ok = False
1345
1378
  if validate_ok:
1346
- plan, plan_diags = build_plan(profile_path, env_file=env_file, environment=args.environment)
1379
+ plan, plan_diags = build_plan(
1380
+ profile_path, env_file=env_file, environment=args.environment, hardened=args.hardened
1381
+ )
1347
1382
  plan_ok = not has_errors(diagnostics + plan_diags)
1348
1383
  if plan_ok:
1349
1384
  compose_yaml, render_diags = render_compose(plan, env_file=env_file)
@@ -1468,7 +1503,7 @@ def main() -> int:
1468
1503
 
1469
1504
  ps_cmd = ["docker", "compose", "-f", str(compose_path), "ps", "-a", "--format", "json"]
1470
1505
  try:
1471
- ps_result = subprocess.run(ps_cmd, capture_output=True, text=True) # nosec B603
1506
+ ps_result = subprocess.run(ps_cmd, capture_output=True, text=True) # nosec B603 # noqa: S603
1472
1507
  except FileNotFoundError:
1473
1508
  print("ERROR docker was not found. Install Docker and ensure it is on your PATH.")
1474
1509
  return 1
@@ -1,23 +1,24 @@
1
1
  # cli/planner.py
2
2
  from __future__ import annotations
3
3
 
4
+ import os
5
+ import re
4
6
  from copy import deepcopy
7
+ from dataclasses import dataclass
5
8
  from pathlib import Path
6
9
  from typing import Any
7
- import os
8
- import re
9
10
 
10
11
  from .constants import MAX_NESTING_DEPTH, MaxNestingDepthExceeded
11
12
  from .diagnostics import Diagnostic
12
13
  from .loader import load_yaml_file, resolve_module_file
13
- from .resolver import is_secret_ref, parse_contract_ref, resolve_path, secret_name_from_ref
14
+ from .resolver import (
15
+ is_secret_ref,
16
+ parse_contract_ref,
17
+ resolve_path,
18
+ secret_name_from_ref,
19
+ )
14
20
  from .secrets import load_profile_secrets
15
21
 
16
- from dataclasses import dataclass
17
-
18
-
19
-
20
-
21
22
 
22
23
  @dataclass
23
24
  class SecretRef:
@@ -28,6 +29,7 @@ def build_plan(
28
29
  profile_path: str,
29
30
  env_file: str | None = None,
30
31
  environment: str | None = None,
32
+ hardened: bool = False,
31
33
  ) -> tuple[dict[str, Any] | None, list[Diagnostic]]:
32
34
  """
33
35
  Build a resolved plan from a profile.
@@ -40,6 +42,14 @@ def build_plan(
40
42
  overlay is merged over the base profile before planning; see
41
43
  cli.overlay.resolve_profile. Value provenance for the merge is
42
44
  recorded on the returned plan under "provenance".
45
+ hardened: When True, overrides `config.image.variant` to "hardened"
46
+ for any module instance whose configSchema exposes an
47
+ `image.variant` property (currently only
48
+ modules/orchestration/dagster), leaving modules without that
49
+ config option untouched. This is the CLI-level `--hardened`
50
+ convenience flag (see cli/main.py's up/render/test subcommands)
51
+ for switching to the Alpine-hardened image variant without
52
+ hand-editing the profile YAML.
43
53
 
44
54
  Returns:
45
55
  Tuple of (plan, diagnostics)
@@ -150,6 +160,8 @@ def build_plan(
150
160
  module_instance.get("config", {}),
151
161
  module_def.get("spec", {}).get("configSchema", {})
152
162
  )
163
+ if hardened and _supports_image_variant(module_def):
164
+ normalized_config.setdefault("image", {})["variant"] = "hardened"
153
165
  except MaxNestingDepthExceeded:
154
166
  diagnostics.append(Diagnostic(
155
167
  level="error",
@@ -253,6 +265,22 @@ def _substitute_config_env_vars(
253
265
  return _CDS_VAR_PATTERN.sub(_replace, obj)
254
266
  return obj
255
267
 
268
+ def _supports_image_variant(module_def: dict[str, Any]) -> bool:
269
+ """
270
+ True if a module's configSchema exposes a `image.variant` property
271
+ (currently only modules/orchestration/dagster), which is what the
272
+ `--hardened` CLI flag overrides. Modules without this config option
273
+ (e.g. warehouse/postgres, bi/superset) are left untouched.
274
+ """
275
+ image_schema = (
276
+ module_def.get("spec", {})
277
+ .get("configSchema", {})
278
+ .get("properties", {})
279
+ .get("image", {})
280
+ )
281
+ return "variant" in image_schema.get("properties", {})
282
+
283
+
256
284
  def apply_defaults(config: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
257
285
  config_copy = deepcopy(config)
258
286
  return _apply_schema_defaults(config_copy, schema)
@@ -307,10 +335,13 @@ def _apply_schema_defaults(value: Any, schema: dict[str, Any], _depth: int = 0)
307
335
 
308
336
  return value
309
337
 
310
- def resolve_provided_contracts(inst: dict[str, Any], secrets: dict[str, str] = {}) -> dict[str, Any]:
338
+ def resolve_provided_contracts(
339
+ inst: dict[str, Any], secrets: dict[str, str] | None = None
340
+ ) -> dict[str, Any]:
311
341
  """
312
342
  Resolve contracts provided by a module instance.
313
343
  """
344
+ secrets = secrets if secrets is not None else {}
314
345
  provides = inst["module"].get("spec", {}).get("provides", [])
315
346
  resolved: dict[str, Any] = {}
316
347
  service_name = inst["id"]
@@ -340,8 +371,9 @@ def resolve_consumed_contracts(
340
371
  modules_by_id: dict[str, dict[str, Any]],
341
372
  resolved_contracts_by_module: dict[str, dict[str, Any]],
342
373
  diagnostics: list[Diagnostic],
343
- secrets: dict[str, str] = {},
374
+ secrets: dict[str, str] | None = None,
344
375
  ) -> dict[str, Any]:
376
+ secrets = secrets if secrets is not None else {}
345
377
  consumes = inst["module"].get("spec", {}).get("consumes", [])
346
378
  resolved: dict[str, Any] = {}
347
379
 
@@ -11,14 +11,17 @@ from typing import Any
11
11
 
12
12
  import yaml
13
13
 
14
- from .image_verification import default_fixture_path, load_policy_from_env, verify_images
14
+ from .image_verification import (
15
+ default_fixture_path,
16
+ load_policy_from_env,
17
+ verify_images,
18
+ )
15
19
  from .security_common import SECRET_KEY_SEGMENT_RE, infer_profile_class
16
20
 
17
-
18
21
  _ENV_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)([^}]*)\}")
19
22
  # Docker publishes ports on every IPv4 interface when no host IP is specified.
20
23
  # Preflight uses this only for a short-lived availability probe.
21
- _DOCKER_WILDCARD_IPV4 = "0.0.0.0" # nosec B104
24
+ _DOCKER_WILDCARD_IPV4 = "0.0.0.0" # nosec B104 # noqa: S104
22
25
 
23
26
 
24
27
  @dataclass(frozen=True)
@@ -95,7 +98,7 @@ def _run_runtime_command(
95
98
  failure_message: str,
96
99
  ) -> PreflightCheck:
97
100
  try:
98
- result = subprocess.run( # nosec B603
101
+ result = subprocess.run( # nosec B603 # noqa: S603
99
102
  command,
100
103
  capture_output=True,
101
104
  text=True,
@@ -171,7 +174,7 @@ def _check_environment(compose_yaml: str, env_file: Path) -> list[PreflightCheck
171
174
  PreflightCheck(
172
175
  "WARN",
173
176
  "environment.placeholders",
174
- "Replace placeholder values for: "
177
+ "Replace placeholder values for: " # noqa: S608
175
178
  + ", ".join(placeholders)
176
179
  + ".",
177
180
  )