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/validator.py ADDED
@@ -0,0 +1,570 @@
1
+ # cli/validator.py
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from jsonschema import Draft202012Validator
9
+
10
+ from .diagnostics import Diagnostic
11
+ from .graph import validate_dependency_graph
12
+ 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
+
15
+
16
+ def validate_profile(profile_path: str, environment: str | None = None) -> list[Diagnostic]:
17
+ if environment is not None:
18
+ # Local import: cli.overlay imports validate_loaded_profile from this
19
+ # module, so importing it back at module scope would be circular.
20
+ from .overlay import resolve_profile
21
+
22
+ _, _, diagnostics = resolve_profile(profile_path, environment)
23
+ return diagnostics
24
+
25
+ profile_file = Path(profile_path)
26
+ profile, diagnostics = load_yaml_file(profile_file)
27
+ if profile is None:
28
+ return diagnostics
29
+
30
+ return diagnostics + validate_loaded_profile(profile, profile_file)
31
+
32
+
33
+ def validate_loaded_profile(profile: dict[str, Any], profile_file: Path) -> list[Diagnostic]:
34
+ """
35
+ Runs the full validation pipeline (shape, module configs, dependencies,
36
+ secret refs, contract bindings, outputs) against an already-loaded
37
+ profile dict. Split out from validate_profile so callers that produce a
38
+ profile dict some other way, e.g. the environment-overlay resolver's
39
+ merged result, get identical validation without re-implementing this
40
+ orchestration.
41
+ """
42
+ diagnostics: list[Diagnostic] = []
43
+
44
+ diagnostics.extend(validate_profile_shape(profile))
45
+ if has_errors(diagnostics):
46
+ return diagnostics
47
+
48
+ module_instances, diags = load_module_instances(profile_file, profile)
49
+ diagnostics.extend(diags)
50
+ if has_errors(diagnostics):
51
+ return diagnostics
52
+
53
+ diagnostics.extend(validate_module_configs(module_instances))
54
+ diagnostics.extend(validate_dependencies(module_instances))
55
+ diagnostics.extend(validate_secret_refs(profile, module_instances))
56
+ diagnostics.extend(validate_contract_bindings(module_instances))
57
+ diagnostics.extend(validate_outputs(profile, module_instances))
58
+ diagnostics.extend(validate_observability_config(profile, module_instances))
59
+
60
+ return diagnostics
61
+
62
+
63
+ def has_errors(diagnostics: list[Diagnostic]) -> bool:
64
+ return any(d.level == "error" for d in diagnostics)
65
+
66
+
67
+ def validate_profile_shape(profile: dict[str, Any]) -> list[Diagnostic]:
68
+ diagnostics: list[Diagnostic] = []
69
+
70
+ if profile.get("kind") != "Profile":
71
+ diagnostics.append(Diagnostic("error", "E010", 'Expected kind: "Profile".', "kind"))
72
+
73
+ spec = profile.get("spec")
74
+ if not isinstance(spec, dict):
75
+ diagnostics.append(Diagnostic("error", "E010", "Missing or invalid spec object.", "spec"))
76
+ return diagnostics
77
+
78
+ modules = spec.get("modules")
79
+ if not isinstance(modules, list):
80
+ diagnostics.append(Diagnostic("error", "E010", "spec.modules must be a list.", "spec.modules"))
81
+ return diagnostics
82
+
83
+ seen_ids = set()
84
+ for i, module in enumerate(modules):
85
+ if not isinstance(module, dict):
86
+ diagnostics.append(Diagnostic("error", "E010", "Module entry must be an object.", f"spec.modules[{i}]"))
87
+ continue
88
+
89
+ module_id = module.get("id")
90
+ source = module.get("source")
91
+ config = module.get("config")
92
+
93
+ if not isinstance(module_id, str) or not module_id:
94
+ diagnostics.append(Diagnostic("error", "E010", "Module id is required.", f"spec.modules[{i}].id"))
95
+ elif module_id in seen_ids:
96
+ diagnostics.append(Diagnostic("error", "E011", f'Duplicate module id "{module_id}".', f"spec.modules[{i}].id"))
97
+ else:
98
+ seen_ids.add(module_id)
99
+
100
+ if not isinstance(source, str) or not source:
101
+ diagnostics.append(Diagnostic("error", "E010", "Module source is required.", f"spec.modules[{i}].source"))
102
+
103
+ if config is None or not isinstance(config, dict):
104
+ diagnostics.append(Diagnostic("error", "E010", "Module config must be an object.", f"spec.modules[{i}].config"))
105
+
106
+ return diagnostics
107
+
108
+
109
+ def load_module_instances(profile_file: Path, profile: dict[str, Any]) -> tuple[list[dict[str, Any]], list[Diagnostic]]:
110
+ diagnostics: list[Diagnostic] = []
111
+ instances: list[dict[str, Any]] = []
112
+
113
+ profile_dir = profile_file.parent
114
+ modules = profile["spec"]["modules"]
115
+
116
+ for i, module_instance in enumerate(modules):
117
+ if module_instance.get("enabled", True) is False:
118
+ continue
119
+
120
+ source = module_instance["source"]
121
+ module_root = os.getenv("CDS_MODULE_PATH")
122
+ module_root_path = Path(module_root) if module_root else None
123
+ module_file, diags = resolve_module_file(
124
+ source=source,
125
+ profile_dir=profile_dir,
126
+ module_root=module_root_path,
127
+ diagnostic_path=f"spec.modules[{i}].source",
128
+ )
129
+ diagnostics.extend(diags)
130
+ if module_file is None:
131
+ continue
132
+
133
+ module_def, diags = load_yaml_file(module_file)
134
+ diagnostics.extend(diags)
135
+ if module_def is None:
136
+ continue
137
+
138
+ if module_def.get("kind") != "Module":
139
+ diagnostics.append(
140
+ Diagnostic(
141
+ level="error",
142
+ code="E021",
143
+ message='Expected kind: "Module".',
144
+ path=f"spec.modules[{i}].source",
145
+ )
146
+ )
147
+ continue
148
+
149
+ instances.append(
150
+ {
151
+ "index": i,
152
+ "id": module_instance["id"],
153
+ "source": source,
154
+ "config": module_instance["config"],
155
+ "dependsOn": module_instance.get("dependsOn", []),
156
+ "instance": module_instance,
157
+ "module": module_def,
158
+ "module_file": str(module_file),
159
+ }
160
+ )
161
+
162
+ return instances, diagnostics
163
+
164
+
165
+ def validate_module_configs(module_instances: list[dict[str, Any]]) -> list[Diagnostic]:
166
+ diagnostics: list[Diagnostic] = []
167
+
168
+ for inst in module_instances:
169
+ schema = inst["module"].get("spec", {}).get("configSchema")
170
+ if not isinstance(schema, dict):
171
+ diagnostics.append(
172
+ Diagnostic("error", "E021", "Module is missing spec.configSchema.", f"module:{inst['id']}.spec.configSchema")
173
+ )
174
+ continue
175
+
176
+ validator = Draft202012Validator(schema)
177
+ errors = sorted(validator.iter_errors(inst["config"]), key=lambda e: list(e.path))
178
+
179
+ for err in errors:
180
+ subpath = ".".join(str(p) for p in err.path)
181
+ full_path = f"spec.modules[{inst['index']}].config"
182
+ if subpath:
183
+ full_path += f".{subpath}"
184
+
185
+ diagnostics.append(
186
+ Diagnostic(
187
+ level="error",
188
+ code="E030",
189
+ message=err.message,
190
+ path=full_path,
191
+ )
192
+ )
193
+
194
+ return diagnostics
195
+
196
+
197
+ def validate_dependencies(module_instances: list[dict[str, Any]]) -> list[Diagnostic]:
198
+ module_ids = {m["id"] for m in module_instances}
199
+ depends_on_map = {m["id"]: m.get("dependsOn", []) for m in module_instances}
200
+ return validate_dependency_graph(module_ids, depends_on_map)
201
+
202
+
203
+ def validate_secret_refs(profile: dict[str, Any], module_instances: list[dict[str, Any]]) -> list[Diagnostic]:
204
+ diagnostics: list[Diagnostic] = []
205
+
206
+ secrets_values = (
207
+ profile.get("spec", {})
208
+ .get("secrets", {})
209
+ .get("values", {})
210
+ )
211
+
212
+ for inst in module_instances:
213
+ walk_for_secret_refs(
214
+ obj=inst["config"],
215
+ current_path=f"spec.modules[{inst['index']}].config",
216
+ known_secrets=set(secrets_values.keys()),
217
+ diagnostics=diagnostics,
218
+ )
219
+
220
+ return diagnostics
221
+
222
+
223
+ def walk_for_secret_refs(obj: Any, current_path: str, known_secrets: set[str], diagnostics: list[Diagnostic]) -> None:
224
+ if isinstance(obj, dict):
225
+ for k, v in obj.items():
226
+ walk_for_secret_refs(v, f"{current_path}.{k}", known_secrets, diagnostics)
227
+ elif isinstance(obj, list):
228
+ for i, v in enumerate(obj):
229
+ walk_for_secret_refs(v, f"{current_path}[{i}]", known_secrets, diagnostics)
230
+ else:
231
+ if is_secret_ref(obj):
232
+ secret_name = secret_name_from_ref(obj)
233
+ if secret_name not in known_secrets:
234
+ diagnostics.append(
235
+ Diagnostic(
236
+ level="error",
237
+ code="E050",
238
+ message=f'Secret ref "{obj}" is not defined in spec.secrets.values.',
239
+ path=current_path,
240
+ )
241
+ )
242
+
243
+
244
+ def validate_contract_bindings(module_instances: list[dict[str, Any]]) -> list[Diagnostic]:
245
+ diagnostics: list[Diagnostic] = []
246
+
247
+ by_id = {m["id"]: m for m in module_instances}
248
+
249
+ for inst in module_instances:
250
+ consumes = inst["module"].get("spec", {}).get("consumes", [])
251
+ for consume in consumes:
252
+ name = consume.get("name")
253
+ expected_kind = consume.get("contract", {}).get("kind")
254
+ mapped_from = consume.get("mappedFrom")
255
+
256
+ if not name or not expected_kind or not mapped_from:
257
+ diagnostics.append(
258
+ Diagnostic(
259
+ level="error",
260
+ code="E021",
261
+ message=f'Consume entry in module "{inst["id"]}" is missing name, contract.kind, or mappedFrom.',
262
+ path=f'module:{inst["id"]}.spec.consumes',
263
+ )
264
+ )
265
+ continue
266
+
267
+ required = consume.get("required", True)
268
+
269
+ try:
270
+ value = resolve_path({"spec": {"config": inst["config"]}}, mapped_from)
271
+ except KeyError:
272
+ if not required:
273
+ continue
274
+ diagnostics.append(
275
+ Diagnostic(
276
+ level="error",
277
+ code="E041",
278
+ message=f'Path "{mapped_from}" could not be resolved in module instance config.',
279
+ path=f"spec.modules[{inst['index']}].config",
280
+ )
281
+ )
282
+ continue
283
+
284
+ if not isinstance(value, dict) or "contractRef" not in value:
285
+ if not required and not value:
286
+ continue
287
+ diagnostics.append(
288
+ Diagnostic(
289
+ level="error",
290
+ code="E041",
291
+ message=f'Consume binding "{name}" must resolve to an object with "contractRef".',
292
+ path=f"spec.modules[{inst['index']}].config",
293
+ )
294
+ )
295
+ continue
296
+
297
+ contract_ref = value["contractRef"]
298
+ parsed = parse_contract_ref(contract_ref)
299
+ if parsed is None:
300
+ diagnostics.append(
301
+ Diagnostic(
302
+ level="error",
303
+ code="E041",
304
+ message=f'Invalid contract ref "{contract_ref}". Expected "<module-id>.<contract-name>".',
305
+ path=f"spec.modules[{inst['index']}].config",
306
+ )
307
+ )
308
+ continue
309
+
310
+ producer_id, provide_name = parsed
311
+ producer = by_id.get(producer_id)
312
+ if producer is None:
313
+ diagnostics.append(
314
+ Diagnostic(
315
+ level="error",
316
+ code="E041",
317
+ message=f'Contract ref "{contract_ref}" points to unknown module "{producer_id}".',
318
+ path=f"spec.modules[{inst['index']}].config",
319
+ )
320
+ )
321
+ continue
322
+
323
+ provides = producer["module"].get("spec", {}).get("provides", [])
324
+ matched = next((p for p in provides if p.get("name") == provide_name), None)
325
+ if matched is None:
326
+ diagnostics.append(
327
+ Diagnostic(
328
+ level="error",
329
+ code="E041",
330
+ message=f'Contract ref "{contract_ref}" points to module "{producer_id}", but it does not provide "{provide_name}".',
331
+ path=f"spec.modules[{inst['index']}].config",
332
+ )
333
+ )
334
+ continue
335
+
336
+ actual_kind = matched.get("contract", {}).get("kind")
337
+ if actual_kind != expected_kind:
338
+ diagnostics.append(
339
+ Diagnostic(
340
+ level="error",
341
+ code="E042",
342
+ message=(
343
+ f'Contract kind mismatch for "{contract_ref}": '
344
+ f'consumer expects "{expected_kind}", provider exposes "{actual_kind}".'
345
+ ),
346
+ path=f"spec.modules[{inst['index']}].config",
347
+ )
348
+ )
349
+
350
+ return diagnostics
351
+
352
+
353
+ def validate_outputs(profile: dict[str, Any], module_instances: list[dict[str, Any]]) -> list[Diagnostic]:
354
+ diagnostics: list[Diagnostic] = []
355
+
356
+ by_id = {m["id"]: m for m in module_instances}
357
+ outputs = profile.get("spec", {}).get("outputs", {}).get("contracts", {})
358
+
359
+ if not isinstance(outputs, dict):
360
+ return diagnostics
361
+
362
+ for output_name, output_value in outputs.items():
363
+ if not isinstance(output_value, dict) or "from" not in output_value:
364
+ diagnostics.append(
365
+ Diagnostic(
366
+ level="error",
367
+ code="E060",
368
+ message=f'Output "{output_name}" must be an object with a "from" field.',
369
+ path=f"spec.outputs.contracts.{output_name}",
370
+ )
371
+ )
372
+ continue
373
+
374
+ ref = output_value["from"]
375
+ parsed = parse_contract_ref(ref)
376
+ if parsed is None:
377
+ diagnostics.append(
378
+ Diagnostic(
379
+ level="error",
380
+ code="E060",
381
+ message=f'Invalid output ref "{ref}". Expected "<module-id>.<contract-name>".',
382
+ path=f"spec.outputs.contracts.{output_name}.from",
383
+ )
384
+ )
385
+ continue
386
+
387
+ module_id, provide_name = parsed
388
+ producer = by_id.get(module_id)
389
+ if producer is None:
390
+ diagnostics.append(
391
+ Diagnostic(
392
+ level="error",
393
+ code="E060",
394
+ message=f'Output ref "{ref}" points to unknown module "{module_id}".',
395
+ path=f"spec.outputs.contracts.{output_name}.from",
396
+ )
397
+ )
398
+ continue
399
+
400
+ provides = producer["module"].get("spec", {}).get("provides", [])
401
+ matched = next((p for p in provides if p.get("name") == provide_name), None)
402
+ if matched is None:
403
+ diagnostics.append(
404
+ Diagnostic(
405
+ level="error",
406
+ code="E060",
407
+ message=f'Output ref "{ref}" points to module "{module_id}", but it does not provide "{provide_name}".',
408
+ path=f"spec.outputs.contracts.{output_name}.from",
409
+ )
410
+ )
411
+
412
+ return diagnostics
413
+
414
+
415
+ def validate_observability_config(profile: dict[str, Any], module_instances: list[dict[str, Any]]) -> list[Diagnostic]:
416
+ """
417
+ Validates the optional spec.observability block (see #174 / docs/observability.md).
418
+
419
+ This block is intentionally module-agnostic: a profile can opt into log
420
+ shipping and declare retention tiers without naming which module collects
421
+ logs. `sink.contractRef` is only required when the profile wants to pin
422
+ a specific provider of the shared `log-sink` contract.
423
+ """
424
+ diagnostics: list[Diagnostic] = []
425
+
426
+ observability = profile.get("spec", {}).get("observability")
427
+ if observability is None:
428
+ return diagnostics
429
+
430
+ if not isinstance(observability, dict):
431
+ return [Diagnostic("error", "E100", "spec.observability must be an object.", "spec.observability")]
432
+
433
+ log_shipping = observability.get("logShipping")
434
+ if log_shipping is None:
435
+ return diagnostics
436
+
437
+ if not isinstance(log_shipping, dict):
438
+ return [
439
+ Diagnostic(
440
+ "error", "E100", "spec.observability.logShipping must be an object.", "spec.observability.logShipping"
441
+ )
442
+ ]
443
+
444
+ if "enabled" not in log_shipping:
445
+ diagnostics.append(
446
+ Diagnostic(
447
+ "error",
448
+ "E100",
449
+ "spec.observability.logShipping.enabled is required.",
450
+ "spec.observability.logShipping.enabled",
451
+ )
452
+ )
453
+ elif not isinstance(log_shipping["enabled"], bool):
454
+ diagnostics.append(
455
+ Diagnostic(
456
+ "error",
457
+ "E100",
458
+ "spec.observability.logShipping.enabled must be a boolean.",
459
+ "spec.observability.logShipping.enabled",
460
+ )
461
+ )
462
+
463
+ retention = log_shipping.get("retention")
464
+ if retention is not None:
465
+ if not isinstance(retention, dict):
466
+ diagnostics.append(
467
+ Diagnostic(
468
+ "error",
469
+ "E100",
470
+ "spec.observability.logShipping.retention must be an object.",
471
+ "spec.observability.logShipping.retention",
472
+ )
473
+ )
474
+ else:
475
+ raw_days = retention.get("rawDays")
476
+ structured_days = retention.get("structuredDays")
477
+
478
+ def _is_positive_int(value: Any) -> bool:
479
+ return isinstance(value, int) and not isinstance(value, bool) and value > 0
480
+
481
+ for field_name, value in (("rawDays", raw_days), ("structuredDays", structured_days)):
482
+ if value is not None and not _is_positive_int(value):
483
+ diagnostics.append(
484
+ Diagnostic(
485
+ "error",
486
+ "E101",
487
+ f"spec.observability.logShipping.retention.{field_name} must be a positive integer.",
488
+ f"spec.observability.logShipping.retention.{field_name}",
489
+ )
490
+ )
491
+
492
+ if _is_positive_int(raw_days) and _is_positive_int(structured_days) and structured_days < raw_days:
493
+ diagnostics.append(
494
+ Diagnostic(
495
+ "error",
496
+ "E101",
497
+ "spec.observability.logShipping.retention.structuredDays must be >= rawDays "
498
+ "(structured events are the long-retention tier; raw logs are short-retention).",
499
+ "spec.observability.logShipping.retention.structuredDays",
500
+ )
501
+ )
502
+
503
+ sink = log_shipping.get("sink")
504
+ if sink is None:
505
+ return diagnostics
506
+
507
+ if not isinstance(sink, dict) or not isinstance(sink.get("contractRef"), str):
508
+ diagnostics.append(
509
+ Diagnostic(
510
+ "error",
511
+ "E100",
512
+ "spec.observability.logShipping.sink must be an object with a string contractRef.",
513
+ "spec.observability.logShipping.sink",
514
+ )
515
+ )
516
+ return diagnostics
517
+
518
+ contract_ref = sink["contractRef"]
519
+ parsed = parse_contract_ref(contract_ref)
520
+ if parsed is None:
521
+ diagnostics.append(
522
+ Diagnostic(
523
+ "error",
524
+ "E102",
525
+ f'Invalid sink contractRef "{contract_ref}". Expected "<module-id>.<contract-name>".',
526
+ "spec.observability.logShipping.sink.contractRef",
527
+ )
528
+ )
529
+ return diagnostics
530
+
531
+ module_id, provide_name = parsed
532
+ by_id = {m["id"]: m for m in module_instances}
533
+ producer = by_id.get(module_id)
534
+ if producer is None:
535
+ diagnostics.append(
536
+ Diagnostic(
537
+ "error",
538
+ "E102",
539
+ f'Sink contractRef "{contract_ref}" points to unknown module "{module_id}".',
540
+ "spec.observability.logShipping.sink.contractRef",
541
+ )
542
+ )
543
+ return diagnostics
544
+
545
+ provides = producer["module"].get("spec", {}).get("provides", [])
546
+ matched = next((p for p in provides if p.get("name") == provide_name), None)
547
+ if matched is None:
548
+ diagnostics.append(
549
+ Diagnostic(
550
+ "error",
551
+ "E102",
552
+ f'Sink contractRef "{contract_ref}" points to module "{module_id}", '
553
+ f'but it does not provide "{provide_name}".',
554
+ "spec.observability.logShipping.sink.contractRef",
555
+ )
556
+ )
557
+ return diagnostics
558
+
559
+ actual_kind = matched.get("contract", {}).get("kind")
560
+ if actual_kind != "log-sink":
561
+ diagnostics.append(
562
+ Diagnostic(
563
+ "error",
564
+ "E102",
565
+ f'Sink contractRef "{contract_ref}" resolves to contract kind "{actual_kind}", expected "log-sink".',
566
+ "spec.observability.logShipping.sink.contractRef",
567
+ )
568
+ )
569
+
570
+ return diagnostics