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

336 statements  

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

1"""Submission, success-criteria, setup, and cleanup drivers for one example. 

2 

3Every driver takes the parsed example plus the run plumbing and returns 

4evidence dictionaries for the report. Submission always travels the 

5DOCUMENTED path (the real ``gco`` CLI or ``kubectl apply``); any deliberate 

6manifest mutation (spec.mutations) is applied to a disclosed temp copy. 

7""" 

8 

9from __future__ import annotations 

10 

11import json 

12import subprocess 

13import tempfile 

14import threading 

15import time 

16from dataclasses import dataclass, field 

17from pathlib import Path 

18from typing import Any 

19 

20import yaml 

21 

22from .kube import KubectlRunner 

23from .specs import ( 

24 DAG_RUN, 

25 DEPLOYMENT_AVAILABLE, 

26 JOB_COMPLETES, 

27 KUBECTL_APPLY, 

28 RAYCLUSTER_READY, 

29 SCALEDJOB_SCALES, 

30 SUBMIT_API, 

31 SUBMIT_DIRECT, 

32 SUBMIT_SQS, 

33 TRAINJOB_COMPLETES, 

34 VCJOB_COMPLETES, 

35) 

36from .static_checks import ParsedExample 

37 

38#: boto3 Session.client() is not thread-safe (client creation mutates shared 

39#: loader state); every client creation against the run's shared session must 

40#: hold this lock when examples run in parallel. The created clients ARE safe 

41#: to use concurrently. 

42BOTO_CLIENT_LOCK = threading.Lock() 

43 

44_POLL_SECONDS = 15 

45 

46 

47class ExampleValidationError(RuntimeError): 

48 """One example failed its criteria; the message carries the evidence.""" 

49 

50 

51@dataclass 

52class ExampleRunResult: 

53 """Evidence for one example's live validation.""" 

54 

55 name: str 

56 status: str # passed | failed | skipped 

57 submission: str 

58 duration_seconds: float = 0.0 

59 detail: str = "" 

60 mutations: dict[str, str] = field(default_factory=dict) 

61 evidence: dict[str, Any] = field(default_factory=dict) 

62 

63 def to_dict(self) -> dict[str, Any]: 

64 return { 

65 "name": self.name, 

66 "status": self.status, 

67 "submission": self.submission, 

68 "duration_seconds": round(self.duration_seconds, 3), 

69 "detail": self.detail, 

70 "mutations": self.mutations, 

71 "evidence": self.evidence, 

72 } 

73 

74 

75def _run_cli(args: list[str], repo_root: Path, timeout: int = 600) -> tuple[int, str, str]: 

76 result = subprocess.run(args, cwd=repo_root, capture_output=True, text=True, timeout=timeout) 

77 return result.returncode, result.stdout, result.stderr 

78 

79 

80def write_temp_manifest(documents: list[dict[str, Any]], suffix: str) -> Path: 

81 """Write documents to a private temp file and return its path.""" 

82 fd, name = tempfile.mkstemp(suffix=suffix, text=True) 

83 with open(fd, "w", encoding="utf-8") as fh: 

84 yaml.safe_dump_all(documents, fh) 

85 return Path(name) 

86 

87 

88def apply_mutations(parsed: ParsedExample) -> tuple[Path, dict[str, str]]: 

89 """Materialize the manifest to submit: verbatim, or a disclosed mutated copy. 

90 

91 Mutation keys use the shape ``Deployment.env.NAME`` (replace that env 

92 var's value) or ``Deployment.args.--flag`` (replace the argv element 

93 following the flag). Anything else is a spec bug and raises. 

94 """ 

95 if not parsed.spec.mutations: 

96 return parsed.path, {} 

97 from .specs import REMOVE_VALUE 

98 

99 documents = [dict(doc) for doc in parsed.documents] 

100 for key, replacement in parsed.spec.mutations.items(): 

101 kind, channel, target = key.split(".", 2) 

102 for doc in documents: 

103 if doc.get("kind") != kind: 

104 continue 

105 containers = doc["spec"]["template"]["spec"]["containers"] 

