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

517 statements  

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

1"""Validate every Helm chart (name, version) pinned in charts.yaml. 

2 

3Confirms that each chart entry in ``lambda/helm-installer/charts.yaml`` is a 

4real, installable Helm chart at the pinned version — i.e. the 

5``(chart, version)`` combination is something Helm can actually pull and 

6render for a cluster, not a typo or a tag that never shipped. The 

7helm-installer Lambda trusts these pins blindly at deploy time, so a bad pin 

8today only surfaces as a failed ``helm upgrade --install`` mid-deploy; this 

9check moves that failure left into CI. 

10 

11Two layers, matching what each CI stage can afford: 

12 

13 Structural (offline, always): pure-Python checks on the parsed YAML — no 

14 network, no ``helm`` binary. Every entry needs a ``chart``, a SemVer-ish 

15 ``version`` and a ``repo_url``; an ``oci://`` ``repo_url`` must set 

16 ``use_oci: true`` (and vice-versa); a classic HTTP(S) repo needs a 

17 ``repo_name`` for ``helm repo add``. Catches the obvious mistakes before 

18 spending a network round-trip. 

19 

20 Online (needs ``helm`` + network): for every chart, build the *same* 

21 reference the installer Lambda builds (see ``handler.install_chart``), 

22 then 

23 

24 * ``helm show chart <ref> --version <ver>`` — proves Helm can pull the 

25 chart metadata at exactly the pinned version; and 

26 * ``helm template <ref> --version <ver> --values <configured>`` — 

27 proves the chart renders to Kubernetes manifests (installable) with 

28 the values ``charts.yaml`` ships. 

29 

30 Two retry layers ride out intermittent registry failures instead of 

31 forcing a manual job rerun. Inner: every network-touching helm call 

32 (repo add/update, show chart, template) goes through ``_run_with_retry`` 

33 — a fixed number of attempts with exponential backoff, first success 

34 wins. Outer: charts still failing after the first sweep get a bounded 

35 number of re-passes (default one more, ~30s later) behind a fresh 

36 ``helm repo add`` + index refresh, covering outages that outlast a 

37 single command's retry window. A genuinely bad pin fails every attempt 

38 of every pass and still surfaces. 

39 

40By default *every* chart in the file is validated, including entries with 

41``enabled: false``: those are toggled on via ``cdk.json``, so their pinned 

42``(name, version)`` must be valid too. 

43 

44Usage:: 

45 

46 # Structural only (no helm needed): 

47 python3 .github/scripts/validate_helm_charts.py --mode offline 

48 

49 # Full check (requires helm on PATH + network) — the CI gate: 

50 python3 .github/scripts/validate_helm_charts.py --mode online 

51 

52 # Auto: structural always, online when helm happens to be installed: 

53 python3 .github/scripts/validate_helm_charts.py 

54 

55 # Point at a non-default charts.yaml (used by the test suite): 

56 python3 .github/scripts/validate_helm_charts.py --charts /path/to/charts.yaml 

57 

58 # Emit one chart's pinned reference / shipped values (consumed by the 

59 # integration:kind:examples-smoke job so its `helm install` uses the 

60 # exact pins and values the installer Lambda would — no copies in CI): 

61 python3 .github/scripts/validate_helm_charts.py --emit-ref mlflow 

62 python3 .github/scripts/validate_helm_charts.py --emit-values mlflow 

63 

64Exit codes:: 

65 

66 0 all validated charts are well-formed (and, when online, resolvable 

67 + renderable at their pinned versions) 

68 1 one or more charts failed validation 

69 2 unexpected I/O / argument error (charts.yaml missing or unparseable, 

70 or --mode online requested without a helm binary) 

71 

72The module is importable from the test suite — call ``validate_structure()``, 

73``build_refs()`` or ``validate_online()`` directly to exercise the logic 

74against fixtures. 

75""" 

76 

77from __future__ import annotations 

78 

79import argparse 

80import contextlib 

81import copy 

82import os 

83import re 

84import shutil 

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

86import sys 

87import tempfile 

88import time 

89from dataclasses import dataclass 

90from pathlib import Path 

91from typing import Any 

92 

93import yaml 

94 

95# charts.yaml lives next to the helm-installer Lambda handler. This script is 

96# .github/scripts/validate_helm_charts.py, so the repo root is two parents up. 

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

98_DEFAULT_CHARTS = _REPO_ROOT / "lambda" / "helm-installer" / "charts.yaml" 

99 

100# Lenient SemVer-ish matcher for the pinned chart ``version``. Helm requires 

101# SemVer2 chart versions, so anything failing this is guaranteed to fail a real 

102# ``helm ... --version`` resolve too — we just catch it offline first. Accepts 

103# an optional leading "v" (cert-manager / aws-efa tag their charts that way) 

104# and an optional pre-release / build-metadata suffix. 

105_VERSION_RE = re.compile(r"^v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") 

106 

107 

108@dataclass(frozen=True) 

109class ChartRef: 

110 """Everything needed to resolve one ``charts.yaml`` entry with Helm.""" 

111 

112 name: str 

113 chart: str 

114 version: str 

115 repo_name: str 

116 repo_url: str 

117 use_oci: bool 

118 namespace: str 

119 enabled: bool 

120 values: dict[str, Any] 

121 

122 def reference(self) -> str: 

123 """Build the Helm chart reference exactly like ``handler.install_chart``. 

124 

125 OCI charts are addressed by their full ``oci://.../<chart>`` URL; 

126 classic HTTP(S) repos are addressed as ``<repo_name>/<chart>`` after 

127 the repo has been added with ``helm repo add``. 

128 """ 

129 if self.use_oci: 

130 return f"{self.repo_url}/{self.chart}" 

131 return f"{self.repo_name}/{self.chart}" 

132 

133 

134def load_charts(path: Path) -> dict[str, Any]: 

135 """Parse ``charts.yaml`` and return the ``charts:`` mapping. 

136 

137 Raises ``FileNotFoundError`` when the file is absent and ``ValueError`` 

138 when the top-level ``charts:`` mapping is missing or malformed; ``main()`` 

139 turns both into exit code 2. 

140 """ 

141 with open(path, encoding="utf-8") as fh: 

142 data = yaml.safe_load(fh) or {} 

143 charts = data.get("charts") 

144 if not isinstance(charts, dict): 

145 raise ValueError("missing or malformed top-level 'charts:' mapping") 

146 return charts 

147 

148 

149def validate_structure(charts: dict[str, Any], *, enabled_only: bool = False) -> list[str]: 

150 """Return a list of structural problems (empty == all well-formed). 

151 

152 These are the offline, pure-Python checks: enough to guarantee we can even 

153 build a Helm reference for each entry and that OCI/classic metadata is 

154 self-consistent. Every returned string names the offending chart so the 

155 failure is actionable from the CI log alone. 

156 """ 

157 errors: list[str] = [] 

158 if not charts: 

159 return ["charts.yaml contains no chart entries under 'charts:'"] 

160 

161 for name, cfg in charts.items(): 

162 if not isinstance(cfg, dict): 

163 errors.append(f"{name}: entry is not a mapping") 

164 continue 

