Coverage for scripts / example_job_validation / actions.py: 100.00%

182 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1"""The ``examples`` action: run every selected example through its documented path.""" 

2 

3from __future__ import annotations 

4 

5import contextlib 

6import math 

7import time 

8from concurrent.futures import ThreadPoolExecutor 

9from pathlib import Path 

10from typing import Any 

11 

12import yaml 

13 

14from scripts.live_release_validation.models import ActionFailure, RunContext 

15 

16from . import drivers, kube 

17from .drivers import ExampleRunResult, ExampleValidationError 

18from .specs import COMPANION, EXAMPLE_SPECS, KUBECTL_APPLY, SCALEDJOB_SCALES 

19from .static_checks import examples_dir, parse_example, run_static_checks 

20 

21 

22def action_static(ctx: RunContext) -> dict[str, Any]: 

23 """Offline checks for the selected examples (also run standalone in CI).""" 

24 names = list(getattr(ctx.settings, "selected_examples", ()) or []) 

25 findings = run_static_checks(ctx.settings.repo_root, names or None) 

26 failed = [finding for finding in findings if not finding.passed] 

27 details = { 

28 "checked": len(findings), 

29 "failed": [ 

30 {"example": item.example, "check": item.check, "detail": item.detail} for item in failed 

31 ], 

32 } 

33 if failed: 

34 raise ActionFailure( 

35 f"{len(failed)} static example check(s) failed: {details['failed']}", details 

36 ) 

37 return details 

38 

39 

40def _capacity_skip_reason(ctx: RunContext, region: str, quota_code: str) -> str | None: 

41 """Return a skip reason when the account has zero quota for the family.""" 

42 if not quota_code: 

43 return None 

44 with drivers.BOTO_CLIENT_LOCK: 

45 client = ctx.session.client("service-quotas", region_name=region) 

46 try: 

47 quota = client.get_service_quota(ServiceCode="ec2", QuotaCode=quota_code) 

48 value = float(quota["Quota"]["Value"]) 

49 except Exception as exc: # quota lookup failing must not fail the run 

50 return f"quota {quota_code} lookup failed ({type(exc).__name__}); treating as unavailable" 

51 if value <= 0: 

52 name = quota["Quota"].get("QuotaName", quota_code) 

53 return f"account quota '{name}' is {value:g} vCPUs — no capacity for this example" 

54 return None 

55 

56 

57def _keda_operator_role_arn(kubectl: kube.KubectlRunner) -> str: 

58 """Resolve the KEDA operator's IAM role from its service-account annotation.""" 

59 for namespace in ("keda", "gco-system", "kube-system"): 

60 code, out, _ = kubectl( 

61 "get", 

62 "serviceaccount", 

63 "keda-operator", 

64 "-n", 

65 namespace, 

66 "-o", 

67 "jsonpath={.metadata.annotations.eks\\.amazonaws\\.com/role-arn}", 

68 ) 

69 if code == 0 and out.strip(): 

70 return out.strip() 

71 raise ExampleValidationError( 

72 "KEDA operator service-account role annotation not found in keda/gco-system/kube-system" 

73 ) 

74 

75 

76def _prepare_keda_manifest(parsed: Any, queue_url: str, region: str, manifest_path: Path) -> Path: 

77 """Substitute the documented placeholder queue URL with the demo queue.""" 

78 documents = [] 

79 for doc in yaml.safe_load_all(manifest_path.read_text(encoding="utf-8")): 

80 if doc and doc.get("kind") == "ScaledJob": 

81 for trigger in doc["spec"]["triggers"]: 

82 metadata = trigger.get("metadata", {}) 

83 if "queueURL" in metadata: 

84 metadata["queueURL"] = queue_url 

85 metadata["awsRegion"] = region 

86 if doc: 

87 documents.append(doc) 

88 return drivers.write_temp_manifest(documents, f"-{parsed.name}.yaml") 

89 

90 

91def _pin_argocd_revision(parsed: Any, revision: str, manifest_path: Path) -> Path: 

92 """Point every Application at the commit under validation (a disclosed mutation). 

93 

94 The shipped example tracks ``main``; the run must sync the Git path at 

95 exactly the SHA it validates, so a change to the fixture is tested before 

96 it merges. 

97 """ 

98 documents = [] 

99 for doc in yaml.safe_load_all(manifest_path.read_text(encoding="utf-8")): 

100 if doc and doc.get("kind") == "Application": 

101 doc["spec"]["source"]["targetRevision"] = revision 

102 if doc: 

103 documents.append(doc) 

104 return drivers.write_temp_manifest(documents, f"-{parsed.name}.yaml") 

