composable-data-stack 0.4.0__py3-none-any.whl

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.
cli/secrets.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ Secret resolution from .env files and environment.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .diagnostics import Diagnostic
11
+
12
+ def load_secrets_from_env(env_file: Path | None = None) -> tuple[dict[str, str], list[Diagnostic]]:
13
+ """
14
+ Load CDS_* secrets from .env file and environment variables.
15
+
16
+ Only keys prefixed with CDS_ are loaded — arbitrary .env keys are ignored.
17
+ Profile-defined logical aliases (spec.secrets.values) are handled separately
18
+ by load_profile_secrets and are the sole source of non-CDS_ keys in secrets.
19
+
20
+ Priority (lowest to highest):
21
+ 1. .env file (if provided)
22
+ 2. Environment variables
23
+
24
+ Args:
25
+ env_file: Optional path to .env file. If None, looks for .env in current directory.
26
+
27
+ Returns:
28
+ Tuple of (secrets_dict, diagnostics)
29
+ """
30
+ diagnostics: list[Diagnostic] = []
31
+ secrets: dict[str, str] = {}
32
+
33
+ if env_file is None:
34
+ env_file = Path(".env")
35
+ else:
36
+ env_file = Path(env_file)
37
+
38
+ if env_file.exists():
39
+ try:
40
+ with open(env_file, encoding="utf-8-sig") as f:
41
+ for line_num, line in enumerate(f, 1):
42
+ line = line.rstrip("\n\r")
43
+
44
+ if not line or line.startswith("#"):
45
+ continue
46
+
47
+ if "=" not in line:
48
+ diagnostics.append(
49
+ Diagnostic(
50
+ level="warning",
51
+ code="W090",
52
+ message=f'Invalid .env line format: "{line}" (expected KEY=VALUE)',
53
+ path=f"{env_file}:{line_num}",
54
+ )
55
+ )
56
+ continue
57
+
58
+ key, _, value = line.partition("=")
59
+ key = key.strip()
60
+ value = value.strip()
61
+
62
+ if (value.startswith('"') and value.endswith('"')) or \
63
+ (value.startswith("'") and value.endswith("'")):
64
+ value = value[1:-1]
65
+
66
+ # Only accept CDS_* keys from .env — logical aliases come from the profile
67
+ if key and key.startswith("CDS_"):
68
+ secrets[key] = value
69
+
70
+ except (OSError, UnicodeDecodeError) as e:
71
+ diagnostics.append(
72
+ Diagnostic(
73
+ level="error",
74
+ code="E080",
75
+ message=f"Failed to read .env file: {e}",
76
+ path=str(env_file),
77
+ )
78
+ )
79
+ return {}, diagnostics
80
+
81
+ # Environment variables — already CDS_* filtered, no change needed
82
+ for key, value in os.environ.items():
83
+ if key.startswith("CDS_"):
84
+ secrets[key] = value
85
+
86
+ return secrets, diagnostics
87
+
88
+ def resolve_secret(key: str, secrets: dict[str, str], required: bool = False) -> tuple[str | None, Diagnostic | None]:
89
+ """
90
+ Resolve a single secret by key.
91
+
92
+ Args:
93
+ key: Secret key (e.g., "CDS_ANALYTICS_POSTGRES_PASSWORD")
94
+ secrets: Dictionary of available secrets
95
+ required: If True, emit error if secret is missing
96
+
97
+ Returns:
98
+ Tuple of (value, diagnostic). Value is None if not found.
99
+ """
100
+ if key in secrets:
101
+ return secrets[key], None
102
+
103
+ if required:
104
+ return None, Diagnostic(
105
+ level="error",
106
+ code="E081",
107
+ message=f'Required secret "{key}" not found in .env or environment',
108
+ path=f"secrets.{key}",
109
+ )
110
+
111
+ return None, None
112
+
113
+ def load_profile_secrets(
114
+ spec_secrets: dict[str, Any] | None,
115
+ env_file: Path | None = None,
116
+ ) -> tuple[dict[str, str], list[Diagnostic]]:
117
+ """
118
+ Returns:
119
+ secrets dict where:
120
+ - CDS_* keys map to themselves (e.g. CDS_DB_PASSWORD -> CDS_DB_PASSWORD)
121
+ - logical alias keys map to their CDS_* env var NAME (not value)
122
+ """
123
+ diagnostics: list[Diagnostic] = []
124
+ raw_secrets, secret_diags = load_secrets_from_env(env_file)
125
+ diagnostics.extend(secret_diags)
126
+
127
+ # Keep only env variable names in the returned mapping; never include values.
128
+ secrets: dict[str, str] = {name: name for name in raw_secrets.keys()}
129
+
130
+ if not isinstance(spec_secrets, dict):
131
+ return secrets, diagnostics
132
+
133
+ values = spec_secrets.get("values", {})
134
+ if not isinstance(values, dict):
135
+ return secrets, diagnostics
136
+
137
+ for secret_name, secret_def in values.items():
138
+ if not isinstance(secret_def, dict):
139
+ diagnostics.append(Diagnostic(
140
+ level="error", code="E082",
141
+ message=f'Secret definition "{secret_name}" must be an object.',
142
+ path=f"spec.secrets.values.{secret_name}",
143
+ ))
144
+ continue
145
+
146
+ env_name = secret_def.get("env")
147
+ required = secret_def.get("required", False)
148
+
149
+ if not isinstance(env_name, str) or not env_name:
150
+ diagnostics.append(Diagnostic(
151
+ level="error", code="E082",
152
+ message=f'Secret definition "{secret_name}" must include a valid env name.',
153
+ path=f"spec.secrets.values.{secret_name}.env",
154
+ ))
155
+ continue
156
+
157
+ if env_name not in raw_secrets:
158
+ if required:
159
+ diagnostics.append(Diagnostic(
160
+ level="error", code="E081",
161
+ message=f'Required secret "{env_name}" not found in environment.',
162
+ path=f"spec.secrets.values.{secret_name}",
163
+ ))
164
+ continue
165
+
166
+ # Map logical name → CDS_* var name, NOT the value.
167
+ secrets[secret_name] = env_name # e.g. "analytics_postgres_password" → "CDS_ANALYTICS_POSTGRES_PASSWORD"
168
+
169
+ return secrets, diagnostics