Coverage for .github / scripts / validate_k8s_manifests.py: 100.00%

240 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-09-14 22:07 +0000

1"""Validate Kubernetes manifests with kubeconform (schema, not just YAML syntax). 

2 

3Confirms that every hand-authored manifest this repo ships — the 

4kubectl-applier's own manifests and the `examples/` gallery — is not just 

5parseable YAML but a *schema-valid* Kubernetes (or supported CRD) resource. 

6`kubeconform` (https://github.com/yannh/kubeconform) does the actual schema 

7check; this script exists to bridge two gaps kubeconform can't close on its 

8own: 

9 

10 Template placeholders: `lambda/kubectl-applier-simple/manifests/*.yaml` 

11 contains `{{PLACEHOLDER}}` tokens the kubectl-applier Lambda substitutes 

12 at deploy time (see `handler.py` / `regional_stack.py`). Raw, these 

13 aren't valid YAML in several spots (bare scalars, a block-list 

14 placeholder), so kubeconform can't even parse the file. This script 

15 renders every placeholder to a schema-shaped stub first — enough to make 

16 the YAML parse and the field types check out, not a claim about the real 

17 runtime value. 

18 

19 Non-Kubernetes files under `examples/`: that directory also ships a DAG 

20 orchestration file (`pipeline-dag.yaml`, a GCO-specific format, not a K8s 

21 manifest) and JSON metric/state fixtures used by other examples. Those 

22 are excluded by construction (only `*.yaml`/`*.yml` under `examples/` are 

23 scanned, and `pipeline-dag.yaml` is skipped by name). 

24 

25Schema resolution: kubeconform's bundled catalog only covers upstream 

26Kubernetes. CRDs this repo depends on (Karpenter `NodePool`, the AWS Load 

27Balancer Controller Gateway API configuration CRDs, Kueue, KEDA) are resolved 

28via the community datreeio/CRDs-catalog as a second `-schema-location`. Two CRDs used in 

29`examples/` aren't in that catalog yet (KubeRay's `RayCluster`, Volcano's 

30`Job`) — those are validated for YAML-shape only (via the structural check) 

31and explicitly `-skip`ped in the schema pass so kubeconform doesn't report a 

32false "no schema found" error for them. 

33 

34Usage:: 

35 

36 # Validate both directories with the real kubeconform binary (the CI gate): 

37 python3 .github/scripts/validate_k8s_manifests.py 

38 

39 # Validate a specific directory, file, or quoted glob. --path is repeatable: 

40 python3 .github/scripts/validate_k8s_manifests.py --path examples/simple-job.yaml 

41 python3 .github/scripts/validate_k8s_manifests.py --path 'examples/**/*.yaml' 

42 

43 # Point at a different kubeconform binary: 

44 python3 .github/scripts/validate_k8s_manifests.py --kubeconform-binary /usr/local/bin/kubeconform 

45 

46Exit codes:: 

47 

48 0 every scanned manifest is schema-valid (or intentionally skipped) 

49 1 one or more manifests failed validation 

50 2 unexpected I/O / argument error (kubeconform missing, directory absent) 

51 

52The module is importable from the test suite — call ``render_placeholders()``, 

53``collect_target_files()``, or ``iter_target_files()`` directly to exercise the 

54logic without invoking the binary. 

55""" 

56 

57from __future__ import annotations 

58 

59import argparse 

60import glob 

61import json 

62import re 

63import shutil 

64import subprocess # nosec B404 - used only to invoke the pinned `kubeconform` binary with fixed argv 

65import sys 

66import tempfile 

67from pathlib import Path 

68from typing import Any 

69 

70_REPO_ROOT = Path(__file__).resolve().parents[2] 

71 

72# Directories this script scans by default. Each entry is a directory 

73# relative to the repo root; every ``*.yaml``/``*.yml`` file directly inside 

74# it is a candidate (no recursion needed — neither directory nests further). 

75DEFAULT_TARGET_DIRS = ( 

76 "lambda/kubectl-applier-simple/manifests", 

77 "examples", 

78) 