105 

106 

107def _run_one_example( 

108 ctx: RunContext, 

109 name: str, 

110 region: str, 

111 kubectl: kube.KubectlRunner, 

112) -> ExampleRunResult: 

113 spec = EXAMPLE_SPECS[name] 

114 parsed = parse_example(ctx.settings.repo_root, name) 

115 started = time.monotonic() 

116 

117 if spec.submission == COMPANION: 

118 return ExampleRunResult( 

119 name=name, 

120 status="passed", 

121 submission=spec.submission, 

122 detail=f"companion artifact: {spec.notes}", 

123 ) 

124 

125 skip_reason = _capacity_skip_reason(ctx, region, spec.capacity_quota_code) 

126 if skip_reason: 

127 return ExampleRunResult( 

128 name=name, status="skipped", submission=spec.submission, detail=skip_reason 

129 ) 

130 

131 try: 

132 # A tunnel that stalled between examples is reopened before anything 

133 # is submitted; one that cannot be reopened fails the example at once 

134 # rather than letting its watchers wait out the whole timeout. 

135 kube.ensure_tunnel(kubectl) 

136 except kube.TunnelUnavailableError as exc: 

137 return ExampleRunResult( 

138 name=name, 

139 status="failed", 

140 submission=spec.submission, 

141 duration_seconds=time.monotonic() - started, 

142 detail=f"the cluster API was unreachable before the example started: {exc}"[:1500], 

143 ) 

144 

145 manifest_path, mutations = drivers.apply_mutations(parsed) 

146 evidence: dict[str, Any] = {} 

147 keda_queue: drivers.KedaDemoQueue | None = None 

148 vector_corpus: drivers.VectorDemoCorpus | None = None 

149 companion: drivers.CompanionApi | None = None 

150 ack_queues: drivers.AckSqsQueues | None = None 

151 try: 

152 if spec.setup_driver and spec.setup_driver not in drivers.KNOWN_SETUP_DRIVERS: 

153 # Fail closed: a spec naming a driver this dispatcher does not 

154 # implement must fail loudly, not run without its precondition 

155 # and report an unearned pass. 

156 raise ExampleValidationError( 

157 f"setup driver {spec.setup_driver!r} is not implemented in actions._run_one_example" 

158 ) 

159 if spec.setup_driver == "keda-demo-queue": 

160 role_arn = _keda_operator_role_arn(kubectl) 

161 keda_queue = drivers.KedaDemoQueue( 

162 session=ctx.session, region=region, run_id=ctx.settings.run_id 

163 ) 

164 evidence["setup"] = keda_queue.create(role_arn) 

165 manifest_path = _prepare_keda_manifest( 

166 parsed, keda_queue.queue_url, region, manifest_path 

167 ) 

168 mutations["ScaledJob.triggers.queueURL"] = "disposable demo queue for this run" 

169 elif spec.setup_driver == "vector-demo-corpus": 

170 # The example's documented prerequisite, run verbatim and fully 

171 # reverted in the finally below (S3 objects + chunk items). 

172 vector_corpus = drivers.VectorDemoCorpus( 

173 repo_root=ctx.settings.repo_root, session=ctx.session, region=region 

174 ) 

175 evidence["setup"] = vector_corpus.create() 

176 elif spec.setup_driver == "trainer-runtime-ready": 

177 # Readiness wait on deploy-time artifacts; nothing to revert. 

178 evidence["setup"] = drivers.wait_trainer_runtime_ready(kubectl) 

179 elif spec.setup_driver == "mlflow-ready": 

180 # Readiness wait; the tracking server may still be rolling out 

181 # right after a fresh install, and the CA bundle the client 

182 # mounts is published by trust-manager after the post-Helm 

183 # pass. Nothing to revert. 

184 evidence["setup"] = drivers.wait_mlflow_ready(kubectl) 

185 elif spec.setup_driver == "argocd-revision-pin": 

186 # Readiness wait on deploy-time artifacts, then the disclosed 

187 # revision pin; the Application itself is the example's object. 

188 evidence["setup"] = drivers.wait_argocd_ready(kubectl) 

189 manifest_path = _pin_argocd_revision(parsed, ctx.settings.expected_sha, manifest_path) 

190 mutations["Application.spec.source.targetRevision"] = ( 

191 f"{ctx.settings.expected_sha} (the commit under validation)" 

192 ) 

193 elif spec.setup_driver in {"kro-api", "crossplane-api"}: 

194 # The companion API definition goes first and is deleted after the 

195 # instance's cleanup (below, and in the finally on failure). 