165 if enabled_only and not cfg.get("enabled", False): 

166 continue 

167 

168 chart = cfg.get("chart") 

169 version = cfg.get("version") 

170 repo_url = cfg.get("repo_url") 

171 repo_name = cfg.get("repo_name") 

172 use_oci = bool(cfg.get("use_oci", False)) 

173 

174 if not (isinstance(chart, str) and chart.strip()): 

175 errors.append(f"{name}: missing or empty 'chart'") 

176 

177 if not (isinstance(version, str) and version.strip()): 

178 errors.append(f"{name}: missing or empty 'version'") 

179 elif not _VERSION_RE.match(version.strip()): 

180 errors.append(f"{name}: version {version!r} is not a valid SemVer chart version") 

181 

182 if not (isinstance(repo_url, str) and repo_url.strip()): 

183 errors.append(f"{name}: missing or empty 'repo_url'") 

184 else: 

185 is_oci_url = repo_url.startswith("oci://") 

186 if is_oci_url and not use_oci: 

187 errors.append(f"{name}: repo_url is oci:// but use_oci is not set to true") 

188 if use_oci and not is_oci_url: 

189 errors.append( 

190 f"{name}: use_oci is true but repo_url {repo_url!r} is not an oci:// URL" 

191 ) 

192 if not use_oci and not is_oci_url: 

193 if not repo_url.startswith(("http://", "https://")): 

194 errors.append( 

195 f"{name}: classic repo_url {repo_url!r} must be http(s):// or oci://" 

196 ) 

197 if not (isinstance(repo_name, str) and repo_name.strip()): 

198 errors.append(f"{name}: non-OCI chart needs a 'repo_name' for 'helm repo add'") 

199 

200 return errors 

201 

202 

203def build_refs(charts: dict[str, Any], *, enabled_only: bool = False) -> list[ChartRef]: 

204 """Build a ``ChartRef`` for every entry well-formed enough to resolve. 

205 

206 Entries too malformed to build a reference (no chart / version / repo_url, 

207 or a classic repo with no repo_name) are skipped here — ``validate_structure`` 

208 already reports them, so there is no point trying to hit Helm for them. 

209 """ 

210 refs: list[ChartRef] = [] 

211 for name, cfg in charts.items(): 

212 if not isinstance(cfg, dict): 

213 continue 

214 if enabled_only and not cfg.get("enabled", False): 

215 continue 

216 

217 chart = cfg.get("chart") 

218 version = cfg.get("version") 

219 repo_url = cfg.get("repo_url") 

220 use_oci = bool(cfg.get("use_oci", False)) 

221 repo_name = cfg.get("repo_name") or "" 

222 

223 if not (isinstance(chart, str) and chart.strip()): 

224 continue 

225 if not (isinstance(version, str) and version.strip()): 

226 continue 

227 if not (isinstance(repo_url, str) and repo_url.strip()): 

228 continue 

229 if not use_oci and not str(repo_name).strip(): 

230 continue 

231 

232 values = cfg.get("values") 

233 refs.append( 

234 ChartRef( 

235 name=str(name), 

236 chart=chart.strip(), 

237 version=version.strip(), 

238 repo_name=str(repo_name).strip(), 

239 repo_url=repo_url.strip(), 

240 use_oci=use_oci, 

241 namespace=str(cfg.get("namespace", "default")), 

242 enabled=bool(cfg.get("enabled", False)), 

243 values=values if isinstance(values, dict) else {}, 

244 ) 

245 ) 

246 return refs 

247 

248 

249def _run(cmd: list[str], env: dict[str, str], *, timeout: int = 120) -> tuple[int, str, str]: 

250 """Run a command with a fixed argv (never a shell), returning (rc, out, err). 

251 

252 A subprocess timeout maps to ``(-1, "", "timeout: ...")`` so callers get a 

253 uniform failure contract instead of an exception — mirrors the same pattern 

254 in the helm-installer Lambda's ``run_helm``. 

255 """ 

256 try: 

257 proc = ( 

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

259 cmd, 

260 capture_output=True, 

261 text=True, 

262 env=env, 

263 timeout=timeout, 

264 check=False, 

265 ) 

266 ) 

267 except subprocess.TimeoutExpired as exc: 

268 return -1, "", f"timeout: command exceeded {exc.timeout}s" 

269 return proc.returncode, proc.stdout, proc.stderr 

270 

271 

272def _run_with_retry( 

273 cmd: list[str], 

274 env: dict[str, str], 

275 *, 

276 attempts: int = 4, 

277 base_delay: float = 2.0, 

278 max_delay: float = 20.0, 

279 timeout: int = 120, 

280 verbose: bool = False, 

281 description: str = "", 

282) -> tuple[int, str, str]: 

283 """Run a network-touching helm command up to ``attempts`` times; first success wins. 

284 

285 The command is retried on any non-zero exit, regardless of why it failed: 

286 the first attempt that succeeds (rc 0) is returned immediately, and if none 

287 do, the last failure is returned. Between attempts we sleep an exponentially 

288 growing delay (``base_delay`` doubling each round, capped at ``max_delay``) 

289 to give a blipping registry a moment to recover. 

290 

291 This is the guard against intermittent registry failures (timeouts, resets, 

292 5xx) that otherwise force a manual rerun of the ``integration:helm:charts-valid`` 

293 job. A genuinely bad pin fails on every attempt and still surfaces, just a 

294 few seconds later. 

295 """ 

296 result: tuple[int, str, str] = (1, "", "") 

297 for attempt in range(1, attempts + 1): 

298 result = _run(cmd, env, timeout=timeout) 

299 if result[0] == 0: 

300 return result 

301 if attempt < attempts: 

302 delay = min(base_delay * (2 ** (attempt - 1)), max_delay) 

303 if verbose: 

304 label = description or " ".join(cmd) 

305 print( 

306 f" attempt {attempt}/{attempts} failed, retrying in " 

307 f"{delay:.1f}s — {label}: {_tail(result[2], 200)}" 

308 ) 

309 time.sleep(delay) 

310 return result 

311 

312 

313def _helm_env(helm_home: Path) -> dict[str, str]: 

314 """Return an environment that isolates Helm's cache/config/data in a temp dir. 

315 

316 Keeps the check hermetic: it never reads or clobbers a developer's real 

317 ``helm repo`` list, and CI starts from a clean slate every run. 

318 """ 

319 env = os.environ.copy() 

320 env["HELM_CACHE_HOME"] = str(helm_home / "cache") 

321 env["HELM_CONFIG_HOME"] = str(helm_home / "config") 

322 env["HELM_DATA_HOME"] = str(helm_home / "data") 

323 return env 

324 

325 

326def _tail(text: str, limit: int = 400) -> str: 

327 """Trim helm stderr to the last ``limit`` chars so reports stay readable.""" 

328 text = (text or "").strip() 

329 if len(text) <= limit: 

330 return text 

331 return "..." + text[-limit:] 

332 

333 

334def _chart_version_from_show(show_output: str) -> str | None: 

335 """Extract the ``version`` field from ``helm show chart`` YAML output.""" 

336 try: 

337 meta = yaml.safe_load(show_output) 