106 for container in containers: 

107 if channel == "env": 

108 env_entries = container.get("env", []) 

109 if replacement == REMOVE_VALUE: 

110 container["env"] = [ 

111 entry for entry in env_entries if entry.get("name") != target 

112 ] 

113 continue 

114 for env_entry in env_entries: 

115 if env_entry.get("name") == target: 

116 env_entry["value"] = replacement 

117 elif channel == "args": 

118 args = container.get("args", []) 

119 for index, value in enumerate(args): 

120 if value == target and index + 1 < len(args): 

121 args[index + 1] = replacement 

122 else: 

123 raise ValueError(f"Unsupported mutation channel in {key!r}") 

124 return ( 

125 write_temp_manifest(documents, f"-{parsed.name}.yaml"), 

126 dict(parsed.spec.mutations), 

127 ) 

128 

129 

130def submit_example( 

131 parsed: ParsedExample, 

132 manifest_path: Path, 

133 *, 

134 repo_root: Path, 

135 region: str, 

136 kubectl: KubectlRunner, 

137) -> dict[str, Any]: 

138 """Submit via the documented path; returns submission evidence.""" 

139 spec = parsed.spec 

140 if spec.submission == SUBMIT_DIRECT: 

141 args = ["gco", "jobs", "submit-direct", str(manifest_path), "-r", region] 

142 elif spec.submission == SUBMIT_SQS: 

143 args = ["gco", "jobs", "submit-sqs", str(manifest_path), "--region", region] 

144 elif spec.submission == SUBMIT_API: 

145 args = ["gco", "jobs", "submit", str(manifest_path), "--region", region] 

146 elif spec.submission == DAG_RUN: 

147 args = ["gco", "dag", "run", str(manifest_path), "-r", region] 

148 elif spec.submission == KUBECTL_APPLY: 

149 code, out, err = kubectl("apply", "-f", str(manifest_path)) 

150 if code != 0: 

151 raise ExampleValidationError(f"kubectl apply failed: {err.strip()[:800]}") 

152 return {"command": f"kubectl apply -f examples/{parsed.name}.yaml", "output": out.strip()} 

153 else: 

154 raise ExampleValidationError(f"No live submission for {spec.submission}") 

155 

156 timeout = 1800 if spec.submission == DAG_RUN else 600 

157 code, out, err = _run_cli(args, repo_root, timeout=timeout) 

158 if code != 0: 

159 raise ExampleValidationError( 

160 f"{' '.join(args[:3])} failed (exit {code}): {(err or out).strip()[:800]}" 

161 ) 

162 return {"command": " ".join(args[:3]) + f" examples/{parsed.name}.yaml", "output": out[-1500:]} 

163 

164 

165# -------------------------------------------------------------------------- 

166# success criteria 

167# -------------------------------------------------------------------------- 

168 

169 

170def _workload_documents(parsed: ParsedExample, kinds: set[str]) -> list[dict[str, Any]]: 

171 return [doc for doc in parsed.documents if doc.get("kind") in kinds] 

172 

173 

174def _job_status(kubectl: KubectlRunner, namespace: str, name: str) -> tuple[str, str]: 

175 code, out, _ = kubectl("get", "job", name, "-n", namespace, "-o", "json") 

176 if code != 0: 

177 return "missing", "" 

178 payload = json.loads(out) 

179 for condition in payload.get("status", {}).get("conditions", []) or []: 

180 if condition.get("type") == "Complete" and condition.get("status") == "True": 

181 return "complete", "" 

182 if condition.get("type") == "Failed" and condition.get("status") == "True": 

183 return "failed", str(condition.get("message", "")) 

184 return "running", "" 

185 

186 

187def _pod_diagnostics(kubectl: KubectlRunner, namespace: str, selector: str) -> str: 

188 _, out, _ = kubectl( 

189 "get", 

190 "pods", 

191 "-n", 

192 namespace, 

193 "-l", 

194 selector, 

195 "-o", 

196 "jsonpath={range .items[*]}{.metadata.name}={.status.phase} {end}", 

197 ) 

198 return out.strip() 

199 

200 

