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/__init__.py +1 -0
- cli/diagnostics.py +13 -0
- cli/graph.py +58 -0
- cli/image_updates.py +326 -0
- cli/image_verification.py +484 -0
- cli/loader.py +180 -0
- cli/main.py +1656 -0
- cli/overlay.py +239 -0
- cli/planner.py +618 -0
- cli/preflight.py +418 -0
- cli/renderer.py +791 -0
- cli/resolver.py +28 -0
- cli/resources/__init__.py +1 -0
- cli/resources/rule-schema.json +274 -0
- cli/resources/rule-set.json +919 -0
- cli/secrets.py +169 -0
- cli/security.py +768 -0
- cli/security_common.py +41 -0
- cli/state.py +112 -0
- cli/up_runner.py +257 -0
- cli/validator.py +570 -0
- composable_data_stack-0.4.0.dist-info/METADATA +872 -0
- composable_data_stack-0.4.0.dist-info/RECORD +27 -0
- composable_data_stack-0.4.0.dist-info/WHEEL +5 -0
- composable_data_stack-0.4.0.dist-info/entry_points.txt +2 -0
- composable_data_stack-0.4.0.dist-info/licenses/LICENSE +201 -0
- composable_data_stack-0.4.0.dist-info/top_level.txt +1 -0
cli/planner.py
ADDED
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# cli/planner.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .diagnostics import Diagnostic
|
|
11
|
+
from .loader import load_yaml_file, resolve_module_file
|
|
12
|
+
from .resolver import is_secret_ref, parse_contract_ref, resolve_path, secret_name_from_ref
|
|
13
|
+
from .secrets import load_profile_secrets
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
# Guards recursive traversal of user-controlled YAML structures (schema
|
|
18
|
+
# defaults, contract/config interpolation) against maliciously or accidentally
|
|
19
|
+
# deeply nested documents that would otherwise raise an unhandled
|
|
20
|
+
# RecursionError / stack overflow.
|
|
21
|
+
MAX_NESTING_DEPTH = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MaxNestingDepthExceeded(Exception):
|
|
25
|
+
"""Raised when a recursive structure exceeds MAX_NESTING_DEPTH."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SecretRef:
|
|
30
|
+
"""Signals that a value should be emitted as a runtime ${VAR} placeholder."""
|
|
31
|
+
var_name: str
|
|
32
|
+
|
|
33
|
+
def build_plan(
|
|
34
|
+
profile_path: str,
|
|
35
|
+
env_file: str | None = None,
|
|
36
|
+
environment: str | None = None,
|
|
37
|
+
) -> tuple[dict[str, Any] | None, list[Diagnostic]]:
|
|
38
|
+
"""
|
|
39
|
+
Build a resolved plan from a profile.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
profile_path: Path to profile.yaml
|
|
43
|
+
env_file: Optional path to .env file for secrets
|
|
44
|
+
environment: Optional environment overlay name (e.g. "dev", "prod").
|
|
45
|
+
When set, profile_path's profiles/<name>/environments/<environment>.yaml
|
|
46
|
+
overlay is merged over the base profile before planning; see
|
|
47
|
+
cli.overlay.resolve_profile. Value provenance for the merge is
|
|
48
|
+
recorded on the returned plan under "provenance".
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Tuple of (plan, diagnostics)
|
|
52
|
+
"""
|
|
53
|
+
diagnostics: list[Diagnostic] = []
|
|
54
|
+
|
|
55
|
+
profile_file = Path(profile_path)
|
|
56
|
+
provenance: dict[str, str] = {}
|
|
57
|
+
if environment is not None:
|
|
58
|
+
# Local import: cli.overlay imports from cli.validator, which this
|
|
59
|
+
# module does not otherwise depend on; keep the dependency scoped to
|
|
60
|
+
# avoid pulling in an import cycle for callers that never use overlays.
|
|
61
|
+
from .overlay import resolve_profile
|
|
62
|
+
|
|
63
|
+
profile, provenance, diags = resolve_profile(profile_path, environment)
|
|
64
|
+
else:
|
|
65
|
+
profile, diags = load_yaml_file(profile_file)
|
|
66
|
+
diagnostics.extend(diags)
|
|
67
|
+
|
|
68
|
+
if profile is None:
|
|
69
|
+
return None, diagnostics
|
|
70
|
+
|
|
71
|
+
spec = profile.get("spec", {})
|
|
72
|
+
secrets, secret_diags = load_profile_secrets(spec.get("secrets"), env_file)
|
|
73
|
+
diagnostics.extend(secret_diags)
|
|
74
|
+
|
|
75
|
+
modules = spec.get("modules", [])
|
|
76
|
+
if not isinstance(modules, list):
|
|
77
|
+
# Defensive guard: validate_profile() already rejects a non-list
|
|
78
|
+
# spec.modules (E010), but build_plan() is a public entry point that
|
|
79
|
+
# may be called directly (e.g. by tests/tools) without prior
|
|
80
|
+
# validation, so it must not crash with an unhandled TypeError from
|
|
81
|
+
# enumerate() on a non-iterable/scalar value.
|
|
82
|
+
diagnostics.append(Diagnostic(
|
|
83
|
+
level="error",
|
|
84
|
+
code="E010",
|
|
85
|
+
message="spec.modules must be a list.",
|
|
86
|
+
path="spec.modules",
|
|
87
|
+
))
|
|
88
|
+
return None, diagnostics
|
|
89
|
+
|
|
90
|
+
profile_dir = profile_file.parent
|
|
91
|
+
|
|
92
|
+
loaded_modules: list[dict[str, Any]] = []
|
|
93
|
+
module_instances_by_id: dict[str, dict[str, Any]] = {}
|
|
94
|
+
|
|
95
|
+
for i, module_instance in enumerate(modules):
|
|
96
|
+
if not isinstance(module_instance, dict):
|
|
97
|
+
# Defensive guard: validate_profile() already rejects non-object
|
|
98
|
+
# module entries (E010), but build_plan() is a public entry point
|
|
99
|
+
# that may be called directly (e.g. by tests/tools) without prior
|
|
100
|
+
# validation, so it must not crash on malformed-but-plausible YAML.
|
|
101
|
+
diagnostics.append(Diagnostic(
|
|
102
|
+
level="error",
|
|
103
|
+
code="E010",
|
|
104
|
+
message="Module entry must be an object.",
|
|
105
|
+
path=f"spec.modules[{i}]",
|
|
106
|
+
))
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if module_instance.get("enabled", True) is False:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
module_id = module_instance.get("id")
|
|
113
|
+
if not module_id:
|
|
114
|
+
diagnostics.append(Diagnostic(
|
|
115
|
+
level="error",
|
|
116
|
+
code="E010",
|
|
117
|
+
message="Module id is required.",
|
|
118
|
+
path=f"spec.modules[{i}].id",
|
|
119
|
+
))
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
source = module_instance.get("source")
|
|
123
|
+
if not source:
|
|
124
|
+
diagnostics.append(Diagnostic(
|
|
125
|
+
level="error",
|
|
126
|
+
code="E010",
|
|
127
|
+
message="Module source is required.",
|
|
128
|
+
path=f"spec.modules[{i}].source",
|
|
129
|
+
))
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
source_path = Path(source)
|
|
133
|
+
if not source_path.is_absolute() and source_path.parts and source_path.parts[0] == ".":
|
|
134
|
+
source_path = source_path.relative_to(".")
|
|
135
|
+
|
|
136
|
+
module_root = os.getenv("CDS_MODULE_PATH")
|
|
137
|
+
module_root_path = Path(module_root) if module_root else None
|
|
138
|
+
module_file, diags = resolve_module_file(
|
|
139
|
+
source=source,
|
|
140
|
+
profile_dir=profile_dir,
|
|
141
|
+
module_root=module_root_path,
|
|
142
|
+
diagnostic_path=f"spec.modules[{i}].source",
|
|
143
|
+
)
|
|
144
|
+
diagnostics.extend(diags)
|
|
145
|
+
if module_file is None:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
module_def, diags = load_yaml_file(module_file)
|
|
149
|
+
diagnostics.extend(diags)
|
|
150
|
+
|
|
151
|
+
if module_def is None:
|
|
152
|
+
continue
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
normalized_config = apply_defaults(
|
|
156
|
+
module_instance.get("config", {}),
|
|
157
|
+
module_def.get("spec", {}).get("configSchema", {})
|
|
158
|
+
)
|
|
159
|
+
except MaxNestingDepthExceeded:
|
|
160
|
+
diagnostics.append(Diagnostic(
|
|
161
|
+
level="error",
|
|
162
|
+
code="E094",
|
|
163
|
+
message=(
|
|
164
|
+
f"Module config or schema nesting exceeds the maximum "
|
|
165
|
+
f"supported depth ({MAX_NESTING_DEPTH})."
|
|
166
|
+
),
|
|
167
|
+
path=f"spec.modules[{i}].config",
|
|
168
|
+
))
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Validate secrets exist but leave "secrets.VAR" strings intact
|
|
172
|
+
resolve_secret_refs(normalized_config, secrets, f"spec.modules[{i}].config", diagnostics)
|
|
173
|
+
|
|
174
|
+
loaded = {
|
|
175
|
+
"index": i,
|
|
176
|
+
"id": module_id,
|
|
177
|
+
"source": source,
|
|
178
|
+
"version": module_instance.get("version"),
|
|
179
|
+
"dependsOn": module_instance.get("dependsOn", []),
|
|
180
|
+
"config": normalized_config,
|
|
181
|
+
"instance": module_instance,
|
|
182
|
+
"module": module_def,
|
|
183
|
+
"module_file": str(module_file),
|
|
184
|
+
}
|
|
185
|
+
loaded_modules.append(loaded)
|
|
186
|
+
module_instances_by_id[loaded["id"]] = loaded
|
|
187
|
+
|
|
188
|
+
resolved_contracts_by_module: dict[str, dict[str, Any]] = {}
|
|
189
|
+
for inst in loaded_modules:
|
|
190
|
+
try:
|
|
191
|
+
resolved_contracts_by_module[inst["id"]] = resolve_provided_contracts(inst, secrets)
|
|
192
|
+
except MaxNestingDepthExceeded:
|
|
193
|
+
diagnostics.append(Diagnostic(
|
|
194
|
+
level="error",
|
|
195
|
+
code="E094",
|
|
196
|
+
message=(
|
|
197
|
+
f"Provided contract nesting for module \"{inst['id']}\" exceeds the "
|
|
198
|
+
f"maximum supported depth ({MAX_NESTING_DEPTH})."
|
|
199
|
+
),
|
|
200
|
+
path=f"module:{inst['id']}.provides",
|
|
201
|
+
))
|
|
202
|
+
resolved_contracts_by_module[inst["id"]] = {}
|
|
203
|
+
|
|
204
|
+
planned_modules: list[dict[str, Any]] = []
|
|
205
|
+
for inst in loaded_modules:
|
|
206
|
+
planned_modules.append(
|
|
207
|
+
{
|
|
208
|
+
"id": inst["id"],
|
|
209
|
+
"source": inst["source"],
|
|
210
|
+
"version": inst["version"],
|
|
211
|
+
"dependsOn": inst["dependsOn"],
|
|
212
|
+
"config": inst["config"],
|
|
213
|
+
"consumes": resolve_consumed_contracts(inst, module_instances_by_id, resolved_contracts_by_module, diagnostics, secrets),
|
|
214
|
+
"provides": resolved_contracts_by_module[inst["id"]],
|
|
215
|
+
"implementation": inst["module"].get("spec", {}).get("implementation", {}),
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
plan = {
|
|
220
|
+
"apiVersion": "cds/v1alpha1",
|
|
221
|
+
"kind": "Plan",
|
|
222
|
+
"metadata": deepcopy(profile.get("metadata", {})),
|
|
223
|
+
"sourceProfile": str(profile_file),
|
|
224
|
+
"environment": environment,
|
|
225
|
+
"provenance": provenance,
|
|
226
|
+
"runtime": spec.get("runtime", {}),
|
|
227
|
+
"secrets": secrets,
|
|
228
|
+
"outputs": resolve_outputs(spec.get("outputs", {}), resolved_contracts_by_module, diagnostics),
|
|
229
|
+
"modules": planned_modules,
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return plan, diagnostics
|
|
233
|
+
|
|
234
|
+
_CDS_VAR_PATTERN = re.compile(r"\$\{(CDS_[A-Z0-9_]+)\}")
|
|
235
|
+
|
|
236
|
+
def _substitute_config_env_vars(
|
|
237
|
+
obj: Any,
|
|
238
|
+
raw_env: dict[str, str],
|
|
239
|
+
path: str,
|
|
240
|
+
diagnostics: list[Diagnostic],
|
|
241
|
+
) -> Any:
|
|
242
|
+
"""Recursively substitute ${CDS_*} patterns in config values with values from .env."""
|
|
243
|
+
if isinstance(obj, dict):
|
|
244
|
+
return {k: _substitute_config_env_vars(v, raw_env, f"{path}.{k}", diagnostics) for k, v in obj.items()}
|
|
245
|
+
if isinstance(obj, list):
|
|
246
|
+
return [_substitute_config_env_vars(v, raw_env, f"{path}[{i}]", diagnostics) for i, v in enumerate(obj)]
|
|
247
|
+
if isinstance(obj, str):
|
|
248
|
+
def _replace(m: re.Match) -> str:
|
|
249
|
+
var = m.group(1)
|
|
250
|
+
if var not in raw_env:
|
|
251
|
+
diagnostics.append(Diagnostic(
|
|
252
|
+
level="error",
|
|
253
|
+
code="E083",
|
|
254
|
+
message=f'Config references env var "${{{var}}}" which is not set in .env or environment.',
|
|
255
|
+
path=path,
|
|
256
|
+
))
|
|
257
|
+
return ""
|
|
258
|
+
return raw_env[var]
|
|
259
|
+
return _CDS_VAR_PATTERN.sub(_replace, obj)
|
|
260
|
+
return obj
|
|
261
|
+
|
|
262
|
+
def apply_defaults(config: dict[str, Any], schema: dict[str, Any]) -> dict[str, Any]:
|
|
263
|
+
config_copy = deepcopy(config)
|
|
264
|
+
return _apply_schema_defaults(config_copy, schema)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _apply_schema_defaults(value: Any, schema: dict[str, Any], _depth: int = 0) -> Any:
|
|
268
|
+
if _depth > MAX_NESTING_DEPTH:
|
|
269
|
+
raise MaxNestingDepthExceeded(
|
|
270
|
+
f"Schema/config nesting exceeds the maximum supported depth ({MAX_NESTING_DEPTH})."
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
schema_type = schema.get("type")
|
|
274
|
+
|
|
275
|
+
if value is None and "default" in schema:
|
|
276
|
+
value = deepcopy(schema["default"])
|
|
277
|
+
|
|
278
|
+
if schema_type == "object":
|
|
279
|
+
if value is None:
|
|
280
|
+
value = {}
|
|
281
|
+
if not isinstance(value, dict):
|
|
282
|
+
return value
|
|
283
|
+
|
|
284
|
+
props = schema.get("properties", {})
|
|
285
|
+
result = deepcopy(value)
|
|
286
|
+
|
|
287
|
+
for prop_name, prop_schema in props.items():
|
|
288
|
+
if prop_name not in result:
|
|
289
|
+
if "default" in prop_schema:
|
|
290
|
+
# Recurse so nested properties of an object-typed default
|
|
291
|
+
# (e.g. healthcheck: {type: object, default: {}, properties:
|
|
292
|
+
# {enabled: {default: true}}}) get their own defaults filled
|
|
293
|
+
# in too, instead of stopping at the raw literal default.
|
|
294
|
+
result[prop_name] = _apply_schema_defaults(
|
|
295
|
+
deepcopy(prop_schema["default"]), prop_schema, _depth + 1
|
|
296
|
+
)
|
|
297
|
+
elif prop_schema.get("type") == "object":
|
|
298
|
+
nested_default = _apply_schema_defaults({}, prop_schema, _depth + 1)
|
|
299
|
+
if nested_default:
|
|
300
|
+
result[prop_name] = nested_default
|
|
301
|
+
else:
|
|
302
|
+
result[prop_name] = _apply_schema_defaults(result[prop_name], prop_schema, _depth + 1)
|
|
303
|
+
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
if schema_type == "array":
|
|
307
|
+
if value is None:
|
|
308
|
+
return []
|
|
309
|
+
if not isinstance(value, list):
|
|
310
|
+
return value
|
|
311
|
+
item_schema = schema.get("items", {})
|
|
312
|
+
return [_apply_schema_defaults(item, item_schema, _depth + 1) for item in value]
|
|
313
|
+
|
|
314
|
+
return value
|
|
315
|
+
|
|
316
|
+
def resolve_provided_contracts(inst: dict[str, Any], secrets: dict[str, str] = {}) -> dict[str, Any]:
|
|
317
|
+
"""
|
|
318
|
+
Resolve contracts provided by a module instance.
|
|
319
|
+
"""
|
|
320
|
+
provides = inst["module"].get("spec", {}).get("provides", [])
|
|
321
|
+
resolved: dict[str, Any] = {}
|
|
322
|
+
service_name = inst["id"]
|
|
323
|
+
|
|
324
|
+
for provided in provides:
|
|
325
|
+
provide_name = provided.get("name")
|
|
326
|
+
contract = deepcopy(provided.get("contract", {}))
|
|
327
|
+
|
|
328
|
+
contract = substitute_values(
|
|
329
|
+
contract,
|
|
330
|
+
context={
|
|
331
|
+
"config": inst["config"],
|
|
332
|
+
"service": {"host": service_name},
|
|
333
|
+
"secrets": secrets,
|
|
334
|
+
"bindings": {},
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
if provide_name:
|
|
339
|
+
resolved[provide_name] = contract
|
|
340
|
+
|
|
341
|
+
return resolved
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def resolve_consumed_contracts(
|
|
345
|
+
inst: dict[str, Any],
|
|
346
|
+
modules_by_id: dict[str, dict[str, Any]],
|
|
347
|
+
resolved_contracts_by_module: dict[str, dict[str, Any]],
|
|
348
|
+
diagnostics: list[Diagnostic],
|
|
349
|
+
secrets: dict[str, str] = {},
|
|
350
|
+
) -> dict[str, Any]:
|
|
351
|
+
consumes = inst["module"].get("spec", {}).get("consumes", [])
|
|
352
|
+
resolved: dict[str, Any] = {}
|
|
353
|
+
|
|
354
|
+
for consume in consumes:
|
|
355
|
+
consume_name = consume.get("name")
|
|
356
|
+
mapped_from = consume.get("mappedFrom")
|
|
357
|
+
|
|
358
|
+
if not consume_name or not mapped_from:
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
required = consume.get("required", True)
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
binding = resolve_path({"spec": {"config": inst["config"]}}, mapped_from)
|
|
365
|
+
except KeyError:
|
|
366
|
+
if not required:
|
|
367
|
+
continue
|
|
368
|
+
diagnostics.append(
|
|
369
|
+
Diagnostic(
|
|
370
|
+
level="error",
|
|
371
|
+
code="E041",
|
|
372
|
+
message=f'Path "{mapped_from}" could not be resolved in planned config.',
|
|
373
|
+
path=f'module:{inst["id"]}.consumes.{consume_name}',
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
if not isinstance(binding, dict) or "contractRef" not in binding:
|
|
379
|
+
if not required and not binding:
|
|
380
|
+
continue
|
|
381
|
+
diagnostics.append(
|
|
382
|
+
Diagnostic(
|
|
383
|
+
level="error",
|
|
384
|
+
code="E041",
|
|
385
|
+
message=f'Binding for "{consume_name}" must contain "contractRef".',
|
|
386
|
+
path=f'module:{inst["id"]}.consumes.{consume_name}',
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
parsed = parse_contract_ref(binding["contractRef"])
|
|
392
|
+
if parsed is None:
|
|
393
|
+
diagnostics.append(
|
|
394
|
+
Diagnostic(
|
|
395
|
+
level="error",
|
|
396
|
+
code="E041",
|
|
397
|
+
message=f'Invalid contract ref "{binding["contractRef"]}".',
|
|
398
|
+
path=f'module:{inst["id"]}.consumes.{consume_name}',
|
|
399
|
+
)
|
|
400
|
+
)
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
producer_id, provide_name = parsed
|
|
404
|
+
producer = modules_by_id.get(producer_id)
|
|
405
|
+
if producer is None:
|
|
406
|
+
diagnostics.append(
|
|
407
|
+
Diagnostic(
|
|
408
|
+
level="error",
|
|
409
|
+
code="E041",
|
|
410
|
+
message=f'Unknown producer module "{producer_id}".',
|
|
411
|
+
path=f'module:{inst["id"]}.consumes.{consume_name}',
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
provider_contracts = resolved_contracts_by_module.get(producer_id, {})
|
|
417
|
+
matched = provider_contracts.get(provide_name)
|
|
418
|
+
if matched is None:
|
|
419
|
+
diagnostics.append(
|
|
420
|
+
Diagnostic(
|
|
421
|
+
level="error",
|
|
422
|
+
code="E041",
|
|
423
|
+
message=f'Module "{producer_id}" does not provide "{provide_name}".',
|
|
424
|
+
path=f'module:{inst["id"]}.consumes.{consume_name}',
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
continue
|
|
428
|
+
|
|
429
|
+
resolved[consume_name] = {
|
|
430
|
+
"contractRef": binding["contractRef"],
|
|
431
|
+
"contract": deepcopy(matched),
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return resolved
|
|
435
|
+
|
|
436
|
+
def resolve_secret_refs(obj: Any, secrets: dict[str, str], current_path: str, diagnostics: list[Diagnostic]) -> Any:
|
|
437
|
+
"""
|
|
438
|
+
Validate that all secrets.* references in obj exist in the secrets dict.
|
|
439
|
+
Emits E081 diagnostics for missing secrets.
|
|
440
|
+
Does NOT resolve the references — values are left as "secrets.VAR_NAME" strings
|
|
441
|
+
so that substitute_string can emit ${VAR_NAME} for Docker Compose runtime resolution.
|
|
442
|
+
"""
|
|
443
|
+
if isinstance(obj, dict):
|
|
444
|
+
return {
|
|
445
|
+
key: resolve_secret_refs(value, secrets, f"{current_path}.{key}", diagnostics)
|
|
446
|
+
for key, value in obj.items()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if isinstance(obj, list):
|
|
450
|
+
return [
|
|
451
|
+
resolve_secret_refs(value, secrets, f"{current_path}[{index}]", diagnostics)
|
|
452
|
+
for index, value in enumerate(obj)
|
|
453
|
+
]
|
|
454
|
+
|
|
455
|
+
if isinstance(obj, str) and is_secret_ref(obj):
|
|
456
|
+
secret_name = secret_name_from_ref(obj)
|
|
457
|
+
if secret_name not in secrets:
|
|
458
|
+
diagnostics.append(
|
|
459
|
+
Diagnostic(
|
|
460
|
+
level="error",
|
|
461
|
+
code="E081",
|
|
462
|
+
message=f'Secret ref "{obj}" could not be resolved.',
|
|
463
|
+
path=current_path,
|
|
464
|
+
)
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
return obj # always return unchanged
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def resolve_outputs(
|
|
471
|
+
outputs: dict[str, Any],
|
|
472
|
+
resolved_contracts_by_module: dict[str, dict[str, Any]],
|
|
473
|
+
diagnostics: list[Diagnostic],
|
|
474
|
+
) -> dict[str, Any]:
|
|
475
|
+
"""
|
|
476
|
+
Resolve output contracts.
|
|
477
|
+
"""
|
|
478
|
+
contracts = outputs.get("contracts", {})
|
|
479
|
+
resolved: dict[str, Any] = {"contracts": {}}
|
|
480
|
+
|
|
481
|
+
for name, value in contracts.items():
|
|
482
|
+
ref = value.get("from")
|
|
483
|
+
|
|
484
|
+
if not isinstance(ref, str):
|
|
485
|
+
continue
|
|
486
|
+
|
|
487
|
+
parsed = parse_contract_ref(ref)
|
|
488
|
+
|
|
489
|
+
if parsed is None:
|
|
490
|
+
diagnostics.append(
|
|
491
|
+
Diagnostic(
|
|
492
|
+
level="error",
|
|
493
|
+
code="E060",
|
|
494
|
+
message=f'Invalid output ref "{ref}".',
|
|
495
|
+
path=f"spec.outputs.contracts.{name}.from",
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
continue
|
|
499
|
+
|
|
500
|
+
module_id, provide_name = parsed
|
|
501
|
+
contract = resolved_contracts_by_module.get(module_id, {}).get(provide_name)
|
|
502
|
+
|
|
503
|
+
if contract is None:
|
|
504
|
+
diagnostics.append(
|
|
505
|
+
Diagnostic(
|
|
506
|
+
level="error",
|
|
507
|
+
code="E060",
|
|
508
|
+
message=f'Output ref "{ref}" could not be resolved.',
|
|
509
|
+
path=f"spec.outputs.contracts.{name}.from",
|
|
510
|
+
)
|
|
511
|
+
)
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
resolved["contracts"][name] = {
|
|
515
|
+
"from": ref,
|
|
516
|
+
"contract": contract,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
return resolved
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def substitute_values(obj: Any, context: dict[str, Any], _depth: int = 0) -> Any:
|
|
523
|
+
"""
|
|
524
|
+
Recursively substitute interpolations in object.
|
|
525
|
+
Supports both pure ${...} and mixed ${...} interpolations.
|
|
526
|
+
"""
|
|
527
|
+
if _depth > MAX_NESTING_DEPTH:
|
|
528
|
+
raise MaxNestingDepthExceeded(
|
|
529
|
+
f"Contract/config nesting exceeds the maximum supported depth ({MAX_NESTING_DEPTH})."
|
|
530
|
+
)
|
|
531
|
+
if isinstance(obj, dict):
|
|
532
|
+
return {k: substitute_values(v, context, _depth + 1) for k, v in obj.items()}
|
|
533
|
+
if isinstance(obj, list):
|
|
534
|
+
return [substitute_values(v, context, _depth + 1) for v in obj]
|
|
535
|
+
if isinstance(obj, str):
|
|
536
|
+
return substitute_string(obj, context)
|
|
537
|
+
return obj
|
|
538
|
+
|
|
539
|
+
def substitute_string(value: str, context: dict[str, Any]) -> Any:
|
|
540
|
+
"""
|
|
541
|
+
Substitute interpolations in a string.
|
|
542
|
+
|
|
543
|
+
Supports:
|
|
544
|
+
- Pure: ${config.name} (entire value replaced, preserves type)
|
|
545
|
+
- Mixed: "prefix-${config.name}-suffix" (string concatenation)
|
|
546
|
+
- Secrets: ${config.passwordFrom} or ${secrets.VAR} (emits ${VAR_NAME} for Docker Compose runtime resolution)
|
|
547
|
+
- Escape: $${config.name} (emit literal ${config.name} without substitution)
|
|
548
|
+
|
|
549
|
+
Examples:
|
|
550
|
+
"${config.name}" -> value of config.name (any type)
|
|
551
|
+
"db://${config.host}:5432" -> "db://localhost:5432"
|
|
552
|
+
"${config.passwordFrom}" -> "${CDS_DB_PASSWORD}" (var name, resolved at runtime by Docker Compose)
|
|
553
|
+
"${secrets.CDS_DB_PASSWORD}" -> "${CDS_DB_PASSWORD}"
|
|
554
|
+
"host=${bindings.db.host}" -> "host=postgres"
|
|
555
|
+
"$${config.name}" -> "${config.name}" (literal, not substituted)
|
|
556
|
+
"""
|
|
557
|
+
if "${" not in value:
|
|
558
|
+
return value
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
ESCAPE_PLACEHOLDER = "\x00ESC\x00"
|
|
562
|
+
value = value.replace("$$", ESCAPE_PLACEHOLDER)
|
|
563
|
+
|
|
564
|
+
def replace_match(match: re.Match) -> str:
|
|
565
|
+
expr = match.group(1)
|
|
566
|
+
resolved = resolve_expr(expr, context)
|
|
567
|
+
if resolved is None:
|
|
568
|
+
return match.group(0)
|
|
569
|
+
# resolve_expr already returns "${VAR_NAME}" strings for secrets —
|
|
570
|
+
# str() is safe here for both secret placeholders and normal string values
|
|
571
|
+
return str(resolved)
|
|
572
|
+
|
|
573
|
+
# Pure interpolation: entire value is a single ${...} — preserves non-string types
|
|
574
|
+
if value.startswith("${") and value.endswith("}") and value.count("${") == 1:
|
|
575
|
+
expr = value[2:-1]
|
|
576
|
+
resolved = resolve_expr(expr, context)
|
|
577
|
+
if resolved is not None:
|
|
578
|
+
if isinstance(resolved, str):
|
|
579
|
+
return resolved.replace(ESCAPE_PLACEHOLDER, "$")
|
|
580
|
+
return resolved # int, bool, list, dict, etc.
|
|
581
|
+
|
|
582
|
+
# Mixed interpolation: one or more ${...} embedded in a larger string
|
|
583
|
+
value = re.sub(r"\$\{([^}]+)\}", replace_match, value)
|
|
584
|
+
return value.replace(ESCAPE_PLACEHOLDER, "$")
|
|
585
|
+
|
|
586
|
+
def resolve_expr(expr: str, context: dict[str, Any]) -> Any:
|
|
587
|
+
"""
|
|
588
|
+
Resolve a dotted expression against context.
|
|
589
|
+
|
|
590
|
+
secrets.* references and config fields holding "secrets.*" strings
|
|
591
|
+
always emit ${CDS_VAR_NAME} for Docker Compose runtime resolution.
|
|
592
|
+
Raw secret values are never returned.
|
|
593
|
+
"""
|
|
594
|
+
# Direct secrets.* reference: secrets.analytics_postgres_password → ${CDS_ANALYTICS_POSTGRES_PASSWORD}
|
|
595
|
+
if expr.startswith("secrets."):
|
|
596
|
+
alias = expr.split(".", 1)[1]
|
|
597
|
+
secrets = context.get("secrets", {})
|
|
598
|
+
# alias maps to CDS_* name; fall back to alias itself if not mapped
|
|
599
|
+
cds_name = secrets.get(alias, alias)
|
|
600
|
+
return f"${{{cds_name}}}"
|
|
601
|
+
|
|
602
|
+
# Walk dotted path
|
|
603
|
+
parts = expr.split(".")
|
|
604
|
+
value = context
|
|
605
|
+
for part in parts:
|
|
606
|
+
if not isinstance(value, dict) or part not in value:
|
|
607
|
+
return None
|
|
608
|
+
value = value[part]
|
|
609
|
+
|
|
610
|
+
# Walked value is itself a secrets.* ref: emit ${CDS_VAR_NAME}
|
|
611
|
+
if isinstance(value, str) and value.startswith("secrets."):
|
|
612
|
+
alias = value.split(".", 1)[1]
|
|
613
|
+
secrets = context.get("secrets", {})
|
|
614
|
+
cds_name = secrets.get(alias, alias)
|
|
615
|
+
return f"${{{cds_name}}}"
|
|
616
|
+
|
|
617
|
+
return value
|
|
618
|
+
|