338 except yaml.YAMLError: 

339 return None 

340 if isinstance(meta, dict): 

341 version = meta.get("version") 

342 if isinstance(version, str): 

343 return version 

344 return None 

345 

346 

347def _versions_match(resolved: str, requested: str) -> bool: 

348 """Compare versions ignoring a leading ``v`` on either side.""" 

349 return resolved.lstrip("v") == requested.lstrip("v") 

350 

351 

352def _render_chart( 

353 ref: ChartRef, 

354 ref_str: str, 

355 helm_binary: str, 

356 env: dict[str, str], 

357 *, 

358 verbose: bool = False, 

359) -> str | None: 

360 """``helm template`` the chart with its shipped values; return an error or None. 

361 

362 Rendering with the exact ``values`` block from ``charts.yaml`` proves the 

363 chart is installable *as GCO configures it*, not just with upstream 

364 defaults. No cluster is contacted, but templating a remote ``--version`` 

365 ref still pulls the chart from its registry, so it goes through 

366 ``_run_with_retry`` to ride out the same transient blips as the resolve 

367 step. 

368 """ 

369 args = [ 

370 helm_binary, 

371 "template", 

372 ref.name, 

373 ref_str, 

374 "--version", 

375 ref.version, 

376 "--namespace", 

377 ref.namespace, 

378 ] 

379 

380 values_path: str | None = None 

381 if ref.values: 

382 fd, values_path = tempfile.mkstemp(suffix=".yaml", prefix=f"{ref.name}-values-") 

383 try: 

384 # fdopen takes ownership of fd; the with-block closes it on any exit, 

385 # so a failing dump must NOT close it again (that raised EBADF and 

386 # masked the real error). Remove the half-written file instead. 

387 with os.fdopen(fd, "w", encoding="utf-8") as fh: 

388 yaml.safe_dump(ref.values, fh) 

389 except Exception: 

390 with contextlib.suppress(OSError): 

391 os.remove(values_path) 

392 raise 

393 args.extend(["--values", values_path]) 

394 

395 try: 

396 rc, _out, err = _run_with_retry( 

397 args, 

398 env, 

399 timeout=180, 

400 verbose=verbose, 

401 description=f"helm template {ref.name}", 

402 ) 

403 finally: 

404 if values_path: 

405 with contextlib.suppress(OSError): 

406 os.remove(values_path) 

407 

408 if rc != 0: 

409 return f"chart failed to render (helm template): {_tail(err)}" 

410 return None 

411 

412 

413def _sync_classic_repos( 

414 classic: dict[str, str], 

415 helm_binary: str, 

416 env: dict[str, str], 

417 *, 

418 verbose: bool = False, 

419) -> list[str]: 

420 """``helm repo add`` every classic repo and refresh the index once. 

421 

422 Returns error strings for repos that could not be added. The index 

423 refresh is network-bound too — it goes through the same retry so a blip 

424 there doesn't cascade into spurious "cannot resolve" errors downstream. 

425 """ 

426 errors: list[str] = [] 

427 for repo_name, repo_url in classic.items(): 

428 rc, _out, err = _run_with_retry( 

429 [helm_binary, "repo", "add", repo_name, repo_url, "--force-update"], 

430 env, 

431 verbose=verbose, 

432 description=f"helm repo add {repo_name}", 

433 ) 

434 if rc != 0: 

435 errors.append(f"helm repo add {repo_name} ({repo_url}) failed: {_tail(err)}") 

436 if classic: 

437 _run_with_retry( 

438 [helm_binary, "repo", "update"], 

439 env, 

440 timeout=180, 

441 verbose=verbose, 

442 description="helm repo update", 

443 ) 

444 return errors 

445 

446 

447def _validate_refs( 

448 refs: list[ChartRef], 

449 helm_binary: str, 

450 env: dict[str, str], 

451 *, 

452 skip_template: bool = False, 

453 verbose: bool = False, 

454) -> dict[str, list[str]]: 

455 """Resolve + render each chart; return ``{chart name: [errors]}`` for failures.""" 

456 failures: dict[str, list[str]] = {} 

457 for ref in refs: 

458 ref_str = ref.reference() 

459 label = f"{ref.name} {ref.version} ({ref_str})" 

460 chart_errors: list[str] = [] 

461 

462 rc, out, err = _run_with_retry( 

463 [helm_binary, "show", "chart", ref_str, "--version", ref.version], 

464 env, 

465 verbose=verbose, 

466 description=f"helm show chart {ref.name}", 

467 ) 

468 if rc != 0: 

469 chart_errors.append( 

470 f"{ref.name}: helm cannot resolve {ref_str} at version " 

471 f"{ref.version!r}: {_tail(err)}" 

472 ) 

473 if verbose: 

474 print(f"FAIL resolve {label}") 

475 failures[ref.name] = chart_errors 

476 continue 

477 

478 resolved = _chart_version_from_show(out) 

479 if resolved and not _versions_match(resolved, ref.version): 

480 chart_errors.append( 

481 f"{ref.name}: requested version {ref.version!r} but helm resolved {resolved!r}" 

482 ) 

483 if verbose: 

484 print(f"ok resolve {label}") 

485 

486 if not skip_template: 

487 render_error = _render_chart(ref, ref_str, helm_binary, env, verbose=verbose) 

488 if render_error: 

489 chart_errors.append(f"{ref.name}: {render_error}") 

490 if verbose: 

491 print(f"FAIL render {label}") 

492 elif verbose: 

493 print(f"ok render {label}") 

494 

495 if chart_errors: 

496 failures[ref.name] = chart_errors 

497 return failures 

498 

499 

500def validate_online( 

501 refs: list[ChartRef], 

502 *, 

503 helm_binary: str = "helm", 

504 skip_template: bool = False, 

505 verbose: bool = False, 

506 passes: int = 2, 

507 repass_delay: float = 30.0, 

508) -> list[str]: 

509 """Resolve (and, unless skipped, render) each chart at its pinned version. 

510 

511 Returns a list of human-readable error strings (empty == every chart is 

512 resolvable and renderable). Runs against an isolated Helm home so it is 

513 safe to invoke on a developer machine. 

514 

515 Retry model, outer layer: the per-command retry in ``_run_with_retry`` 

516 rides out blips that clear within one command's ~40-second attempt 

517 window, but a registry outage lasting a few minutes fails several charts 

518 on every inner attempt and used to fail the job (observed live: a rerun 

519 of the unchanged job passed). So after the first sweep, the charts that 

520 failed get up to ``passes - 1`` additional sweeps, each preceded by a 

521 ``repass_delay`` pause and a fresh repo add + index refresh. Only 

522 failures that survive every pass are reported; a genuinely bad pin fails 

523 every pass and still surfaces. 

524 """ 

525 errors: list[str] = [] 

526 if not refs: 

527 return errors 

528 

529 with tempfile.TemporaryDirectory(prefix="gco-helm-validate-") as tmp: 

530 env = _helm_env(Path(tmp)) 

531 classic = {ref.repo_name: ref.repo_url for ref in refs if not ref.use_oci} 

532 

533 repo_errors = _sync_classic_repos(classic, helm_binary, env, verbose=verbose) 