201def _job_admission_rejection(kubectl: KubectlRunner, namespace: str, name: str) -> str | None: 

202 """Return the rejection message when the Job's pods are forbidden. 

203 

204 A LimitRange or ResourceQuota rejection never becomes a Job condition: 

205 the controller retries pod creation forever, the Job stays podless, and 

206 the only signal is ``FailedCreate ... forbidden`` namespace events. 

207 Waiting the full example timeout on such a job is pure burn (observed 

208 live: example-job validation run ex241-df723811, 40 minutes against the 

209 old per-container GPU ceiling) — surface the event message immediately. 

210 ResourceQuota rejections (``exceeded quota``) are the one retriable 

211 shape: under parallel example submission the namespace quota is 

212 transiently full, the Job controller retries pod creation, and the pods 

213 land once peers finish. Never fail fast on those; each message is 

214 evaluated separately so a transient quota event cannot mask a permanent 

215 LimitRange rejection emitted for the same Job. 

216 """ 

217 code, out, _ = kubectl( 

218 "get", 

219 "events", 

220 "-n", 

221 namespace, 

222 "--field-selector", 

223 f"involvedObject.kind=Job,involvedObject.name={name},reason=FailedCreate", 

224 "-o", 

225 'jsonpath={range .items[*]}{.message}{"\\n"}{end}', 

226 timeout=60, 

227 ) 

228 if code != 0: 

229 return None 

230 for message in out.splitlines(): 

231 if "forbidden" in message and "exceeded quota" not in message: 

232 return message[-600:] 

233 return None 

234 

235 

236def wait_jobs_complete( 

237 parsed: ParsedExample, kubectl: KubectlRunner, *, timeout: int 

238) -> dict[str, Any]: 

239 """Every batch/v1 Job in the example must reach Complete.""" 

240 jobs = [ 

241 ((doc.get("metadata") or {}).get("namespace", "gco-jobs"), doc["metadata"]["name"]) 

242 for doc in _workload_documents(parsed, {"Job"}) 

243 ] 

244 if not jobs: 

245 raise ExampleValidationError("spec says job-completes but the file defines no Jobs") 

246 deadline = time.monotonic() + timeout 

247 pending = dict.fromkeys(jobs, "unknown") 

248 while time.monotonic() < deadline: 

249 for namespace, name in jobs: 

250 state, message = _job_status(kubectl, namespace, name) 

251 pending[(namespace, name)] = state 

252 if state == "failed": 

253 _, logs, _ = kubectl( 

254 "logs", f"job/{name}", "-n", namespace, "--tail", "40", timeout=60 

255 ) 

256 raise ExampleValidationError( 

257 f"Job {namespace}/{name} failed: {message} :: last logs: {logs[-800:]}" 

258 ) 

259 rejection = _job_admission_rejection(kubectl, namespace, name) 

260 if rejection is not None: 

261 raise ExampleValidationError( 

262 f"Job {namespace}/{name} pods are rejected at admission and can never " 

263 f"run: {rejection}" 

264 ) 

265 if all(state == "complete" for state in pending.values()): 

266 return {"jobs": {f"{ns}/{name}": "complete" for (ns, name) in jobs}} 

267 time.sleep(_POLL_SECONDS) 

268 detail = ", ".join(f"{ns}/{name}={state}" for (ns, name), state in pending.items()) 

269 pods = _pod_diagnostics(kubectl, jobs[0][0], f"job-name={jobs[0][1]}") 

270 raise ExampleValidationError(f"timeout after {timeout}s: {detail}; pods: {pods}") 

271 

272 

273def wait_deployment_available( 

274 parsed: ParsedExample, kubectl: KubectlRunner, *, timeout: int 

275) -> dict[str, Any]: 

276 """The Deployment must report Available and its Service must have endpoints.""" 

277 deployments = _workload_documents(parsed, {"Deployment"}) 

278 services = _workload_documents(parsed, {"Service"}) 

279 if not deployments: 

280 raise ExampleValidationError("spec says deployment-available but no Deployment found") 

281 namespace = deployments[0]["metadata"].get("namespace", "gco-inference") 