196 companion = drivers.CompanionApi( 

197 flavor=spec.setup_driver.removesuffix("-api"), 

198 path=examples_dir(ctx.settings.repo_root) / f"{spec.companion}.yaml", 

199 kubectl=kubectl, 

200 ) 

201 evidence["setup"] = companion.create() 

202 elif spec.setup_driver == "ack-sqs": 

203 ack_queues = drivers.AckSqsQueues(session=ctx.session, region=region) 

204 evidence["setup"] = ack_queues.wait_ready(kubectl) 

205 

206 evidence["submission"] = drivers.submit_example( 

207 parsed, manifest_path, repo_root=ctx.settings.repo_root, region=region, kubectl=kubectl 

208 ) 

209 if spec.criteria in drivers.CRITERIA_WAITERS: 

210 evidence["criteria"] = drivers.CRITERIA_WAITERS[spec.criteria]( 

211 parsed, kubectl, timeout=spec.timeout_seconds 

212 ) 

213 if ack_queues is not None: 

214 evidence["aws"] = ack_queues.verify_created(parsed) 

215 if spec.criteria == SCALEDJOB_SCALES or spec.submission == KUBECTL_APPLY: 

216 evidence["cleanup"] = drivers.cleanup_example(parsed, manifest_path, kubectl) 

217 else: 

218 # CLI-submitted resources: delete through kubectl as well so quota 

219 # headroom is restored for the next example. 

220 evidence["cleanup"] = drivers.cleanup_example(parsed, manifest_path, kubectl) 

221 if ack_queues is not None: 

222 evidence["aws_cleanup"] = ack_queues.verify_deleted(parsed) 

223 if companion is not None: 

224 evidence["companion_cleanup"] = companion.destroy() 

225 return ExampleRunResult( 

226 name=name, 

227 status="passed", 

228 submission=spec.submission, 

229 duration_seconds=time.monotonic() - started, 

230 mutations=mutations, 

231 evidence=evidence, 

232 ) 

233 except ExampleValidationError as exc: 

234 with contextlib.suppress(ExampleValidationError): 

235 drivers.cleanup_example(parsed, manifest_path, kubectl) 

236 return ExampleRunResult( 

237 name=name, 

238 status="failed", 

239 submission=spec.submission, 

240 duration_seconds=time.monotonic() - started, 

241 detail=str(exc)[:1500], 

242 mutations=mutations, 

243 evidence=evidence, 

244 ) 

245 finally: 

246 if keda_queue is not None: 

247 keda_queue.destroy() 

248 if vector_corpus is not None: 

249 vector_corpus.destroy() 

250 if companion is not None and companion.applied: 

251 # Failure path: the instance cleanup above ran first. 

252 with contextlib.suppress(ExampleValidationError): 

253 companion.destroy() 

254 

255 

256#: What one example may spend outside its criteria wait: a setup driver's 

257#: readiness wait (up to 600 s), the submission (``gco`` allows 600 s, a DAG 

258#: run 1800 s), and cleanup (a 300 s delete plus 180 s for derived Jobs). 

259_EXAMPLE_OVERHEAD_SECONDS = 1800 

260 

261 

262def _bastion_ttl_minutes(names: list[str], workers: int) -> int: 

263 """The tunnel bastion's self-termination backstop, sized to the pending examples. 

264 

265 The bastion lives exactly as long as the session in the normal path; the 

266 TTL only stops an orphan (a harness killed outright). The bastion's 

267 two-hour default is shorter than a sequential pass over the catalog, and 

268 a bastion that terminates mid-run takes the tunnel with it, so the TTL is 

269 the worst case a thread pool can take — the total over the workers plus 

270 the longest single example — within the one day a bastion accepts. 

271 """ 

272 from cli import ephemeral_bastion 

273 

274 budgets = [EXAMPLE_SPECS[name].timeout_seconds + _EXAMPLE_OVERHEAD_SECONDS for name in names] 

275 minutes = math.ceil((sum(budgets) / workers + max(budgets)) / 60) 

276 return max( 

277 ephemeral_bastion.DEFAULT_TTL_MINUTES, min(ephemeral_bastion.MAX_TTL_MINUTES, minutes) 

278 ) 

279 

280 

281def action_examples(ctx: RunContext) -> dict[str, Any]: 

