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