534 failures = _validate_refs( 

535 refs, helm_binary, env, skip_template=skip_template, verbose=verbose 

536 ) 

537 

538 for extra_pass in range(2, max(passes, 1) + 1): 

539 if not failures and not repo_errors: 

540 break 

541 if verbose: 

542 print( 

543 f"re-pass {extra_pass}/{passes}: retrying " 

544 f"{len(failures)} failed chart(s) in {repass_delay:.0f}s " 

545 "(fresh repo index)" 

546 ) 

547 time.sleep(repass_delay) 

548 repo_errors = _sync_classic_repos(classic, helm_binary, env, verbose=verbose) 

549 retry_refs = [ref for ref in refs if ref.name in failures] 

550 failures = _validate_refs( 

551 retry_refs, helm_binary, env, skip_template=skip_template, verbose=verbose 

552 ) 

553 

554 errors.extend(repo_errors) 

555 for ref in refs: 

556 errors.extend(failures.get(ref.name, [])) 

557 

558 return errors 

559 

560 

561# --------------------------------------------------------------------------- 

562# Gateway API / aws-load-balancer-controller lockstep 

563# 

564# The controller is built against an exact ``sigs.k8s.io/gateway-api`` release 

565# (declared in its go.mod) and its own gateway CRDs ship per controller tag. 

566# GCO pins three coupled artifacts in two different files: 

567# 

568# * the aws-load-balancer-controller chart version (charts.yaml, this file's 

569# usual input), 

570# * the ``gateway-api-standard-vX.Y.Z`` CRD bundle, and 

571# * the ``aws-lbc-gateway-vX.Y.Z`` CRD bundle 

572# (both in lambda/helm-installer/handler.py PINNED_GATEWAY_CRD_BUNDLES). 

573# 

574# When the chart moved to 3.5.0 while the standard bundle stayed at v1.5.0, 

575# the controller silently stopped reconciling gateways — nothing failed until 

576# the live release validation deployed the pair. These checks encode the 

577# contract so the drift fails CI instead: 

578# 

579# offline: the aws-lbc-gateway bundle version must equal the pinned chart 

580# version (they ship from the same controller tag). 

581# online: the controller tag's go.mod names its required gateway-api 

582# release; the pinned standard bundle must be at least that (major.minor). 

583# --------------------------------------------------------------------------- 

584 

585_HANDLER_PATH = _REPO_ROOT / "lambda" / "helm-installer" / "handler.py" 

586_LBC_CHART_KEY = "aws-load-balancer-controller" 

587_GATEWAY_API_BUNDLE_RE = re.compile(r'name="gateway-api-standard-v(\d+\.\d+\.\d+)"') 

588_LBC_BUNDLE_RE = re.compile(r'name="aws-lbc-gateway-v(\d+\.\d+\.\d+)"') 

589_GO_MOD_GATEWAY_API_RE = re.compile(r"^\s*sigs\.k8s\.io/gateway-api\s+v(\d+\.\d+\.\d+)", re.M) 

590_LBC_GO_MOD_URL_TEMPLATE = ( 

591 "https://raw.githubusercontent.com/kubernetes-sigs/" 

592 "aws-load-balancer-controller/v{version}/go.mod" 

593) 

594_GO_MOD_FETCH_ATTEMPTS = 3 

595_GO_MOD_FETCH_TIMEOUT_SECONDS = 15 

596 

597 

598def parse_pinned_gateway_bundles(handler_source: str) -> tuple[str | None, str | None]: 

599 """Return (gateway-api standard bundle version, aws-lbc bundle version).""" 

600 gateway_api = _GATEWAY_API_BUNDLE_RE.search(handler_source) 

601 lbc = _LBC_BUNDLE_RE.search(handler_source) 

602 return ( 

603 gateway_api.group(1) if gateway_api else None, 

604 lbc.group(1) if lbc else None, 

605 ) 

606 

607 

608def gateway_api_requirement_from_go_mod(go_mod_text: str) -> str | None: 

609 """Return the gateway-api release the controller's go.mod declares.""" 

610 match = _GO_MOD_GATEWAY_API_RE.search(go_mod_text) 

611 return match.group(1) if match else None 

612 

613 

614def _version_tuple(version: str) -> tuple[int, ...]: 

615 return tuple(int(part) for part in version.split(".")) 

616 

617 

618def fetch_lbc_go_mod(controller_version: str) -> str: 

619 """Fetch the controller tag's go.mod, retrying transient HTTP failures.""" 

620 import urllib.error 

621 import urllib.request 

622 

623 if not re.fullmatch(r"\d+\.\d+\.\d+", controller_version): 

624 raise RuntimeError( 

625 f"refusing go.mod fetch for non-semver controller version {controller_version!r}" 

626 ) 

627 url = _LBC_GO_MOD_URL_TEMPLATE.format(version=controller_version) 

628 last_error: Exception | None = None 

629 for attempt in range(1, _GO_MOD_FETCH_ATTEMPTS + 1): 

630 try: 

631 with urllib.request.urlopen( # nosec B310 # nosemgrep: dynamic-urllib-use-detected - fixed https://raw.githubusercontent.com template; the only variable is a strictly semver-validated version segment, so no scheme or host injection is possible # noqa: S310 

632 url, timeout=_GO_MOD_FETCH_TIMEOUT_SECONDS 

633 ) as response: 

634 return str(response.read().decode("utf-8")) 

635 except (urllib.error.URLError, TimeoutError, OSError) as exc: 

636 last_error = exc 

637 if attempt < _GO_MOD_FETCH_ATTEMPTS: 

638 time.sleep(2**attempt) 

639 raise RuntimeError(f"could not fetch {url}: {last_error}") 

640 

641 

642def validate_gateway_lockstep( 

643 charts: dict[str, Any], 

644 *, 

645 handler_source: str | None = None, 

646 go_mod_fetcher: Any = None, 

647 online: bool = False, 

648 require_entry: bool = True, 

649) -> list[str]: 

650 """Check the chart / CRD-bundle lockstep contract; return error strings. 

651 

652 Offline: the ``aws-lbc-gateway`` bundle version must equal the pinned 

653 chart version. Online (adds one HTTPS fetch): the pinned standard 

654 gateway-api bundle must satisfy the requirement in the controller tag's 

655 go.mod. With ``require_entry`` (the default, used for the repository's 

656 real charts.yaml) a missing chart entry or renamed handler constants are 

657 reported rather than skipped — this guard exists precisely for refactors 

658 that move them. ``main()`` disables ``require_entry`` for explicitly 

659 supplied ``--charts`` fixture files that legitimately omit the entry. 

660 """ 

661 errors: list[str] = [] 

662 entry = charts.get(_LBC_CHART_KEY) 

663 if not isinstance(entry, dict) or not entry.get("version"): 

664 if require_entry: 

665 return [f"gateway lockstep: no {_LBC_CHART_KEY!r} entry with a version in charts.yaml"] 

666 return [] 

667 chart_version = str(entry["version"]).lstrip("v") 

668 

669 if handler_source is None: 

670 try: 

671 handler_source = _HANDLER_PATH.read_text(encoding="utf-8") 