282 """Run the selected examples in parallel inside one cluster session. 

283 

284 Every example is self-contained (own workload names, own temp manifest, 

285 own cleanup), so all selected examples are submitted at once and each 

286 thread drives its example's full documented flow: submit, wait on the 

287 success criteria, clean up. GPU node provisioning and image pulls — the 

288 dominant wall-clock costs — overlap instead of serializing. Transient 

289 ``exceeded quota`` admission rejections while peers hold the namespace 

290 quota are expected and retried by the Job controller (the fail-fast in 

291 ``drivers`` deliberately exempts them). ``max_parallel_examples`` 

292 throttles the pool; 0 means all selected examples at once. 

293 """ 

294 selected = list(getattr(ctx.settings, "selected_examples", ()) or EXAMPLE_SPECS) 

295 region = ctx.deployment_regions[0] 

296 cluster_name = f"{ctx.config.project_name}-{region}" 

297 state: dict[str, Any] = ctx.checkpoint.state.setdefault("examples", {}) 

298 

299 results: dict[str, ExampleRunResult] = {} 

300 pending: list[str] = [] 

301 for name in selected: 

302 previous = state.get(name) 

303 if isinstance(previous, dict) and previous.get("status") == "passed": 

304 results[name] = ExampleRunResult( 

305 name=name, 

306 status="passed", 

307 submission=str(previous.get("submission", "")), 

308 detail="checkpoint: already passed in this run", 

309 ) 

310 else: 

311 pending.append(name) 

312 

313 def run_example(name: str, kubectl: kube.KubectlRunner) -> None: 

314 print(f"[example] {name} ({EXAMPLE_SPECS[name].submission}) started") 

315 result = _run_one_example(ctx, name, region, kubectl) 

316 with ctx.state_lock: 

317 results[name] = result 

318 state[name] = result.to_dict() 

319 ctx.persist() 

320 print(f"[example] {name}: {result.status} ({result.duration_seconds:.1f}s)") 

321 

322 limit = int(getattr(ctx.settings, "max_parallel_examples", 0) or 0) 

323 workers = min(len(pending), limit) if limit > 0 else len(pending) 

324 tunnel: dict[str, Any] | None = None 

325 if pending: 

326 tunnel = {"bastion_ttl_minutes": _bastion_ttl_minutes(pending, workers), "reopens": []} 

327 # The run's own kubeconfig in its private report dir, as in the release 

328 # harness. On the default kubeconfig a KUBECONFIG exported by the 

329 # operator's shell split the two: `aws eks update-kubeconfig` and 

330 # kubectl used that file while the tunnel was pinned in 

331 # ~/.kube/config, and two live runs timed out on the private endpoint 

332 # before their first example. It also leaves ~/.kube/config alone. 

333 with kube.cluster_session( 

334 ctx.settings.repo_root, 

335 cluster_name, 

336 region, 

337 kubeconfig_path=ctx.settings.kubeconfig_path, 

338 bastion_ttl_minutes=tunnel["bastion_ttl_minutes"], 

339 tunnel_events=tunnel["reopens"], 

340 ) as kubectl: 

341 if workers == 1: 

342 for name in pending: 

343 run_example(name, kubectl) 

344 else: 

345 with ThreadPoolExecutor(max_workers=workers, thread_name_prefix="example") as pool: 

346 futures = {pool.submit(run_example, name, kubectl): name for name in pending} 

347 for future, name in futures.items(): 

348 # Surface unexpected (non-validation) errors with the 

349 # example's name; ExampleValidationError is already 

350 # converted to a failed result inside the thread. 

351 try: 

352 future.result() 

353 except Exception as exc: 

354 raise RuntimeError(f"example {name} crashed: {exc}") from exc 

355 

356 ordered = [results[name] for name in selected if name in results] 

357 summary = { 

358 "region": region, 

359 "results": [result.to_dict() for result in ordered], 

360 "max_parallel": workers, 

361 "passed": sum(1 for item in ordered if item.status == "passed"), 

362 "skipped": sum(1 for item in ordered if item.status == "skipped"), 

363 "failed": sum(1 for item in ordered if item.status == "failed"), 

364 } 

365 if tunnel is not None: 

366 # Every reopen the tunnel keeper attempted, so a pass that needed one 

367 # says so, and the bastion lifetime the run asked for. 

368 summary["tunnel"] = tunnel 

369 ctx.checkpoint.state["examples_summary"] = summary 

370 ctx.persist() 

371 if summary["failed"]: 

372 failed_names = [item.name for item in ordered if item.status == "failed"] 

373 # The summary rides on the exception so the failed action's report 

374 # row carries every example's result, as a passing run's does. 

375 raise ActionFailure( 

376 f"{summary['failed']} example(s) failed: {', '.join(failed_names)} " 

377 "(per-example results are in this action's details in the JSON report)", 

378 summary, 

379 ) 

380 return summary