79 

80# Files that are YAML but not Kubernetes manifests, so they're excluded by 

81# name rather than left for kubeconform to fail on a missing 'kind'/'apiVersion'. 

82# - pipeline-dag.yaml: a GCO DAG-orchestration definition (see `gco dag`, 

83# tests/test_mission_validation.py-style docs). Its own structure is 

84# checked separately in CI (integration:k8s:manifest-schema's "Validate 

85# DAG pipeline definitions" step) — validating it against the K8s schema 

86# would always fail because it deliberately has no 'kind'. 

87NON_MANIFEST_FILENAMES = frozenset({"pipeline-dag.yaml"}) 

88 

89# GVK-qualified (not bare-Kind) skips for CRDs used in examples/ that the 

90# datreeio/CRDs-catalog fallback doesn't (yet) carry a schema for. GVK 

91# qualification matters here specifically because Volcano's Job kind 

92# collides with the built-in batch/v1 Job — a bare `-skip Job` would also 

93# skip every ordinary Job manifest in the repo. 

94SCHEMA_UNAVAILABLE_SKIPS = ( 

95 "ray.io/v1/RayCluster", # KubeRay — not in datreeio/CRDs-catalog 

96 "batch.volcano.sh/v1alpha1/Job", # Volcano — not in datreeio/CRDs-catalog 

97 # Kubeflow Trainer v2 — not in datreeio/CRDs-catalog. The shipped 

98 # torch-distributed runtime is validated more strongly than a catalog 

99 # lookup could: validate_helm_charts.py's trainer runtime lockstep 

100 # re-renders the pinned kubeflow-trainer chart online and requires the 

101 # manifest to reproduce it spec-for-spec; the TrainJob example's shape 

102 # is exercised by the submission pipeline's decomposition tests and the 

103 # live example run. 

104 "trainer.kubeflow.org/v1alpha1/TrainJob", 

105 "trainer.kubeflow.org/v1alpha1/ClusterTrainingRuntime", 

106 # AWS LBC gateway CRDs at their v1 storage version (v3.5.0+) — the 

107 # datreeio catalog only carries the deprecated v1beta1 schemas. These two 

108 # resources are instead schema-validated against the exact pinned CRD 

109 # bundle by validate_helm_charts.py's gateway-lockstep check, which is 

110 # stronger than the catalog lookup this skip bypasses. 

111 "gateway.k8s.aws/v1/LoadBalancerConfiguration", 

112 "gateway.k8s.aws/v1/TargetGroupConfiguration", 

113) 

114 

115# kubeconform's own default schema catalog (upstream Kubernetes resources). 

116DEFAULT_SCHEMA_LOCATION = "default" 

117 

118# Community-maintained CRD catalog covering Karpenter, EKS Auto Mode, Kueue, 

119# KEDA, and hundreds of other CRDs — see https://github.com/datreeio/CRDs-catalog. 

120# kubeconform tries -schema-location entries in order and stops at the first 

121# match, so this is consulted only when a Kind isn't a built-in K8s resource. 

122CRD_CATALOG_SCHEMA_LOCATION = ( 

123 "https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/" 

124 "{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json" 

125) 

126 

127_PLACEHOLDER_RE = re.compile(r"\{\{[A-Za-z_]+\}\}") 

128 

129# A handful of placeholders sit in *structural* or *typed* positions where a 

130# generic string stub would either break YAML parsing or fail the schema's 

131# type check. Each needs a stub shaped like the real substitution. 

132# 

133# {{VPC_ENDPOINT_CIDR_BLOCKS}} expands to one or more `- ipBlock: {cidr: ...}` 

134# list entries (see regional_stack.py::_compute... / the NetworkPolicy 

135# `to:` block in 03-network-policies.yaml) — the placeholder sits at the 

136# start of a YAML sequence, so it needs a real sequence item, not a string. 

137_STRUCTURAL_STUBS: dict[str, str] = { 

138 "{{VPC_ENDPOINT_CIDR_BLOCKS}}": '- ipBlock:\n cidr: "10.0.0.0/16"', 

139} 