672 except OSError as exc: 

673 return [f"gateway lockstep: cannot read {_HANDLER_PATH}: {exc}"] 

674 

675 gateway_api_pin, lbc_pin = parse_pinned_gateway_bundles(handler_source) 

676 if gateway_api_pin is None or lbc_pin is None: 

677 return [ 

678 "gateway lockstep: PINNED_GATEWAY_CRD_BUNDLES in " 

679 "lambda/helm-installer/handler.py no longer names a " 

680 "'gateway-api-standard-vX.Y.Z' and an 'aws-lbc-gateway-vX.Y.Z' " 

681 "bundle; update this check alongside any rename" 

682 ] 

683 

684 if lbc_pin != chart_version: 

685 errors.append( 

686 f"gateway lockstep: aws-lbc-gateway CRD bundle v{lbc_pin} does not match " 

687 f"the pinned {_LBC_CHART_KEY} chart {chart_version} — the controller's " 

688 "gateway CRDs ship per controller tag; bump both together " 

689 "(charts.yaml + PINNED_GATEWAY_CRD_BUNDLES in lambda/helm-installer/handler.py)" 

690 ) 

691 

692 if not online: 

693 return errors 

694 

695 fetcher = go_mod_fetcher or fetch_lbc_go_mod 

696 try: 

697 go_mod_text = fetcher(chart_version) 

698 except RuntimeError as exc: 

699 errors.append(f"gateway lockstep: {exc}") 

700 return errors 

701 

702 required = gateway_api_requirement_from_go_mod(go_mod_text) 

703 if required is None: 

704 errors.append( 

705 f"gateway lockstep: go.mod for {_LBC_CHART_KEY} v{chart_version} does not " 

706 "declare sigs.k8s.io/gateway-api — upstream layout changed; update this check" 

707 ) 

708 return errors 

709 

710 if _version_tuple(gateway_api_pin)[:2] < _version_tuple(required)[:2]: 

711 errors.append( 

712 f"gateway lockstep: {_LBC_CHART_KEY} {chart_version} is built against " 

713 f"gateway-api v{required} (its go.mod), but the pinned standard CRD bundle " 

714 f"is v{gateway_api_pin}. Upgrading the controller without its Gateway API " 

715 "CRDs silently stops gateway reconciliation (caught live, 2026-08). Bump " 

716 "'gateway-api-standard' in lambda/helm-installer/handler.py to at least " 

717 f"v{required} in the same change" 

718 ) 

719 return errors 

720 

721 

722# --------------------------------------------------------------------------- 

723# Kubeflow Trainer runtime / example / docs lockstep 

724# 

725# The kubeflow-trainer chart delivers its built-in ClusterTrainingRuntime 

726# blueprints through a post-install hook Job (an unpinned run-time network 

727# fetch), so GCO disables that hook and ships the ``torch-distributed`` 

728# runtime itself — extracted verbatim from the pinned chart into 

729# ``post-helm-kubeflow-trainer-runtimes.yaml`` with two documented 

730# deviations (``automountServiceAccountToken: false`` and NoNewPrivs on the 

731# ``node`` container). Three more surfaces 

732# repeat the runtime's pinned trainer image so users see exactly what runs: 

733# 

734# * the shipped runtime manifest (the source of truth in-repo), 

735# * ``examples/kubeflow-trainjob.yaml`` ``spec.trainer.image`` (listed 

736# explicitly so the image-trust gate validates it), and 

737# * the ``docs/DISTRIBUTED_TRAINING.md`` TrainJob snippet. 

738# 

739# A chart version bump that skips re-extraction would silently run a stale 

740# runtime (or torchrun wiring) against a newer controller. These checks 

741# encode the contract so the drift fails CI instead: 

742# 

743# offline: the shipped runtime manifest, the example and every 

744# ``pytorch/pytorch`` image mentioned in the distributed-training doc 

745# agree on one image. 

746# online: rendering the *pinned* chart with its runtime delivery enabled 

747# must reproduce the shipped runtime — image called out explicitly, 

748# full spec compared after applying the documented deviations, and the 

749# upstream ``trainer.kubeflow.org/*`` labels preserved (the 

750# ``webhook-validation: disabled`` label is what keeps webhook warm-up 

751# from flaking the apply). 

752# --------------------------------------------------------------------------- 

753 

754_TRAINER_CHART_KEY = "kubeflow-trainer" 

755_TRAINER_RUNTIME_NAME = "torch-distributed" 

756_TRAINER_RUNTIME_MANIFEST = ( 

757 _REPO_ROOT 

758 / "lambda" 

759 / "kubectl-applier-simple" 

760 / "manifests" 

761 / "post-helm-kubeflow-trainer-runtimes.yaml" 

762) 

763_TRAINJOB_EXAMPLE = _REPO_ROOT / "examples" / "kubeflow-trainjob.yaml" 

764_DISTRIBUTED_TRAINING_DOC = _REPO_ROOT / "docs" / "DISTRIBUTED_TRAINING.md" 

765# Only pytorch/pytorch mentions are lockstep-bound: the doc may legitimately 

766# show other registries' images, but a pytorch/pytorch tag that differs from 

767# the shipped runtime is exactly the stale-doc drift this check exists for. 

768_DOC_PYTORCH_IMAGE_RE = re.compile(r"image:\s*(pytorch/pytorch:\S+)") 

769# The runtime labels GCO must carry verbatim: framework selection and the 

770# upstream pre-validation marker that exempts the built-in runtime from 

771# webhook admission (losing it reintroduces webhook warm-up flakes). 

772_TRAINER_RUNTIME_LOCKSTEP_LABELS = ( 

773 "trainer.kubeflow.org/framework", 

774 "trainer.kubeflow.org/webhook-validation", 

775) 

776 

777 

778def parse_shipped_torch_runtime(manifest_text: str) -> dict[str, Any] | None: 

779 """Return the one ``torch-distributed`` runtime in the shipped manifest. 

780 

781 ``None`` when the manifest does not contain exactly one 

782 ``ClusterTrainingRuntime`` named ``torch-distributed`` — the caller turns 

783 that into an "update this check" error rather than guessing. 

784 """ 

785 try: 

786 docs = [doc for doc in yaml.safe_load_all(manifest_text) if isinstance(doc, dict)] 

787 except yaml.YAMLError: 

788 return None 

789 runtimes = [ 

790 doc 

791 for doc in docs 

792 if doc.get("kind") == "ClusterTrainingRuntime" 

793 and (doc.get("metadata") or {}).get("name") == _TRAINER_RUNTIME_NAME 

794 ] 

795 return runtimes[0] if len(runtimes) == 1 else None 

796 

797 

798def upstream_torch_runtime_from_render(render_text: str) -> dict[str, Any] | None: 

799 """Extract the ``torch-distributed`` runtime from a rendered chart. 

800 

801 The chart ships its runtimes as multi-doc YAML inside the 

802 ``*runtimes-installer`` ConfigMap's ``runtimes.yaml`` key (the payload its 

803 hook Job would kubectl-apply); this digs the runtime out of that payload. 

804 """ 

805 try: 