282 name = deployments[0]["metadata"]["name"] 

283 code, _, err = kubectl( 

284 "wait", 

285 f"deployment/{name}", 

286 "-n", 

287 namespace, 

288 "--for", 

289 "condition=Available", 

290 f"--timeout={timeout}s", 

291 timeout=timeout + 60, 

292 ) 

293 if code != 0: 

294 pods = _pod_diagnostics(kubectl, namespace, f"app={name}") 

295 _, describe, _ = kubectl("describe", f"deployment/{name}", "-n", namespace, timeout=60) 

296 raise ExampleValidationError( 

297 f"Deployment {namespace}/{name} never became Available: {err.strip()[:300]}; " 

298 f"pods: {pods}; describe tail: {describe[-600:]}" 

299 ) 

300 evidence: dict[str, Any] = {"deployment": f"{namespace}/{name}=Available"} 

301 if services: 

302 service_name = services[0]["metadata"]["name"] 

303 _, endpoints, _ = kubectl( 

304 "get", 

305 "endpoints", 

306 service_name, 

307 "-n", 

308 namespace, 

309 "-o", 

310 "jsonpath={.subsets[*].addresses[*].ip}", 

311 ) 

312 if not endpoints.strip(): 

313 raise ExampleValidationError(f"Service {namespace}/{service_name} has no endpoints") 

314 evidence["service_endpoints"] = endpoints.strip() 

315 return evidence 

316 

317 

318def wait_raycluster_ready( 

319 parsed: ParsedExample, kubectl: KubectlRunner, *, timeout: int 

320) -> dict[str, Any]: 

321 clusters = _workload_documents(parsed, {"RayCluster"}) 

322 namespace = clusters[0]["metadata"].get("namespace", "gco-jobs") 

323 name = clusters[0]["metadata"]["name"] 

324 min_workers = int(clusters[0]["spec"]["workerGroupSpecs"][0].get("minReplicas", 1)) 

325 deadline = time.monotonic() + timeout 

326 while time.monotonic() < deadline: 

327 code, out, _ = kubectl("get", "raycluster", name, "-n", namespace, "-o", "json") 

328 if code == 0: 

329 status = json.loads(out).get("status", {}) 

330 state = str(status.get("state", "")) 

331 ready_workers = int(status.get("readyWorkerReplicas", 0) or 0) 

332 if state.lower() == "ready" and ready_workers >= min_workers: 

333 return { 

334 "raycluster": f"{namespace}/{name}", 

335 "state": state, 

336 "ready_workers": ready_workers, 

337 } 

338 time.sleep(_POLL_SECONDS) 

339 _, describe, _ = kubectl("describe", "raycluster", name, "-n", namespace, timeout=60) 

340 raise ExampleValidationError( 

341 f"RayCluster {namespace}/{name} not ready after {timeout}s; tail: {describe[-600:]}" 

342 ) 

343 

344 

345def wait_vcjob_completes( 

346 parsed: ParsedExample, kubectl: KubectlRunner, *, timeout: int 

347) -> dict[str, Any]: 

348 jobs = _workload_documents(parsed, {"Job"}) 

349 volcano_jobs = [ 

350 doc for doc in jobs if str(doc.get("apiVersion", "")).startswith("batch.volcano") 

351 ] 

352 namespace = volcano_jobs[0]["metadata"].get("namespace", "gco-jobs") 

353 name = volcano_jobs[0]["metadata"]["name"] 

354 deadline = time.monotonic() + timeout 

355 while time.monotonic() < deadline: 

356 code, out, _ = kubectl("get", "vcjob", name, "-n", namespace, "-o", "json") 

357 if code == 0: 

358 phase = str(json.loads(out).get("status", {}).get("state", {}).get("phase", "")) 

359 if phase == "Completed": 

360 return {"vcjob": f"{namespace}/{name}", "phase": phase} 

361 if phase in {"Failed", "Aborted", "Terminated"}: 

362 raise ExampleValidationError(f"vcjob {namespace}/{name} reached phase {phase}") 

363 time.sleep(_POLL_SECONDS) 