140 

141# Placeholders that sit in a bare (unquoted) numeric scalar position — e.g. 

142# `pollingInterval: {{QP_POLLING_INTERVAL}}` in post-helm-sqs-consumer.yaml. 

143# These must render to a bare integer, not a quoted string, or the field 

144# fails the schema's `type: integer` check. 

145_INTEGER_PLACEHOLDER_TOKENS: frozenset[str] = frozenset( 

146 { 

147 "{{INFERENCE_PROXY_TLS_CPU_TARGET_UTILIZATION}}", 

148 "{{INFERENCE_PROXY_MIN_REPLICAS}}", 

149 "{{INFERENCE_PROXY_MAX_REPLICAS}}", 

150 "{{MP_REPLICAS}}", 

151 "{{MP_HPA_MAX_REPLICAS}}", 

152 "{{MP_HPA_CPU_TARGET_UTILIZATION}}", 

153 "{{QP_POLLING_INTERVAL}}", 

154 "{{QP_SUCCESSFUL_JOBS_HISTORY}}", 

155 "{{QP_FAILED_JOBS_HISTORY}}", 

156 "{{QP_MAX_CONCURRENT_JOBS}}", 

157 } 

158) 

159 

160# Placeholders that sit in Kubernetes *quantity* positions (e.g. the Kueue 

161# ClusterQueue `nominalQuota` fields in post-helm-kueue-default-queues.yaml, 

162# which reuse the namespace-quota tokens). CRD catalog schemas enforce the 

163# quantity regex, which the generic stub fails; a literal 1 is valid whether 

164# the position is quoted or bare. 

165_QUANTITY_PLACEHOLDER_TOKENS: frozenset[str] = frozenset( 

166 { 

167 "{{INFERENCE_PROXY_TLS_CPU_REQUEST}}", 

168 "{{MP_CPU_LIMIT}}", 

169 "{{MP_MEMORY_LIMIT}}", 

170 "{{QUOTA_MAX_CPU}}", 

171 "{{QUOTA_MAX_MEMORY}}", 

172 "{{QUOTA_MAX_GPU}}", 

173 } 

174) 

175 

176# Every other placeholder (quoted string values, and the couple of bare 

177# `image: {{...}}` lines) renders fine as a generic string stub — YAML 

178# treats an unquoted bare word as a string scalar automatically. 

179_GENERIC_STUB = "placeholder-value" 

180 

181 

182def render_placeholders(text: str) -> str: 

183 """Replace every ``{{PLACEHOLDER}}`` token with a schema-shaped stub. 

184 

185 This is a validation-time rendering only — it exists to make templated 

186 manifests parseable and schema-checkable, not to model what the 

187 kubectl-applier Lambda actually substitutes at deploy time. See the 

188 module docstring for why each stub category exists. 

189 """ 

190 for token, stub in _STRUCTURAL_STUBS.items(): 

191 text = text.replace(token, stub) 

192 for token in _INTEGER_PLACEHOLDER_TOKENS | _QUANTITY_PLACEHOLDER_TOKENS: 

193 text = text.replace(token, "1") 

194 return _PLACEHOLDER_RE.sub(_GENERIC_STUB, text) 

195 

196 

197def _manifest_files_in_directory(directory: Path) -> list[Path]: 

198 """Return supported direct-child manifests from one directory.""" 

199 files: list[Path] = [] 

200 for pattern in ("*.yaml", "*.yml"): 

201 for path in sorted(directory.glob(pattern)): 

202 if path.name not in NON_MANIFEST_FILENAMES: 

203 files.append(path.resolve()) 

204 return files 

205 

206 

207def collect_target_files(targets: tuple[str, ...]) -> tuple[list[Path], list[str]]: 

208 """Resolve every explicit directory, file, or glob independently. 

209 

210 Valid files are returned even when another input is invalid so callers can 

211 still validate and report them. Errors preserve one entry per bad explicit 

212 input. Directories retain the historical direct-child-only behavior; quoted 

213 globs can opt into recursion with ``**``. 

214 """ 