806 docs = [doc for doc in yaml.safe_load_all(render_text) if isinstance(doc, dict)] 

807 except yaml.YAMLError: 

808 return None 

809 for doc in docs: 

810 if doc.get("kind") != "ConfigMap": 

811 continue 

812 name = str((doc.get("metadata") or {}).get("name") or "") 

813 if not name.endswith("runtimes-installer"): 

814 continue 

815 payload = (doc.get("data") or {}).get("runtimes.yaml") 

816 if not isinstance(payload, str): 

817 continue 

818 try: 

819 runtimes = [item for item in yaml.safe_load_all(payload) if isinstance(item, dict)] 

820 except yaml.YAMLError: 

821 return None 

822 for runtime in runtimes: 

823 if ( 

824 runtime.get("kind") == "ClusterTrainingRuntime" 

825 and (runtime.get("metadata") or {}).get("name") == _TRAINER_RUNTIME_NAME 

826 ): 

827 return runtime 

828 return None 

829 

830 

831def trainer_node_image(runtime: dict[str, Any]) -> str | None: 

832 """Return the trainer image of a runtime's ``node`` replicated Job.""" 

833 template_spec = ((runtime.get("spec") or {}).get("template") or {}).get("spec") or {} 

834 jobs = template_spec.get("replicatedJobs") 

835 if not isinstance(jobs, list): 

836 return None 

837 for job in jobs: 

838 if not isinstance(job, dict) or job.get("name") != "node": 

839 continue 

840 pod_spec = (((job.get("template") or {}).get("spec") or {}).get("template") or {}).get( 

841 "spec" 

842 ) or {} 

843 containers = pod_spec.get("containers") 

844 if not isinstance(containers, list): 

845 return None 

846 for container in containers: 

847 if isinstance(container, dict) and container.get("name") == "node": 

848 image = container.get("image") 

849 return image if isinstance(image, str) else None 

850 return None 

851 

852 

853def example_trainer_image(example_text: str) -> str | None: 

854 """Return ``spec.trainer.image`` from the TrainJob example manifest.""" 

855 try: 

856 docs = [doc for doc in yaml.safe_load_all(example_text) if isinstance(doc, dict)] 

857 except yaml.YAMLError: 

858 return None 

859 for doc in docs: 

860 if doc.get("kind") == "TrainJob": 

861 image = ((doc.get("spec") or {}).get("trainer") or {}).get("image") 

862 return image if isinstance(image, str) else None 

863 return None 

864 

865 

866def doc_pytorch_images(doc_text: str) -> list[str]: 

867 """Return every ``pytorch/pytorch`` image the doc's snippets mention.""" 

868 return _DOC_PYTORCH_IMAGE_RE.findall(doc_text) 

869 

870 

871def _apply_documented_runtime_deviations(upstream_spec: dict[str, Any]) -> dict[str, Any]: 

872 """Return the upstream runtime spec with GCO's sanctioned deviations applied. 

873 

874 Exactly two deviations from verbatim extraction are documented in the 

875 manifest header, both on the ``node`` pod template: 

876 

877 - ``automountServiceAccountToken: false`` (the same security default 

878 both submission paths inject into user Jobs), and 

879 - ``securityContext.allowPrivilegeEscalation: false`` on the ``node`` 

880 container (NoNewPrivs; the platform's manifest policy already rejects 

881 an explicit ``true``). 

882 

883 Anything else that differs from upstream is drift and must fail — a new 

884 deliberate deviation belongs here *and* in the manifest header, in the 

885 same change. 

886 """ 

887 adjusted = copy.deepcopy(upstream_spec) 

888 jobs = ((adjusted.get("template") or {}).get("spec") or {}).get("replicatedJobs") 

889 for job in jobs if isinstance(jobs, list) else []: 

890 if not isinstance(job, dict) or job.get("name") != "node": 

891 continue 

892 pod_spec = (((job.get("template") or {}).get("spec") or {}).get("template") or {}).get( 

893 "spec" 

894 ) 

895 if not isinstance(pod_spec, dict): 

896 continue 

897 pod_spec["automountServiceAccountToken"] = False 

898 for container in pod_spec.get("containers") or []: 

899 if isinstance(container, dict) and container.get("name") == "node": 

900 security = container.setdefault("securityContext", {}) 

901 if isinstance(security, dict): 

902 security["allowPrivilegeEscalation"] = False 

903 return adjusted 

904 

905 

906def fetch_upstream_torch_runtime( 

907 entry: dict[str, Any], helm_binary: str = "helm" 

908) -> dict[str, Any]: 

909 """Render the pinned chart with runtime delivery enabled; return the runtime. 

910 

911 Runs against an isolated Helm home (same hermetic setup as the resolve / 

912 render pass) and rides the shared retry ladder, so registry blips do not 

913 fail the job. Raises ``RuntimeError`` with an actionable message when the 

914 chart cannot be rendered or no longer ships the runtime where this check 

915 expects it. 

916 """ 

917 refs = build_refs({_TRAINER_CHART_KEY: entry}) 

918 if not refs: 

919 raise RuntimeError( 

920 f"cannot build a Helm reference from the {_TRAINER_CHART_KEY!r} charts.yaml entry" 

921 ) 

922 ref = refs[0] 

923 with tempfile.TemporaryDirectory(prefix="gco-trainer-lockstep-") as tmp: 

924 env = _helm_env(Path(tmp)) 

925 if not ref.use_oci: 

926 _sync_classic_repos({ref.repo_name: ref.repo_url}, helm_binary, env) 

927 rc, out, err = _run_with_retry( 

928 [ 

929 helm_binary, 

930 "template", 

931 ref.name, 

932 ref.reference(), 

933 "--version", 

934 ref.version, 

935 "--namespace", 

936 ref.namespace, 

937 "--set", 

938 "runtimes.torchDistributed.enabled=true", 

939 ], 

940 env, 

941 timeout=180, 

942 description="helm template kubeflow-trainer (runtime delivery enabled)", 

943 ) 

944 if rc != 0: 

945 raise RuntimeError( 

946 f"helm template {ref.reference()} at {ref.version!r} (with " 

947 f"runtimes.torchDistributed.enabled=true) failed: {_tail(err)}" 

948 ) 

949 runtime = upstream_torch_runtime_from_render(out) 

950 if runtime is None: 

951 raise RuntimeError( 

952 f"chart {ref.version} no longer ships a 'runtimes-installer' ConfigMap " 

953 f"containing ClusterTrainingRuntime {_TRAINER_RUNTIME_NAME!r} — upstream " 

954 "runtime delivery changed; update this check alongside the re-extraction" 

955 ) 

956 return runtime 

957 

958 

959def validate_trainer_runtime_lockstep( 

960 charts: dict[str, Any], 

961 *, 

962 manifest_text: str | None = None, 

963 example_text: str | None = None, 

964 doc_text: str | None = None, 

965 online: bool = False, 

966 runtime_fetcher: Any = None, 

967 helm_binary: str = "helm", 

968 require_entry: bool = True, 

969) -> list[str]: 