364 raise ExampleValidationError(f"vcjob {namespace}/{name} did not complete within {timeout}s") 

365 

366 

367def wait_trainjob_completes( 

368 parsed: ParsedExample, kubectl: KubectlRunner, *, timeout: int 

369) -> dict[str, Any]: 

370 """Kubeflow TrainJob must reach condition Complete (Failed is terminal). 

371 

372 Evidence carries the terminal condition plus the per-child-Job counts 

373 from status.jobsStatus so the report shows the gang actually ran 

374 (numNodes pods succeeded), not merely that a condition flipped. 

375 """ 

376 trainjobs = _workload_documents(parsed, {"TrainJob"}) 

377 namespace = trainjobs[0]["metadata"].get("namespace", "gco-jobs") 

378 name = trainjobs[0]["metadata"]["name"] 

379 deadline = time.monotonic() + timeout 

380 while time.monotonic() < deadline: 

381 code, out, _ = kubectl("get", "trainjob", name, "-n", namespace, "-o", "json") 

382 if code == 0: 

383 status = json.loads(out).get("status", {}) or {} 

384 jobs_status = status.get("jobsStatus", []) or [] 

385 for condition in status.get("conditions", []) or []: 

386 if condition.get("status") != "True": 

387 continue 

388 if condition.get("type") == "Complete": 

389 return { 

390 "trainjob": f"{namespace}/{name}", 

391 "condition": "Complete", 

392 "jobsStatus": jobs_status, 

393 } 

394 if condition.get("type") == "Failed": 

395 raise ExampleValidationError( 

396 f"TrainJob {namespace}/{name} reached condition Failed: " 

397 f"{condition.get('message', '')}" 

398 ) 

399 time.sleep(_POLL_SECONDS) 

400 _, describe, _ = kubectl("describe", "trainjob", name, "-n", namespace, timeout=60) 

401 raise ExampleValidationError( 

402 f"TrainJob {namespace}/{name} did not complete within {timeout}s; tail: {describe[-600:]}" 

403 ) 

404 

405 

406def wait_scaledjob_scales( 

407 parsed: ParsedExample, kubectl: KubectlRunner, *, timeout: int 

408) -> dict[str, Any]: 

409 """KEDA must spawn at least one Job for the ScaledJob from queue depth.""" 

410 scaled = _workload_documents(parsed, {"ScaledJob"}) 

411 namespace = scaled[0]["metadata"].get("namespace", "gco-jobs") 

412 name = scaled[0]["metadata"]["name"] 

413 deadline = time.monotonic() + timeout 

414 while time.monotonic() < deadline: 

415 code, out, _ = kubectl( 

416 "get", 

417 "jobs", 

418 "-n", 

419 namespace, 

420 "-l", 

421 f"scaledjob.keda.sh/name={name}", 

422 "-o", 

423 "jsonpath={.items[*].metadata.name}", 

424 ) 

425 spawned = [item for item in out.split() if item] 

426 if code == 0 and spawned: 

427 return {"scaledjob": f"{namespace}/{name}", "spawned_jobs": spawned[:5]} 

428 time.sleep(_POLL_SECONDS) 

429 _, describe, _ = kubectl("describe", "scaledjob", name, "-n", namespace, timeout=60) 

430 raise ExampleValidationError( 

431 f"ScaledJob {namespace}/{name} spawned no Jobs within {timeout}s; tail: {describe[-600:]}" 

432 ) 

433 

434 

435CRITERIA_WAITERS = { 

436 JOB_COMPLETES: wait_jobs_complete, 

437 DEPLOYMENT_AVAILABLE: wait_deployment_available, 

438 RAYCLUSTER_READY: wait_raycluster_ready, 

439 VCJOB_COMPLETES: wait_vcjob_completes, 

440 SCALEDJOB_SCALES: wait_scaledjob_scales, 

441 TRAINJOB_COMPLETES: wait_trainjob_completes, 

442} 

443 

444 

445# -------------------------------------------------------------------------- 

446# cleanup 

447# -------------------------------------------------------------------------- 

448 

449 