215 files: list[Path] = [] 

216 errors: list[str] = [] 

217 seen: set[Path] = set() 

218 

219 def add(path: Path) -> None: 

220 resolved = path.resolve() 

221 if resolved not in seen: 

222 seen.add(resolved) 

223 files.append(resolved) 

224 

225 for raw_target in targets: 

226 expanded = Path(raw_target).expanduser() 

227 resolved_input = expanded if expanded.is_absolute() else _REPO_ROOT / expanded 

228 

229 if glob.has_magic(str(expanded)): 

230 matches = [ 

231 Path(match) for match in sorted(glob.glob(str(resolved_input), recursive=True)) 

232 ] 

233 matched_manifests: list[Path] = [] 

234 for match in matches: 

235 if match.is_dir(): 

236 matched_manifests.extend(_manifest_files_in_directory(match)) 

237 elif ( 

238 match.is_file() 

239 and match.suffix in (".yaml", ".yml") 

240 and match.name not in NON_MANIFEST_FILENAMES 

241 ): 

242 matched_manifests.append(match.resolve()) 

243 if not matched_manifests: 

244 errors.append(f"{raw_target}: glob matched no Kubernetes YAML manifests") 

245 continue 

246 for path in matched_manifests: 

247 add(path) 

248 continue 

249 

250 if not resolved_input.exists(): 

251 errors.append(f"{raw_target}: path does not exist") 

252 continue 

253 if resolved_input.is_dir(): 

254 directory_files = _manifest_files_in_directory(resolved_input) 

255 if not directory_files: 

256 errors.append(f"{raw_target}: directory contains no Kubernetes YAML manifests") 

257 continue 

258 for path in directory_files: 

259 add(path) 

260 continue 

261 if resolved_input.is_file(): 

262 if resolved_input.suffix not in (".yaml", ".yml"): 

263 errors.append(f"{raw_target}: explicit file is not .yaml or .yml") 

264 elif resolved_input.name in NON_MANIFEST_FILENAMES: 

265 errors.append(f"{raw_target}: explicit file is not a Kubernetes manifest") 

266 else: 

267 add(resolved_input) 

268 continue 

269 errors.append(f"{raw_target}: unsupported input type") 

270 

271 return files, errors 

272 

273 

274def iter_target_files(target_dirs: tuple[str, ...] = DEFAULT_TARGET_DIRS) -> list[Path]: 

275 """Compatibility wrapper returning valid manifest files only. 

276 

277 Use :func:`collect_target_files` when input errors must be surfaced. 

278 """ 

279 files, _errors = collect_target_files(target_dirs) 

280 return files 

281 

282 

283def _rendered_relative_path(path: Path) -> Path: 

284 """Return a collision-safe relative location for a rendered source file.""" 

285 resolved = path.resolve() 

286 try: 

287 return resolved.relative_to(_REPO_ROOT.resolve()) 

288 except ValueError: 

289 # Explicit absolute paths outside the repository are still supported. 

290 # Preserve their path below a marker rather than flattening basenames. 

291 return Path("_external", *resolved.parts[1:]) 

292 

293 

294def render_tree(files: list[Path], dest: Path) -> list[Path]: 

295 """Render files beneath ``dest`` while preserving source-relative paths. 

296 

297 Repository files retain their repository-relative path, preventing files 

298 with the same basename in different inputs from overwriting one another. 

299 Returns the rendered paths for callers/tests that need the mapping. 

300 """ 

301 rendered_paths: list[Path] = [] 

302 dest.mkdir(parents=True, exist_ok=True) 

303 for path in files: 

304 text = path.read_text(encoding="utf-8") 

305 rendered = render_placeholders(text) if "{{" in text else text 

306 rendered_path = dest / _rendered_relative_path(path) 

307 rendered_path.parent.mkdir(parents=True, exist_ok=True) 

308 rendered_path.write_text(rendered, encoding="utf-8") 

309 rendered_paths.append(rendered_path) 