970 """Check the trainer runtime / example / docs lockstep; return error strings. 

971 

972 Offline: the shipped runtime manifest, the TrainJob example and the 

973 distributed-training doc must agree on the trainer image. Online (adds 

974 one chart render): the shipped runtime must reproduce what the pinned 

975 chart ships — byte-identical spec after the documented deviations, with 

976 the trainer image compared explicitly for an actionable message. With 

977 ``require_entry`` (the default, used for the repository's real 

978 charts.yaml) a missing chart entry is reported rather than skipped; 

979 ``main()`` disables it for ``--charts`` fixture files. 

980 """ 

981 errors: list[str] = [] 

982 entry = charts.get(_TRAINER_CHART_KEY) 

983 if not isinstance(entry, dict) or not entry.get("version"): 

984 if require_entry: 

985 return [ 

986 f"trainer runtime lockstep: no {_TRAINER_CHART_KEY!r} entry with a " 

987 "version in charts.yaml" 

988 ] 

989 return [] 

990 

991 if manifest_text is None: 

992 try: 

993 manifest_text = _TRAINER_RUNTIME_MANIFEST.read_text(encoding="utf-8") 

994 except OSError as exc: 

995 return [f"trainer runtime lockstep: cannot read {_TRAINER_RUNTIME_MANIFEST}: {exc}"] 

996 if example_text is None: 

997 try: 

998 example_text = _TRAINJOB_EXAMPLE.read_text(encoding="utf-8") 

999 except OSError as exc: 

1000 return [f"trainer runtime lockstep: cannot read {_TRAINJOB_EXAMPLE}: {exc}"] 

1001 if doc_text is None: 

1002 try: 

1003 doc_text = _DISTRIBUTED_TRAINING_DOC.read_text(encoding="utf-8") 

1004 except OSError as exc: 

1005 return [f"trainer runtime lockstep: cannot read {_DISTRIBUTED_TRAINING_DOC}: {exc}"] 

1006 

1007 shipped = parse_shipped_torch_runtime(manifest_text) 

1008 if shipped is None: 

1009 return [ 

1010 "trainer runtime lockstep: post-helm-kubeflow-trainer-runtimes.yaml no " 

1011 f"longer contains exactly one ClusterTrainingRuntime named " 

1012 f"{_TRAINER_RUNTIME_NAME!r}; update this check alongside any restructure" 

1013 ] 

1014 shipped_image = trainer_node_image(shipped) 

1015 if not shipped_image: 

1016 return [ 

1017 "trainer runtime lockstep: the shipped torch-distributed runtime has no " 

1018 "containers[name=node] image; update this check alongside any restructure" 

1019 ] 

1020 

1021 example_image = example_trainer_image(example_text) 

1022 if example_image != shipped_image: 

1023 errors.append( 

1024 f"trainer runtime lockstep: examples/kubeflow-trainjob.yaml pins " 

1025 f"spec.trainer.image {example_image!r} but the shipped torch-distributed " 

1026 f"runtime pins {shipped_image!r} — the example deliberately lists the " 

1027 "runtime's image so the image-trust gate validates exactly what runs; " 

1028 "bump both together" 

1029 ) 

1030 for doc_image in dict.fromkeys(doc_pytorch_images(doc_text)): 

1031 if doc_image != shipped_image: 

1032 errors.append( 

1033 f"trainer runtime lockstep: docs/DISTRIBUTED_TRAINING.md shows " 

1034 f"{doc_image!r} but the shipped torch-distributed runtime pins " 

1035 f"{shipped_image!r} — update the doc snippet in the same change" 

1036 ) 

1037 

1038 if not online: 

1039 return errors 

1040 

1041 fetcher = runtime_fetcher or fetch_upstream_torch_runtime 

1042 try: 

1043 upstream = fetcher(entry, helm_binary) 

1044 except RuntimeError as exc: 

1045 errors.append(f"trainer runtime lockstep: {exc}") 

1046 return errors 

1047 

1048 upstream_image = trainer_node_image(upstream) 

1049 if upstream_image != shipped_image: 

1050 errors.append( 

1051 f"trainer runtime lockstep: chart {entry.get('version')} ships " 

1052 f"torch-distributed with image {upstream_image!r} but " 

1053 f"post-helm-kubeflow-trainer-runtimes.yaml pins {shipped_image!r}" 

1054 "re-extract the runtime from the pinned chart (helm template --set " 

1055 "runtimes.torchDistributed.enabled=true) and bump the example + doc " 

1056 "images in the same change" 

1057 ) 

1058 expected_spec = _apply_documented_runtime_deviations(upstream.get("spec") or {}) 

1059 if expected_spec != (shipped.get("spec") or {}): 

1060 errors.append( 

1061 f"trainer runtime lockstep: the shipped torch-distributed runtime spec " 

1062 f"differs from what chart {entry.get('version')} ships (beyond the " 

1063 "documented deviations in _apply_documented_runtime_deviations) — the manifest " 

1064 "header's contract is 'same bytes'; re-extract it from the pinned chart " 

1065 "or record a new sanctioned deviation in both the manifest header and " 

1066 "_apply_documented_runtime_deviations" 

1067 ) 

1068 shipped_labels = (shipped.get("metadata") or {}).get("labels") or {} 

1069 upstream_labels = (upstream.get("metadata") or {}).get("labels") or {} 

1070 for label in _TRAINER_RUNTIME_LOCKSTEP_LABELS: 

1071 if shipped_labels.get(label) != upstream_labels.get(label): 

1072 errors.append( 

1073 f"trainer runtime lockstep: label {label!r} is " 

1074 f"{shipped_labels.get(label)!r} in the shipped runtime but " 

1075 f"{upstream_labels.get(label)!r} upstream — these labels carry " 

1076 "upstream semantics (framework selection / webhook pre-validation) " 

1077 "and must be preserved verbatim" 

1078 ) 

1079 return errors 

1080 

1081 

1082def emit_chart_ref(charts: dict[str, Any], chart_name: str) -> tuple[str, str]: 

1083 """Return ``(text, error)`` for --emit-ref. 

1084 

1085 The emitted line is "<helm-ref> <version> <namespace> <repo_url>", 

1086 space-separated so shell callers can consume it with a plain 

1087 ``read -r ref version namespace repo_url``. The reference is built by the 

1088 same ``ChartRef.reference()`` the online validator uses, which mirrors 

1089 ``handler.install_chart`` — the CI job installs exactly what the 

1090 installer Lambda would. ``repo_url`` rides along for classic (non-OCI) 

1091 charts, whose reference is ``<repo_name>/<chart>`` and only resolves 

1092 after ``helm repo add <repo_name> <repo_url>`` (or via 

1093 ``helm pull <chart> --repo <repo_url>``); for OCI charts it is the 

1094 ``oci://`` base already embedded in the reference. 

1095 """ 

1096 refs = {ref.name: ref for ref in build_refs(charts)} 

1097 ref = refs.get(chart_name) 

1098 if ref is None: 

1099 known = ", ".join(sorted(refs)) or "(none)" 

1100 return "", f"chart {chart_name!r} not found in charts.yaml (known: {known})" 

1101 return f"{ref.reference()} {ref.version} {ref.namespace} {ref.repo_url}", "" 