450def cleanup_example( 

451 parsed: ParsedExample, manifest_path: Path, kubectl: KubectlRunner 

452) -> dict[str, Any]: 

453 """Delete everything the example created and verify it is gone.""" 

454 if parsed.spec.submission == DAG_RUN: 

455 # A DAG example's file is a pipeline SPEC, not a Kubernetes manifest 

456 # (kubectl cannot decode it — observed live in run ex241-4bf01801); 

457 # what actually ran on the cluster are the step manifests it names. 

458 repo_root = parsed.path.parent.parent 

459 deleted: list[str] = [] 

460 for document in parsed.documents: 

461 for step in document.get("steps", []): 

462 step_manifest = repo_root / str(step.get("manifest", "")) 

463 code, out, err = kubectl( 

464 "delete", 

465 "-f", 

466 str(step_manifest), 

467 "--ignore-not-found", 

468 "--wait=true", 

469 timeout=300, 

470 ) 

471 if code != 0: 

472 raise ExampleValidationError( 

473 f"cleanup failed for {parsed.name} step " 

474 f"{step.get('name', '?')}: {err.strip()[:500]}" 

475 ) 

476 deleted.extend(line for line in out.strip().splitlines() if line) 

477 return {"deleted": deleted[:20]} 

478 code, out, err = kubectl( 

479 "delete", "-f", str(manifest_path), "--ignore-not-found", "--wait=true", timeout=300 

480 ) 

481 if code != 0: 

482 raise ExampleValidationError(f"cleanup failed for {parsed.name}: {err.strip()[:500]}") 

483 return {"deleted": [line for line in out.strip().splitlines() if line][:20]} 

484 

485 

486# -------------------------------------------------------------------------- 

487# setup drivers (spec.setup_driver) 

488# -------------------------------------------------------------------------- 

489 

490 

491@dataclass 

492class KedaDemoQueue: 

493 """Disposable SQS queue backing the KEDA scaling demonstration. 

494 

495 Implements the example's documented prerequisites: a demo queue with 

496 synthetic messages and read-only queue-metric access for the KEDA 

497 operator (granted with a queue policy, never by touching IAM roles). 

498 """ 

499 

500 session: Any 

501 region: str 

502 run_id: str 

503 queue_url: str = "" 

504 queue_arn: str = "" 

505 

506 def _sqs(self) -> Any: 

507 # boto3 Session.client() is not thread-safe; examples may run in 

508 # parallel threads. The returned client is safe to use concurrently. 

509 with BOTO_CLIENT_LOCK: 

510 return self.session.client("sqs", region_name=self.region) 

511 

512 def create(self, operator_role_arn: str) -> dict[str, Any]: 

513 sqs = self._sqs() 

514 name = f"gco-keda-demo-{self.run_id}"[:80] 

515 self.queue_url = sqs.create_queue(QueueName=name)["QueueUrl"] 

516 attrs = sqs.get_queue_attributes(QueueUrl=self.queue_url, AttributeNames=["QueueArn"]) 

517 self.queue_arn = attrs["Attributes"]["QueueArn"] 

518 policy = { 

519 "Version": "2012-10-17", 

520 "Statement": [ 

521 { 

522 "Sid": "KedaOperatorQueueMetrics", 

523 "Effect": "Allow", 

524 "Principal": {"AWS": operator_role_arn}, 

525 "Action": ["sqs:GetQueueAttributes", "sqs:GetQueueUrl"], 

526 "Resource": self.queue_arn, 

527 } 

528 ], 

529 } 

530 sqs.set_queue_attributes(QueueUrl=self.queue_url, Attributes={"Policy": json.dumps(policy)}) 

531 for index in range(10): 

532 sqs.send_message(QueueUrl=self.queue_url, MessageBody=f"demo-{index}") 

533 return {"queue_arn": self.queue_arn, "seeded_messages": 10} 

534 

535 def destroy(self) -> None: 

536 if self.queue_url: 

537 self._sqs().delete_queue(QueueUrl=self.queue_url) 

538 

539 

540@dataclass 

541class VectorDemoCorpus: 