310 return rendered_paths 

311 

312 

313def run_kubeconform( 

314 directory: Path, 

315 *, 

316 kubeconform_binary: str = "kubeconform", 

317 strict: bool = True, 

318 extra_schema_locations: tuple[str, ...] = (CRD_CATALOG_SCHEMA_LOCATION,), 

319 skip_gvks: tuple[str, ...] = SCHEMA_UNAVAILABLE_SKIPS, 

320) -> tuple[int, object]: 

321 """Run kubeconform against every manifest in ``directory``, JSON output. 

322 

323 Returns ``(returncode, parsed_json)``. ``parsed_json`` is ``{}`` if 

324 kubeconform produced no parseable JSON (e.g. the binary is missing — 

325 callers should check that separately via ``shutil.which`` before calling). 

326 """ 

327 cmd = [ 

328 kubeconform_binary, 

329 "-output", 

330 "json", 

331 "-summary", 

332 "-verbose", 

333 "-schema-location", 

334 DEFAULT_SCHEMA_LOCATION, 

335 ] 

336 for location in extra_schema_locations: 

337 cmd.extend(["-schema-location", location]) 

338 if skip_gvks: 

339 cmd.extend(["-skip", ",".join(skip_gvks)]) 

340 if strict: 

341 cmd.append("-strict") 

342 cmd.append(str(directory)) 

343 

344 proc = subprocess.run( # nosemgrep: dangerous-subprocess-use-audit - fixed argv, no shell=True 

345 cmd, 

346 capture_output=True, 

347 text=True, 

348 check=False, 

349 timeout=180, 

350 ) 

351 try: 

352 parsed = json.loads(proc.stdout) if proc.stdout.strip() else {} 

353 except json.JSONDecodeError: 

354 parsed = {} 

355 return proc.returncode, parsed 

356 

357 

358_KUBECONFORM_STATUS_TO_SUMMARY = { 

359 "statusValid": "valid", 

360 "statusInvalid": "invalid", 

361 "statusError": "errors", 

362 "statusSkipped": "skipped", 

363} 

364_KUBECONFORM_RESOURCE_FIELDS = frozenset({"filename", "kind", "name", "version", "status", "msg"}) 

365_KUBECONFORM_BLANK_RESOURCE_FIELDS = ("kind", "name", "version", "status", "msg") 

366 

367 

368def _is_kubeconform_separator_record(resource: object) -> bool: 

369 """Recognize kubeconform v0.8.0's record for a leading YAML separator. 

370 

371 The pinned binary emits one record with a filename but empty semantic 

372 fields when a file starts with ``---``. Match that exact shape so a 

373 partially populated record or a future unknown field still fails closed. 

374 """ 

375 return ( 

376 isinstance(resource, dict) 

377 and set(resource) == _KUBECONFORM_RESOURCE_FIELDS 

378 and isinstance(resource["filename"], str) 

379 and bool(resource["filename"]) 

380 and all(resource[field] == "" for field in _KUBECONFORM_BLANK_RESOURCE_FIELDS) 

381 ) 

382 

383 

384def validate_kubeconform_output( 

385 result: object, 

386 *, 

387 expected_filenames: set[str], 

388) -> list[str]: 

389 """Validate kubeconform's JSON envelope and per-file resource accounting. 

390 

391 A zero process exit code is not sufficient: blank, malformed, truncated, 

392 or structurally incomplete JSON must fail closed rather than reporting 

393 ``OK: 0 manifest(s)``. Multi-document inputs may produce many resource 

394 records, so completeness is based on exact rendered filenames instead of 

395 comparing aggregate record and file counts. 

396 """ 

397 if not isinstance(result, dict): 

398 return ["top-level JSON value is not an object"] 

399 

400 errors: list[str] = [] 

401 raw_resources = result.get("resources") 

402 if not isinstance(raw_resources, list): 

403 return ["'resources' is missing or is not an array"] 

404 resources = [ 

405 resource for resource in raw_resources if not _is_kubeconform_separator_record(resource) 

406 ] 

