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/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
+