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.
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/PKG-INFO +1 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/getter.py +79 -13
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/image_updates.py +2 -2
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/image_verification.py +1 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/loader.py +0 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/main.py +61 -26
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/planner.py +42 -10
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/preflight.py +8 -5
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/renderer.py +125 -11
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/secrets.py +1 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/security.py +2 -3
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/security_common.py +0 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/up_runner.py +4 -4
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/utils.py +1 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/validator.py +6 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/PKG-INFO +1 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/pyproject.toml +16 -4
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_environment_classification.py +5 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_getter.py +209 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_image_verification.py +1 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_main.py +143 -14
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_module_isolation.py +43 -18
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_modules_no_committed_secrets.py +3 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_overlay.py +6 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_planner.py +101 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_render_example_profile.py +3 -9
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_renderer.py +214 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_security.py +6 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_state.py +5 -1
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/LICENSE +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/README.md +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/__init__.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/constants.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/diagnostics.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/graph.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/overlay.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resolver.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/__init__.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/contract.schema.json +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/module.schema.json +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/profile.schema.json +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/rule-schema.json +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/resources/rule-set.json +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/cli/state.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/SOURCES.txt +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/dependency_links.txt +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/entry_points.txt +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/requires.txt +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/composable_data_stack.egg-info/top_level.txt +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/setup.cfg +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_cds_workflow.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_completion.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_compose_runtime_smoke.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_config_generation.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_definitions_uri.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_entrypoint_backend_guard.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_dagster_hardening.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_environment_cli.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_graph.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_image_security_scan_workflow.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_image_updates.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_mvp_proof_workflow.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_preflight.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_publish_images_workflow.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_python_version_alignment.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_release_version_check.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_resolve_profile_path.py +3 -3
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_resolver.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_secrets.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_smoke_example_profile.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_superset_hardening.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_trivyignore.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_up_runner.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_upstream_image_hardening.py +0 -0
- {composable_data_stack-0.5.1 → composable_data_stack-0.6.0}/tests/test_validator.py +0 -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
672
|
-
except
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
|
@@ -7,9 +7,8 @@ import os
|
|
|
7
7
|
import re
|
|
8
8
|
import subprocess # nosec B404
|
|
9
9
|
import sys
|
|
10
|
-
import
|
|
11
|
-
from
|
|
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
|
-
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
22
|
from .diagnostics import Diagnostic
|
|
23
|
-
from .
|
|
24
|
-
from .
|
|
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 .
|
|
48
|
-
from .
|
|
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 (
|
|
118
|
-
|
|
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 (
|
|
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
|
|
339
|
-
directory containing
|
|
340
|
-
it
|
|
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 (
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
)
|