407 if not resources: 

408 errors.append("'resources' is empty") 

409 

410 expected = {str(Path(filename).resolve(strict=False)) for filename in expected_filenames} 

411 observed: set[str] = set() 

412 actual_counts = dict.fromkeys(_KUBECONFORM_STATUS_TO_SUMMARY.values(), 0) 

413 for index, resource in enumerate(resources): 

414 if not isinstance(resource, dict): 

415 errors.append(f"resources[{index}] is not an object") 

416 continue 

417 

418 filename = resource.get("filename") 

419 if not isinstance(filename, str) or not filename: 

420 errors.append(f"resources[{index}].filename is missing or is not a non-empty string") 

421 else: 

422 observed.add(str(Path(filename).resolve(strict=False))) 

423 

424 status = resource.get("status") 

425 summary_field = ( 

426 _KUBECONFORM_STATUS_TO_SUMMARY.get(status) if isinstance(status, str) else None 

427 ) 

428 if summary_field is None: 

429 errors.append(f"resources[{index}] has unknown status {status!r}") 

430 continue 

431 actual_counts[summary_field] += 1 

432 

433 missing = sorted(expected - observed) 

434 if missing: 

435 errors.append("no resource result was returned for input file(s): " + ", ".join(missing)) 

436 unexpected = sorted(observed - expected) 

437 if unexpected: 

438 errors.append( 

439 "resource results were returned for unexpected file(s): " + ", ".join(unexpected) 

440 ) 

441 

442 summary = result.get("summary") 

443 if not isinstance(summary, dict): 

444 errors.append("'summary' is missing or is not an object") 

445 return errors 

446 

447 for field, actual_count in actual_counts.items(): 

448 reported_count = summary.get(field) 

449 if ( 

450 isinstance(reported_count, bool) 

451 or not isinstance(reported_count, int) 

452 or reported_count < 0 

453 ): 

454 errors.append(f"summary.{field} is missing or is not a non-negative integer") 

455 elif reported_count != actual_count: 

456 errors.append( 

457 f"summary.{field} reports {reported_count}, but resources contain {actual_count}" 

458 ) 

459 

460 return errors 

461 

462 

463def format_failures(result: dict[str, Any]) -> list[str]: 

464 """Turn kubeconform's per-resource JSON records into readable error lines. 

465 

466 Only ``statusInvalid`` and ``statusError`` are failures — ``statusValid`` 

467 and ``statusSkipped`` (the two explicitly ``-skip``ped CRDs) are fine. 

468 """ 

469 lines: list[str] = [] 

470 for resource in result.get("resources", []): 

471 status = resource.get("status") 

472 if status not in ("statusInvalid", "statusError"): 

473 continue 

474 filename = resource.get("filename", "<unknown file>") 

475 kind = resource.get("kind") or "<unparsed>" 

476 name = resource.get("name") or "" 

477 label = f"{kind} {name}".strip() 

478 msg = resource.get("msg", "") 

479 lines.append(f"{filename}: {label}: {msg}") 

480 return lines 

481 

482 

483def _build_parser() -> argparse.ArgumentParser: 

484 parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) 

485 parser.add_argument( 

486 "--path", 

487 action="append", 

488 dest="paths", 

489 help=( 

490 "Directory, YAML file, or glob to validate (relative to repo root or absolute). " 

491 "Repeatable; quote glob patterns so Python expands them. Defaults to both " 

492 "lambda/kubectl-applier-simple/manifests and examples." 

493 ), 

494 ) 

495 parser.add_argument( 

496 "--kubeconform-binary", 

497 default="kubeconform", 

498 help="kubeconform executable to use (default: 'kubeconform' on PATH).", 

499 ) 

500 parser.add_argument( 

501 "--no-strict", 

502 action="store_true", 

503 help="Disable kubeconform's -strict mode (allow unknown/additional properties).", 

504 ) 

505 parser.add_argument( 

506 "-v", 

507 "--verbose", 

508 action="store_true", 

509 help="Print a per-resource OK line in addition to failures.", 

510 ) 