542 """Demo-corpus precondition for the vector-search example, fully reverted. 

543 

544 ``create`` runs the example's documented prerequisite verbatim — 

545 ``gco vector ingest --demo --wait`` — and records exactly which corpus 

546 objects the CLI uploaded. ``destroy`` reverts precisely those: the S3 

547 objects (whose upload is what triggered ingestion) and every DynamoDB 

548 chunk item whose ``source`` is one of the recorded keys, resolved with 

549 the same filtered-Scan shape the CLI's ingest wait uses (the table has 

550 no by-source key schema; corpora are document-scale). Scoping deletion 

551 to the recorded keys means a pre-existing user corpus in the same table 

552 is never touched. 

553 """ 

554 

555 repo_root: Path 

556 session: Any 

557 region: str 

558 uploaded: list[str] = field(default_factory=list) 

559 bucket: str = "" 

560 

561 def create(self) -> dict[str, Any]: 

562 code, out, err = _run_cli( 

563 ["gco", "vector", "ingest", "--demo", "--wait", "--output", "json"], 

564 self.repo_root, 

565 timeout=900, 

566 ) 

567 if code != 0: 

568 raise ExampleValidationError( 

569 f"gco vector ingest --demo --wait failed (exit {code}): " 

570 f"{(err or out).strip()[:800]}" 

571 ) 

572 try: 

573 summary = json.loads(out) 

574 except ValueError as exc: 

575 raise ExampleValidationError( 

576 f"gco vector ingest emitted non-JSON output: {out[:400]}" 

577 ) from exc 

578 self.bucket = str(summary.get("bucket", "")) 

579 self.uploaded = [str(key) for key in summary.get("uploaded", [])] 

580 if not self.bucket or not self.uploaded: 

581 raise ExampleValidationError( 

582 f"ingest summary carried no bucket/keys to revert later: {out[:400]}" 

583 ) 

584 return { 

585 "command": "gco vector ingest --demo --wait", 

586 "bucket": self.bucket, 

587 "uploaded": self.uploaded, 

588 "chunks_by_source": summary.get("chunks_by_source", {}), 

589 } 

590 

591 def destroy(self) -> None: 

592 if not self.uploaded: 

593 return 

594 from cli.vector_store import VectorStoreClient 

595 

596 client = VectorStoreClient(query_region=self.region) 

597 table_name = client._resolve_table_name() 

598 bucket_name, bucket_region = client._resolve_bucket() 

599 if bucket_name != self.bucket: 

600 # Fail loudly rather than delete from a bucket other than the 

601 # one create() actually uploaded to. 

602 raise ExampleValidationError( 

603 f"corpus bucket changed between ingest ({self.bucket}) and " 

604 f"revert ({bucket_name}); refusing to delete" 

605 ) 

606 with BOTO_CLIENT_LOCK: 

607 dynamodb = self.session.client("dynamodb", region_name=self.region) 

608 s3 = self.session.client("s3", region_name=bucket_region) 

609 

610 # Chunk items first (their source keys reference the S3 objects), 

611 # then the objects themselves. Deleting the objects does NOT 

612 # un-ingest — the notification only fires on creates — hence the 

613 # explicit item sweep. 

614 for key in self.uploaded: 

615 doc_ids: list[str] = [] 

616 scan_kwargs: dict[str, Any] = { 

617 "TableName": table_name, 

618 "FilterExpression": "#source = :source", 

619 "ExpressionAttributeNames": {"#source": "source"}, 

620 "ExpressionAttributeValues": {":source": {"S": key}}, 

621 "ProjectionExpression": "doc_id", 

622 } 

623 while True: 

624 page = dynamodb.scan(**scan_kwargs) 

625 doc_ids.extend( 

626 item["doc_id"]["S"] for item in page.get("Items", []) if "doc_id" in item 

627 ) 

628 last_key = page.get("LastEvaluatedKey") 

629 if not last_key: 

630 break 

631 scan_kwargs["ExclusiveStartKey"] = last_key 

632 for start in range(0, len(doc_ids), 25): 

633 batch = doc_ids[start : start + 25] 