1102 

1103 

1104def emit_chart_values(charts: dict[str, Any], chart_name: str) -> tuple[str, str]: 

1105 """Return ``(yaml_text, error)`` for --emit-values. 

1106 

1107 Fails when the values still carry a ``{{TOKEN}}`` deployment placeholder: 

1108 charts.yaml values are the deploy-time *fallback* and must be 

1109 standalone-installable (the regional stack only ever layers additional 

1110 overrides on top). A token here would mean the fallback contract broke — 

1111 better to fail the emit than install a chart with a literal ``{{...}}``. 

1112 """ 

1113 refs = {ref.name: ref for ref in build_refs(charts)} 

1114 ref = refs.get(chart_name) 

1115 if ref is None: 

1116 known = ", ".join(sorted(refs)) or "(none)" 

1117 return "", f"chart {chart_name!r} not found in charts.yaml (known: {known})" 

1118 text = yaml.safe_dump(ref.values, default_flow_style=False, sort_keys=False) 

1119 if "{{" in text: 

1120 tokens = sorted(set(re.findall(r"\{\{[A-Z0-9_]+\}\}", text))) 

1121 return "", ( 

1122 f"{chart_name}: values contain deployment tokens {tokens}" 

1123 "charts.yaml values must be standalone-installable fallbacks" 

1124 ) 

1125 return text, "" 

1126 

1127 

1128def _build_parser() -> argparse.ArgumentParser: 

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

1130 parser.add_argument( 

1131 "--charts", 

1132 type=Path, 

1133 default=_DEFAULT_CHARTS, 

1134 help="Path to charts.yaml (defaults to lambda/helm-installer/charts.yaml).", 

1135 ) 

1136 parser.add_argument( 

1137 "--mode", 

1138 choices=("auto", "offline", "online"), 

1139 default="auto", 

1140 help=( 

1141 "auto (default): structural checks always, online checks when helm " 

1142 "is on PATH. offline: structural only. online: require helm and run " 

1143 "the resolve/render checks (fail if helm is missing)." 

1144 ), 

1145 ) 

1146 parser.add_argument( 

1147 "--skip-template", 

1148 action="store_true", 

1149 help="Online mode only: resolve each chart but skip the helm template render.", 

1150 ) 

1151 parser.add_argument( 

1152 "--enabled-only", 

1153 action="store_true", 

1154 help="Validate only charts with enabled: true (default: validate every entry).", 

1155 ) 

1156 parser.add_argument( 

1157 "--helm-binary", 

1158 default="helm", 

1159 help="Helm executable to use (default: 'helm' on PATH).", 

1160 ) 

1161 parser.add_argument( 

1162 "-v", 

1163 "--verbose", 

1164 action="store_true", 

1165 help="Print a per-chart resolve/render line during the online pass.", 

1166 ) 

1167 emit = parser.add_mutually_exclusive_group() 

1168 emit.add_argument( 

1169 "--emit-ref", 

1170 metavar="CHART", 

1171 help=( 

1172 "Print '<helm-ref> <version> <namespace>' for one charts.yaml entry " 

1173 "and exit (query mode for CI jobs that helm-install the pinned chart)." 

1174 ), 

1175 ) 

1176 emit.add_argument( 

1177 "--emit-values", 

1178 metavar="CHART", 

1179 help=( 

1180 "Print the shipped values block for one charts.yaml entry as YAML " 

1181 "and exit; fails if the values carry {{TOKEN}} placeholders." 

1182 ), 

1183 ) 

1184 return parser 

1185 

1186 

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

1188 args = _build_parser().parse_args(argv) 

1189 

1190 try: 

1191 charts = load_charts(args.charts) 

1192 except FileNotFoundError: 

1193 print(f"ERROR: charts file not found: {args.charts}", file=sys.stderr) 

1194 return 2 

1195 except (yaml.YAMLError, ValueError) as exc: 

1196 print(f"ERROR: could not parse {args.charts}: {exc}", file=sys.stderr) 

1197 return 2 

1198 

1199 if args.emit_ref or args.emit_values: 

1200 emitter = emit_chart_ref if args.emit_ref else emit_chart_values 

1201 text, error = emitter(charts, args.emit_ref or args.emit_values) 

1202 if error: 

1203 print(f"ERROR: {error}", file=sys.stderr) 

1204 return 2 

1205 print(text, end="" if text.endswith("\n") else "\n") 

1206 return 0 

1207 

1208 errors = validate_structure(charts, enabled_only=args.enabled_only) 

1209 

1210 helm_available = shutil.which(args.helm_binary) is not None 

1211 run_online = False 

1212 if args.mode == "online": 

1213 if not helm_available: 

1214 print( 

1215 f"ERROR: --mode online requires the '{args.helm_binary}' binary on PATH.", 

1216 file=sys.stderr, 

1217 ) 

1218 return 2 

1219 run_online = True 

1220 elif args.mode == "auto": 

1221 run_online = helm_available 

1222 if not helm_available: 

1223 print( 

1224 "note: 'helm' not found on PATH; running structural checks only. " 

1225 "Pass --mode online in CI to require the resolve/render checks." 

1226 ) 

1227 

1228 # Fixture chart files passed via --charts may legitimately omit the 

1229 # controller / trainer entries; the repository's real charts.yaml may not. 

1230 is_default_charts = args.charts.resolve() == _DEFAULT_CHARTS.resolve() 

1231 errors.extend( 

1232 validate_gateway_lockstep( 

1233 charts, 

1234 online=run_online, 

1235 require_entry=is_default_charts, 

1236 ) 

1237 ) 

1238 errors.extend( 

1239 validate_trainer_runtime_lockstep( 

1240 charts, 

1241 online=run_online, 

1242 helm_binary=args.helm_binary, 

1243 require_entry=is_default_charts, 

1244 ) 

1245 ) 

1246 

1247 refs = build_refs(charts, enabled_only=args.enabled_only) 

1248 if run_online: 

1249 errors.extend( 

1250 validate_online( 

1251 refs, 

1252 helm_binary=args.helm_binary, 

1253 skip_template=args.skip_template, 

1254 verbose=args.verbose, 

1255 ) 

1256 ) 

1257 

1258 if errors: 

1259 print() 

1260 print(f"ERROR: {len(errors)} Helm chart validation problem(s) found:") 

1261 for err in errors: 

1262 print(f" - {err}") 

1263 print() 

1264 print( 

1265 "Fix the pinned chart name/version in lambda/helm-installer/charts.yaml " 

1266 "so every entry is a real, installable Helm chart." 

1267 ) 

1268 return 1 

1269 

1270 scope = "enabled" if args.enabled_only else "all" 

1271 if run_online: 

1272 rendered = "" if args.skip_template else " + rendered" 

1273 print( 

1274 f"OK: {len(refs)} Helm chart(s) ({scope}) are well-formed and " 

1275 f"resolvable{rendered} at their pinned versions." 

1276 ) 

1277 else: 

1278 print(f"OK: {len(refs)} Helm chart(s) ({scope}) are structurally valid.") 

1279 return 0 

1280 

1281 

1282if __name__ == "__main__": 

1283 sys.exit(main())