511 return parser 

512 

513 

514def _print_input_errors(errors: list[str]) -> None: 

515 if not errors: 

516 return 

517 print(f"ERROR: {len(errors)} manifest input problem(s) found:", file=sys.stderr) 

518 for error in errors: 

519 print(f" - {error}", file=sys.stderr) 

520 

521 

522def main(argv: list[str] | None = None) -> int: 

523 args = _build_parser().parse_args(argv) 

524 targets = tuple(args.paths) if args.paths else DEFAULT_TARGET_DIRS 

525 files, input_errors = collect_target_files(targets) 

526 

527 if shutil.which(args.kubeconform_binary) is None: 

528 _print_input_errors(input_errors) 

529 print( 

530 f"ERROR: '{args.kubeconform_binary}' not found on PATH. " 

531 "Install it (see Dockerfile.dev / docs/MAINTENANCE.md) or pass " 

532 "--kubeconform-binary.", 

533 file=sys.stderr, 

534 ) 

535 return 2 

536 

537 if not files: 

538 _print_input_errors(input_errors) 

539 if not input_errors: 

540 print(f"ERROR: no *.yaml/*.yml files found for {targets}", file=sys.stderr) 

541 return 2 

542 

543 with tempfile.TemporaryDirectory(prefix="gco-k8s-validate-") as tmp: 

544 rendered_dir = Path(tmp) 

545 rendered_paths = render_tree(files, rendered_dir) 

546 expected_filenames = {str(path) for path in rendered_paths} 

547 

548 rc, result = run_kubeconform( 

549 rendered_dir, 

550 kubeconform_binary=args.kubeconform_binary, 

551 strict=not args.no_strict, 

552 ) 

553 

554 output_errors = validate_kubeconform_output( 

555 result, 

556 expected_filenames=expected_filenames, 

557 ) 

558 result_dict = result if isinstance(result, dict) else {} 

559 

560 if args.verbose and not output_errors: 

561 for resource in result_dict.get("resources", []): 

562 if resource.get("status") == "statusValid": 

563 kind = resource.get("kind") or "" 

564 name = resource.get("name") or "" 

565 print(f"ok {resource.get('filename')}: {kind} {name}".rstrip()) 

566 

567 failures = format_failures(result_dict) if not output_errors else [] 

568 summary = result_dict.get("summary", {}) 

569 

570 if failures: 

571 print() 

572 print(f"ERROR: {len(failures)} Kubernetes manifest validation problem(s) found:") 

573 for line in failures: 

574 print(f" - {line}") 

575 print() 

576 print( 

577 "Fix the manifest (or, if this is a new CRD with no upstream schema " 

578 "yet, add it to SCHEMA_UNAVAILABLE_SKIPS in " 

579 "validate_k8s_manifests.py with a comment explaining why)." 

580 ) 

581 

582 _print_input_errors(input_errors) 

583 

584 runtime_failure = bool(output_errors) or (rc != 0 and not failures) 

585 if output_errors: 

586 print("ERROR: kubeconform returned unusable JSON output:", file=sys.stderr) 

587 for error in output_errors: 

588 print(f" - {error}", file=sys.stderr) 

589 elif rc != 0 and not failures: 

590 # A non-zero process result without resource validation failures is an 

591 # invocation/runtime error, even if a partial JSON document was emitted. 

592 print("ERROR: kubeconform exited non-zero without validation failures.", file=sys.stderr) 

593 

594 # Input/runtime errors take precedence, but valid supplied files were still 

595 # rendered and validated above so one missing path cannot mask their report. 

596 if input_errors or runtime_failure: 

597 return 2 

598 if failures: 

599 return 1 

600 

601 print( 

602 f"OK: {summary.get('valid', 0)} manifest(s) are schema-valid " 

603 f"({summary.get('skipped', 0)} intentionally skipped: no upstream " 

604 "schema yet for KubeRay/Volcano CRDs)." 

605 ) 

606 return 0 

607 

608 

609if __name__ == "__main__": 

610 sys.exit(main())