634 dynamodb.batch_write_item( 

635 RequestItems={ 

636 table_name: [ 

637 {"DeleteRequest": {"Key": {"doc_id": {"S": doc_id}}}} 

638 for doc_id in batch 

639 ] 

640 } 

641 ) 

642 s3.delete_object(Bucket=bucket_name, Key=key) 

643 

644 

645def wait_trainer_runtime_ready(kubectl: KubectlRunner, *, timeout: int = 300) -> dict[str, Any]: 

646 """Wait until the TrainJob CRD is served and the shipped runtime exists. 

647 

648 The trainer chart installs the CRDs and controller; the post-Helm 

649 kubectl pass applies the torch-distributed ClusterTrainingRuntime the 

650 example's ``runtimeRef`` names. Both are deploy-time artifacts, so this 

651 is a readiness wait, not created state — nothing to revert. 

652 """ 

653 deadline = time.monotonic() + timeout 

654 last_error = "" 

655 while True: 

656 code, _, err = kubectl("get", "crd", "trainjobs.trainer.kubeflow.org") 

657 if code != 0: 

658 last_error = f"TrainJob CRD not present: {err.strip()[:300]}" 

659 else: 

660 code, out, err = kubectl( 

661 "get", "clustertrainingruntime", "torch-distributed", "-o", "json" 

662 ) 

663 if code == 0: 

664 runtime = json.loads(out) 

665 return { 

666 "crd": "trainjobs.trainer.kubeflow.org", 

667 "runtime": runtime["metadata"]["name"], 

668 "runtime_created": runtime["metadata"].get("creationTimestamp", ""), 

669 } 

670 last_error = f"torch-distributed runtime not present: {err.strip()[:300]}" 

671 if time.monotonic() >= deadline: 

672 break 

673 time.sleep(_POLL_SECONDS) 

674 raise ExampleValidationError( 

675 f"Kubeflow Trainer runtime not ready within {timeout}s — is " 

676 f"helm.kubeflow_trainer enabled? Last error: {last_error}" 

677 ) 

678 

679 

680def wait_mlflow_ready(kubectl: KubectlRunner, *, timeout: int = 600) -> dict[str, Any]: 

681 """Wait until the MLflow tracking server Deployment is Available. 

682 

683 The example's client job fails its read-back (or hangs on connect) if 

684 it races the server's first rollout — the backend PVC arrives one 

685 applier pass after the chart on a fresh install. Readiness wait only; 

686 nothing to revert. 

687 """ 

688 deadline = time.monotonic() + timeout 

689 last_state = "" 

690 while True: 

691 code, out, err = kubectl("get", "deployment", "mlflow", "-n", "monitoring", "-o", "json") 

692 if code != 0: 

693 last_state = f"mlflow Deployment not found: {err.strip()[:300]}" 

694 else: 

695 payload = json.loads(out) 

696 conditions = payload.get("status", {}).get("conditions", []) or [] 

697 available = any( 

698 condition.get("type") == "Available" and condition.get("status") == "True" 

699 for condition in conditions 

700 ) 

701 if available: 

702 return { 

703 "deployment": "monitoring/mlflow", 

704 "ready_replicas": payload.get("status", {}).get("readyReplicas", 0), 

705 } 

706 last_state = f"conditions: {json.dumps(conditions)[:400]}" 

707 if time.monotonic() >= deadline: 

708 break 

709 time.sleep(_POLL_SECONDS) 

710 raise ExampleValidationError( 

711 f"MLflow tracking server not Available within {timeout}s — is " 

712 f"cluster_observability.mlflow enabled? Last state: {last_state}" 

713 ) 

714 

715 

716#: Setup drivers _run_one_example knows how to dispatch, by spec.setup_driver 

717#: name. Kept as an explicit registry so a spec naming a driver that does not 

718#: exist fails the registry pin test, not a live run. 

719KNOWN_SETUP_DRIVERS = frozenset( 

720 { 

721 "keda-demo-queue", 

722 "vector-demo-corpus", 

723 "trainer-runtime-ready", 

724 "mlflow-ready", 

725 } 

726)