Coverage for cli / jobs.py: 100.00%

537 statements  

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

1""" 

2Job management for GCO CLI. 

3 

4Provides functionality to submit, query, and manage jobs across GCO clusters. 

5""" 

6 

7from collections.abc import Callable, Mapping 

8from dataclasses import dataclass, field 

9from datetime import UTC, datetime 

10from pathlib import Path 

11from typing import Any 

12from urllib.parse import quote 

13 

14import requests 

15import yaml 

16 

17from gco.services.manifest_processor import TRAINJOB_API_VERSION, safe_load_all_yaml 

18 

19from .aws_client import get_aws_client 

20from .config import GCOConfig, get_config 

21 

22# <pyflowchart-code-diagram> BEGIN - auto-inserted, do not edit 

23# Generated at (UTC): 2026-09-03T18:56:22Z 

24# Generated from Git commit: 37fd4384775eeebf18fea3e5e085cef9645077be 

25# Flowchart(s) generated from this file: 

26# * ``JobManager.submit_job`` -> ``diagrams/code_diagrams/cli/jobs.JobManager_submit_job.html`` 

27# (PNG: ``diagrams/code_diagrams/cli/jobs.JobManager_submit_job.png``) 

28# * ``JobManager.submit_job_sqs`` -> ``diagrams/code_diagrams/cli/jobs.JobManager_submit_job_sqs.html`` 

29# (PNG: ``diagrams/code_diagrams/cli/jobs.JobManager_submit_job_sqs.png``) 

30# * ``JobManager.get_job_logs`` -> ``diagrams/code_diagrams/cli/jobs.JobManager_get_job_logs.html`` 

31# (PNG: ``diagrams/code_diagrams/cli/jobs.JobManager_get_job_logs.png``) 

32# Regenerate with ``SOURCE_DATE_EPOCH=<unix-seconds> GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/generate.py --code-only``. 

33# <pyflowchart-code-diagram> END 

34 

35 

36logger = __import__("logging").getLogger(__name__) 

37 

38 

39def _format_duration(seconds: int) -> str: 

40 """Format seconds into a human-readable duration string.""" 

41 if seconds < 60: 

42 return f"{seconds}s" 

43 minutes, secs = divmod(seconds, 60) 

44 if minutes < 60: 

45 return f"{minutes}m{secs:02d}s" 

46 hours, mins = divmod(minutes, 60) 

47 return f"{hours}h{mins:02d}m{secs:02d}s" 

48 

49 

50def _first_manifest_namespace(manifests: list[dict[str, Any]]) -> str | None: 

51 """Return the first explicit ``metadata.namespace`` found in a manifest list. 

52 

53 Used by the SQS submission path to populate the envelope ``namespace`` 

54 field (informational — the queue processor reads each manifest's own 

55 namespace for validation). Returns None if no manifest declares one. 

56 """ 

57 for manifest in manifests: 

58 ns = manifest.get("metadata", {}).get("namespace") if isinstance(manifest, dict) else None 

59 if ns: 

60 return str(ns) 

61 return None 

62 

63 

64def resolve_submission_identity( 

65 result: Any, 

66 *, 

67 fallback_name: str | None = None, 

68 fallback_namespace: str | None = None, 

69) -> tuple[str | None, str | None]: 

70 """Resolve the submitted Job name and namespace from supported responses. 

71 

72 API submissions return resource-status dictionaries, while direct kubectl 

73 submissions return a top-level ``job_name`` and a ``resources`` list of 

74 human-readable strings. Only mapping-shaped resources are inspected, so 

75 direct response lines can never be mistaken for response envelopes. 

76 """ 

77 if not isinstance(result, Mapping): 

78 return fallback_name, fallback_namespace 

79 

80 raw_resources = result.get("resources") or [] 

81 if isinstance(raw_resources, Mapping): 

82 raw_resources = [raw_resources] 

83 resources = [resource for resource in raw_resources if isinstance(resource, Mapping)] 

84 job_resources = [ 

85 resource for resource in resources if str(resource.get("kind", "")).lower() == "job" 

86 ] 

87 

88 explicit_job_name = result.get("job_name") 

89 resource_with_name = next( 

90 (resource for resource in job_resources if resource.get("name")), None 

91 ) 

92 job_name = ( 

93 str(explicit_job_name) 

94 if explicit_job_name 

95 else str(resource_with_name.get("name")) 

96 if resource_with_name is not None 

97 else fallback_name 

98 ) 

99 

100 matching_resource = next( 

101 (resource for resource in job_resources if resource.get("name") == job_name), 

102 resource_with_name, 

103 ) 

104 resource_namespace = matching_resource.get("namespace") if matching_resource else None 

105 envelope_namespace = result.get("namespace") 

106 namespace = ( 

107 str(resource_namespace) 

108 if resource_namespace 

109 else str(envelope_namespace) 

110 if envelope_namespace 

111 else fallback_namespace 

112 ) 

113 return job_name, namespace 

114 

115 

116def _extract_image_refs(spec: dict[str, Any]) -> list[str]: 

117 """Extract container image refs from a parsed Job spec. 

118 

119 The API surface for a Job carries ``spec.template.spec.containers`` and 

120 ``spec.template.spec.initContainers`` lists, each entry of which has 

121 a ``name`` and an ``image`` URI. Returns an alphabetically-sorted, 

122 deduplicated list so the output is stable across calls — orphan-image 

123 cross-references rely on set equality. 

124 """ 

125 refs: set[str] = set() 

126 template = spec.get("template") if isinstance(spec, dict) else None 

127 pod_spec = template.get("spec") if isinstance(template, dict) else None 

128 if not isinstance(pod_spec, dict): 

129 return [] 

130 for key in ("containers", "initContainers"): 

131 items = pod_spec.get(key, []) 

132 if not isinstance(items, list): 

133 continue 

134 for entry in items: 

135 if not isinstance(entry, dict): 

136 continue 

137 image = entry.get("image") 

138 if isinstance(image, str) and image: 

139 refs.add(image) 

140 return sorted(refs) 

141 

142 

143def _extract_scheduling(payload: dict[str, Any]) -> dict[str, Any]: 

144 """Pull the node-placement block out of a job or pods API response. 

145 

146 Absent on responses that predate the field (an older regional bridge) and 

147 on the list endpoint, which deliberately does not pay for a Node read per 

148 job. Both cases yield empty placement rather than an error: an absent 

149 instance type is honest, a guessed one is not. 

150 """ 

151 scheduling = payload.get("scheduling") 

152 if not isinstance(scheduling, dict): 

153 return {} 

154 

155 nodes = scheduling.get("nodes") 

156 labels = scheduling.get("node_labels") 

157 return { 

158 "node_name": scheduling.get("node_name"), 

159 "node_instance_type": scheduling.get("node_instance_type"), 

160 "node_capacity_type": scheduling.get("node_capacity_type"), 

161 "node_labels": dict(labels) if isinstance(labels, dict) else {}, 

162 "nodes": list(nodes) if isinstance(nodes, list) else [], 

163 } 

164 

165 

166@dataclass 

167class JobInfo: 

168 """Information about a Kubernetes job.""" 

169 

170 name: str 

171 namespace: str 

172 region: str 

173 status: str # "pending", "running", "succeeded", "failed" 

174 created_time: datetime | None = None 

175 start_time: datetime | None = None 

176 completion_time: datetime | None = None 

177 active_pods: int = 0 

178 succeeded_pods: int = 0 

179 failed_pods: int = 0 

180 parallelism: int = 1 

181 completions: int = 1 

182 labels: dict[str, str] = field(default_factory=dict) 

183 image_refs: list[str] = field(default_factory=list) 

184 

185 # Where the job's pods actually landed. A job constrained to a *set* of 

186 # interchangeable instance types is placed by Karpenter within that set, 

187 # so the manifest records only what the run was authorized to use. These 

188 # record what it used, which is what cost reconciliation and 

189 # "did this fail on the smaller box?" both need. 

190 # 

191 # ``node_*`` describe the earliest-created scheduled pod; ``nodes`` lists 

192 # every node involved (a retried job can move between instance types) with 

193 # the pods on each. All are unset when nothing is scheduled yet, when the 

194 # pods have been garbage-collected, or on the list endpoint, which does not 

195 # pay for a Node read per job. 

196 node_name: str | None = None 

197 node_instance_type: str | None = None 

198 node_capacity_type: str | None = None 

199 node_labels: dict[str, str] = field(default_factory=dict) 

200 nodes: list[dict[str, Any]] = field(default_factory=list) 

201 

202 @property 

203 def is_complete(self) -> bool: 

204 return self.status in ("succeeded", "failed") 

205 

206 @property 

207 def duration_seconds(self) -> int | None: 

208 if self.start_time and self.completion_time: 

209 return int((self.completion_time - self.start_time).total_seconds()) 

210 if self.start_time: 

211 return int((datetime.now(UTC) - self.start_time).total_seconds()) 

212 return None 

213 

214 

215class JobManager: 

216 """ 

217 Manages jobs across GCO clusters. 

218 

219 Provides: 

220 - Job submission with region targeting 

221 - Job status queries across regions 

222 - Job logs retrieval 

223 - Job deletion 

224 """ 

225 

226 def __init__(self, config: GCOConfig | None = None): 

227 self.config = config or get_config() 

228 self._aws_client = get_aws_client(self.config) 

229 

230 def load_manifests(self, path: str) -> list[dict[str, Any]]: 

231 """ 

232 Load Kubernetes manifests from a file or directory. 

233 

234 Args: 

235 path: Path to YAML file or directory containing YAML files 

236 

237 Returns: 

238 List of manifest dictionaries 

239 """ 

240 manifests = [] 

241 path_obj = Path(path) 

242 

243 if path_obj.is_file(): 

244 manifests.extend(self._load_yaml_file(path_obj)) 

245 elif path_obj.is_dir(): 

246 for yaml_file in sorted(path_obj.glob("*.yaml")): 

247 manifests.extend(self._load_yaml_file(yaml_file)) 

248 for yaml_file in sorted(path_obj.glob("*.yml")): 

249 manifests.extend(self._load_yaml_file(yaml_file)) 

250 else: 

251 raise FileNotFoundError(f"Path not found: {path}") 

252 

253 return manifests 

254 

255 def _load_yaml_file(self, path: Path) -> list[dict[str, Any]]: 

256 """Load manifests from a single YAML file.""" 

257 with open(path, encoding="utf-8") as f: 

258 return safe_load_all_yaml(f, allow_aliases=False) 

259 

260 def submit_job( 

261 self, 

262 manifests: str | list[dict[str, Any]], 

263 namespace: str | None = None, 

264 target_region: str | None = None, 

265 dry_run: bool = False, 

266 labels: dict[str, str] | None = None, 

267 ) -> dict[str, Any]: 

268 """ 

269 Submit a job to GCO. 

270 

271 Args: 

272 manifests: Path to manifest file/directory or list of manifest dicts 

273 namespace: Fallback namespace for manifests that don't declare 

274 their own. When set, each manifest's ``metadata.namespace`` is 

275 filled in only if missing — existing values are preserved so 

276 users who've declared a target namespace in the manifest can 

277 rely on it reaching the server untouched. Server-side 

278 validation enforces the allowlist. 

279 target_region: Force job to specific region 

280 dry_run: Validate without applying 

281 labels: Additional labels to add to manifests 

282 

283 Returns: 

284 Submission result dictionary 

285 """ 

286 # Load manifests if path provided 

287 manifest_list = self.load_manifests(manifests) if isinstance(manifests, str) else manifests 

288 

289 # Apply the explicit or configured namespace as a fallback only — 

290 # preserve any namespace declared by an individual manifest. 

291 effective_namespace = namespace or self.config.default_namespace 

292 for manifest in manifest_list: 

293 if "metadata" not in manifest: 

294 manifest["metadata"] = {} 

295 manifest["metadata"].setdefault("namespace", effective_namespace) 

296 

297 # Apply additional labels 

298 if labels: 

299 for manifest in manifest_list: 

300 if "labels" not in manifest["metadata"]: 

301 manifest["metadata"]["labels"] = {} 

302 manifest["metadata"]["labels"].update(labels) 

303 

304 # Submit via API 

305 return self._aws_client.submit_manifests( 

306 manifests=manifest_list, 

307 namespace=effective_namespace, 

308 target_region=target_region, 

309 dry_run=dry_run, 

310 ) 

311 

312 def submit_job_direct( 

313 self, 

314 manifests: str | list[dict[str, Any]], 

315 region: str, 

316 namespace: str | None = None, 

317 dry_run: bool = False, 

318 labels: dict[str, str] | None = None, 

319 ) -> dict[str, Any]: 

320 """ 

321 Submit a job directly to a regional cluster using kubectl. 

322 

323 This bypasses the API Gateway and submits directly to the EKS cluster 

324 using kubectl. Requires: 

325 - kubectl installed and in PATH 

326 - EKS access entry configured for your IAM principal 

327 - AWS credentials with eks:DescribeCluster permission 

328 

329 Args: 

330 manifests: Path to manifest file/directory or list of manifest dicts 

331 region: Target region for direct submission (required) 

332 namespace: Fallback namespace for manifests that don't declare 

333 their own. When set, each manifest's ``metadata.namespace`` is 

334 filled in only if missing — existing values are preserved so 

335 users who've declared a target namespace in the manifest can 

336 rely on it reaching ``kubectl apply`` untouched. 

337 dry_run: Validate without applying 

338 labels: Additional labels to add to manifests 

339 

340 Returns: 

341 Submission result dictionary 

342 """ 

343 import subprocess 

344 import tempfile 

345 import uuid 

346 

347 # Load manifests if path provided 

348 manifest_list = self.load_manifests(manifests) if isinstance(manifests, str) else manifests 

349 

350 # Apply the explicit or configured namespace as a fallback only — 

351 # preserve any namespace declared by an individual manifest. 

352 effective_namespace = namespace or self.config.default_namespace 

353 for manifest in manifest_list: 

354 if "metadata" not in manifest: 

355 manifest["metadata"] = {} 

356 manifest["metadata"].setdefault("namespace", effective_namespace) 

357 

358 # Apply additional labels 

359 if labels: 

360 for manifest in manifest_list: 

361 if "labels" not in manifest["metadata"]: 

362 manifest["metadata"]["labels"] = {} 

363 manifest["metadata"]["labels"].update(labels) 

364 

365 # Get cluster name from stack 

366 stack = self._aws_client.get_regional_stack(region) 

367 if not stack: 

368 raise ValueError(f"No GCO stack found in region {region}") 

369 

370 cluster_name = stack.cluster_name 

371 

372 # Update kubeconfig for the cluster 

373 from .kubectl_helpers import update_kubeconfig 

374 

375 update_kubeconfig(cluster_name, region) 

376 

377 # Handle existing Job resources before applying 

378 warnings: list[str] = [] 

379 if not dry_run: 

380 for manifest in manifest_list: 

381 if manifest.get("kind") != "Job": 

382 continue 

383 job_name = manifest.get("metadata", {}).get("name") 

384 job_ns = manifest.get("metadata", {}).get("namespace", effective_namespace) 

385 if not job_name: 

386 continue 

387 

388 existing_status = self._get_kubectl_job_status(job_name, job_ns) 

389 if existing_status is None: 

390 # No existing job — nothing to do 

391 continue 

392 

393 if existing_status in ("complete", "failed"): 

394 # Finished job — safe to delete and replace 

395 subprocess.run( 

396 ["kubectl", "delete", "job", job_name, "-n", job_ns], 

397 capture_output=True, 

398 text=True, 

399 ) 

400 else: 

401 # Job is still active — auto-rename to avoid collision 

402 suffix = uuid.uuid4().hex[:5] 

403 new_name = f"{job_name}-{suffix}" 

404 original_name = job_name 

405 manifest["metadata"]["name"] = new_name 

406 warnings.append( 

407 f"Job '{original_name}' is still running in namespace " 

408 f"'{job_ns}'. Renamed new submission to '{new_name}'." 

409 ) 

410 logger.warning( 

411 "Job %s is active in %s, renamed to %s", 

412 original_name, 

413 job_ns, 

414 new_name, 

415 ) 

416 

417 # Write manifests to temp file 

418 with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: 

419 yaml.dump_all(manifest_list, f) 

420 f.flush() # Ensure content is written before using f.name 

421 temp_path = f.name # nosemgrep: tempfile-without-flush 

422 

423 try: 

424 # Build kubectl command 

425 kubectl_cmd = ["kubectl", "apply", "-f", temp_path] 

426 

427 if dry_run: 

428 kubectl_cmd.extend(["--dry-run=client"]) 

429 

430 # Run kubectl apply 

431 result = subprocess.run( 

432 kubectl_cmd, capture_output=True, text=True 

433 ) # nosemgrep: dangerous-subprocess-use-audit - kubectl_cmd is a list ["kubectl","apply","-f",temp_path]; temp_path is a secure tempfile, not user input 

434 

435 if result.returncode != 0: 

436 raise RuntimeError(f"kubectl apply failed: {result.stderr}") 

437 

438 # Parse output to get job name 

439 output_lines = result.stdout.strip().split("\n") 

440 created_resources = [] 

441 for line in output_lines: 

442 if line: 

443 created_resources.append(line) 

444 

445 # Get the actual Job identity from the submitted manifest. The 

446 # name may have been changed above to avoid an active-job collision, 

447 # and a manifest-declared namespace takes precedence over the CLI 

448 # fallback. 

449 job_name = None 

450 job_namespace = effective_namespace 

451 for manifest in manifest_list: 

452 if manifest.get("kind") == "Job": 

453 metadata = manifest.get("metadata", {}) 

454 job_name = metadata.get("name") 

455 job_namespace = metadata.get("namespace") or job_namespace 

456 break 

457 

458 response: dict[str, Any] = { 

459 "status": "success", 

460 "method": "kubectl", 

461 "cluster": cluster_name, 

462 "region": region, 

463 "namespace": job_namespace, 

464 "job_name": job_name, 

465 "dry_run": dry_run, 

466 "resources": created_resources, 

467 "output": result.stdout, 

468 } 

469 if warnings: 

470 response["warnings"] = warnings 

471 return response 

472 

473 finally: 

474 # Clean up temp file 

475 import os 

476 

477 os.unlink(temp_path) 

478 

479 def _get_kubectl_job_status(self, job_name: str, namespace: str) -> str | None: 

480 """Check the status of an existing Job via kubectl. 

481 

482 Returns: 

483 "complete", "failed", "active", or None if the job doesn't exist. 

484 """ 

485 import json 

486 import subprocess 

487 

488 result = subprocess.run( 

489 [ 

490 "kubectl", 

491 "get", 

492 "job", 

493 job_name, 

494 "-n", 

495 namespace, 

496 "-o", 

497 "json", 

498 ], 

499 capture_output=True, 

500 text=True, 

501 ) 

502 if result.returncode != 0: 

503 return None # Job doesn't exist 

504 

505 try: 

506 job_data = json.loads(result.stdout) 

507 except json.JSONDecodeError, KeyError: 

508 return None 

509 

510 conditions = job_data.get("status", {}).get("conditions") or [] 

511 for condition in conditions: 

512 cond_type = condition.get("type", "") 

513 cond_status = condition.get("status", "") 

514 if cond_type == "Complete" and cond_status == "True": 

515 return "complete" 

516 if cond_type == "Failed" and cond_status == "True": 

517 return "failed" 

518 return "active" 

519 

520 def list_jobs( 

521 self, 

522 region: str | None = None, 

523 namespace: str | None = None, 

524 status: str | None = None, 

525 all_regions: bool = False, 

526 ) -> list[JobInfo]: 

527 """ 

528 List jobs across GCO clusters. 

529 

530 Args: 

531 region: Specific region to query 

532 namespace: Filter by namespace 

533 status: Filter by status 

534 all_regions: Query all discovered regions 

535 

536 Returns: 

537 List of JobInfo objects 

538 """ 

539 jobs = [] 

540 

541 if all_regions: 

542 # Query all discovered regional stacks 

543 stacks = self._aws_client.discover_regional_stacks() 

544 for stack_region in stacks: 

545 try: 

546 region_jobs = self._query_jobs_in_region(stack_region, namespace, status) 

547 jobs.extend(region_jobs) 

548 except Exception as e: 

549 logger.warning("Failed to query jobs in %s: %s", stack_region, e) 

550 continue 

551 elif region: 

552 jobs = self._query_jobs_in_region(region, namespace, status) 

553 else: 

554 # Use default region 

555 jobs = self._query_jobs_in_region(self.config.default_region, namespace, status) 

556 

557 return jobs 

558 

559 def _query_jobs_in_region( 

560 self, region: str, namespace: str | None, status: str | None 

561 ) -> list[JobInfo]: 

562 """Query jobs in a specific region.""" 

563 try: 

564 response = self._aws_client.get_jobs(region=region, namespace=namespace, status=status) 

565 

566 jobs = [] 

567 # response is a list, but we expect a dict with "jobs" key from the API 

568 job_list = response.get("jobs", []) if isinstance(response, dict) else response 

569 for job_data in job_list: 

570 jobs.append(self._parse_job_info(job_data, region)) 

571 

572 return jobs 

573 except Exception as exc: 

574 logger.warning("Failed to query jobs in %s: %s", region, exc) 

575 return [] 

576 

577 def _parse_job_info(self, job_data: dict[str, Any], region: str) -> JobInfo: 

578 """Parse job data into JobInfo object.""" 

579 metadata = job_data.get("metadata", {}) 

580 status_data = job_data.get("status", {}) 

581 spec = job_data.get("spec", {}) 

582 

583 # Determine job status 

584 conditions = status_data.get("conditions", []) 

585 job_status = "pending" 

586 for condition in conditions: 

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

588 job_status = "succeeded" 

589 break 

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

591 job_status = "failed" 

592 break 

593 

594 if job_status == "pending" and status_data.get("active", 0) > 0: 

595 job_status = "running" 

596 

597 # Parse timestamps 

598 created_time = None 

599 if metadata.get("creationTimestamp"): 

600 created_time = datetime.fromisoformat( 

601 metadata["creationTimestamp"].replace("Z", "+00:00") 

602 ) 

603 

604 start_time = None 

605 if status_data.get("startTime"): 

606 start_time = datetime.fromisoformat(status_data["startTime"].replace("Z", "+00:00")) 

607 

608 completion_time = None 

609 if status_data.get("completionTime"): 

610 completion_time = datetime.fromisoformat( 

611 status_data["completionTime"].replace("Z", "+00:00") 

612 ) 

613 

614 return JobInfo( 

615 name=metadata.get("name", ""), 

616 namespace=metadata.get("namespace", "default"), 

617 region=region, 

618 status=job_status, 

619 created_time=created_time, 

620 start_time=start_time, 

621 completion_time=completion_time, 

622 active_pods=status_data.get("active", 0), 

623 succeeded_pods=status_data.get("succeeded", 0), 

624 failed_pods=status_data.get("failed", 0), 

625 parallelism=spec.get("parallelism", 1), 

626 completions=spec.get("completions", 1), 

627 labels=metadata.get("labels", {}), 

628 image_refs=_extract_image_refs(spec), 

629 **_extract_scheduling(job_data), 

630 ) 

631 

632 def get_job(self, job_name: str, namespace: str, region: str | None = None) -> JobInfo | None: 

633 """ 

634 Get detailed information about a specific job. 

635 

636 A name the batch Job endpoint does not know (404) is retried as a 

637 Kubeflow TrainJob through the generic manifests endpoint, so 

638 ``gco jobs get`` / ``gco jobs submit --wait`` work unchanged for 

639 TrainJobs. 

640 

641 ``None`` means the API confirmed the job does not exist (HTTP 404 on 

642 both lookups). Any other failure — an unreachable regional API bridge, 

643 an auth error, a 5xx — propagates to the caller so it is never 

644 misreported as "not found" (see issue #258). 

645 

646 Args: 

647 job_name: Name of the job 

648 namespace: Namespace of the job 

649 region: Region where the job is running 

650 

651 Returns: 

652 JobInfo, or None if the API confirmed the job does not exist 

653 

654 Raises: 

655 RuntimeError: If the regional API bridge is unreachable or the 

656 request fails for reasons other than the job being absent 

657 requests.exceptions.HTTPError: For non-404 HTTP responses 

658 """ 

659 target_region = region or self.config.default_region 

660 try: 

661 response = self._aws_client.get_job_details( 

662 job_name=job_name, namespace=namespace, region=target_region 

663 ) 

664 return self._parse_job_info(response, target_region) 

665 except requests.exceptions.HTTPError as e: 

666 if e.response is not None and e.response.status_code == 404: 

667 # The batch Job endpoint confirmed the name is unknown; the 

668 # job may still exist as a Kubeflow TrainJob. 

669 trainjob = self._get_trainjob_info(job_name, namespace, target_region) 

670 if trainjob is not None: 

671 return trainjob 

672 logger.debug("Job %s not found in %s/%s", job_name, target_region, namespace) 

673 return None 

674 # Non-404 responses (401/403/5xx) do not mean the job is absent. 

675 # Surface the real failure instead of collapsing it into None, 

676 # which the CLI would render as "not found". 

677 raise 

678 

679 def _get_trainjob_info(self, job_name: str, namespace: str, region: str) -> JobInfo | None: 

680 """Fetch a TrainJob through the generic manifests endpoint, or None.""" 

681 try: 

682 response = self._aws_client.call_api( 

683 "GET", 

684 f"/api/v1/manifests/{quote(namespace, safe='')}/{quote(job_name, safe='')}", 

685 region=region, 

686 params={"api_version": TRAINJOB_API_VERSION, "kind": "TrainJob"}, 

687 ) 

688 except RuntimeError as e: 

689 # 404 (no such TrainJob either) and transport errors both mean 

690 # "not resolvable as a TrainJob"; the caller reports not-found. 

691 logger.debug("TrainJob fallback lookup failed for %s: %s", job_name, e) 

692 return None 

693 resource = response.get("resource") or {} 

694 if not resource.get("exists", False): 

695 return None 

696 info = self._parse_trainjob_info(resource, region) 

697 self._attach_trainjob_scheduling(info, job_name, namespace, region) 

698 return info 

699 

700 def _attach_trainjob_scheduling( 

701 self, info: JobInfo, job_name: str, namespace: str, region: str 

702 ) -> None: 

703 """Fill in node placement for a TrainJob from its child Job's pods. 

704 

705 The manifests endpoint that resolves a TrainJob knows nothing about 

706 pods, so placement comes from the child Job ``<name>-node-0`` whose 

707 indexed pods are the node ranks (same convention the log fetch uses). 

708 Best effort: a TrainJob with no pods yet, or a bridge too old to report 

709 placement, simply leaves the fields unset. 

710 """ 

711 try: 

712 response = self._aws_client.get_job_pods(f"{job_name}-node-0", namespace, region) 

713 except Exception as exc: 

714 logger.debug("No node placement available for TrainJob %s: %s", job_name, exc) 

715 return 

716 

717 scheduling = _extract_scheduling(response if isinstance(response, dict) else {}) 

718 for attr, value in scheduling.items(): 

719 setattr(info, attr, value) 

720 

721 def _parse_trainjob_info(self, resource: dict[str, Any], region: str) -> JobInfo: 

722 """Map a TrainJob resource payload onto JobInfo. 

723 

724 TrainJob status carries ``conditions`` (terminal types ``Complete`` / 

725 ``Failed``, same strings as batch Jobs) plus per-child-Job counts in 

726 ``jobsStatus``; there is no ``startTime``/``completionTime``, so the 

727 terminal condition's ``lastTransitionTime`` stands in for completion. 

728 """ 

729 metadata = resource.get("metadata", {}) or {} 

730 status_data = resource.get("status", {}) or {} 

731 spec = resource.get("spec", {}) or {} 

732 

733 job_status = "pending" 

734 completion_time = None 

735 for condition in status_data.get("conditions", []) or []: 

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

737 continue 

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

739 job_status = "succeeded" 

740 elif condition.get("type") == "Failed": 

741 job_status = "failed" 

742 else: 

743 continue 

744 if condition.get("lastTransitionTime"): 

745 completion_time = datetime.fromisoformat( 

746 condition["lastTransitionTime"].replace("Z", "+00:00") 

747 ) 

748 break 

749 

750 active = succeeded = failed = 0 

751 for child in status_data.get("jobsStatus", []) or []: 

752 active += child.get("active", 0) or 0 

753 succeeded += child.get("succeeded", 0) or 0 

754 failed += child.get("failed", 0) or 0 

755 if job_status == "pending" and active > 0: 

756 job_status = "running" 

757 

758 created_time = None 

759 if metadata.get("creationTimestamp"): 

760 created_time = datetime.fromisoformat( 

761 metadata["creationTimestamp"].replace("Z", "+00:00") 

762 ) 

763 

764 trainer = spec.get("trainer", {}) or {} 

765 try: 

766 num_nodes = max(1, int(trainer.get("numNodes") or 1)) 

767 except TypeError, ValueError: 

768 num_nodes = 1 

769 

770 return JobInfo( 

771 name=metadata.get("name", ""), 

772 namespace=metadata.get("namespace", "default"), 

773 region=region, 

774 status=job_status, 

775 created_time=created_time, 

776 # TrainJob status reports no startTime; leave it unset rather 

777 # than fabricating one from creation time. 

778 completion_time=completion_time, 

779 active_pods=active, 

780 succeeded_pods=succeeded, 

781 failed_pods=failed, 

782 parallelism=num_nodes, 

783 completions=num_nodes, 

784 labels=metadata.get("labels", {}) or {}, 

785 image_refs=sorted({trainer["image"]} if trainer.get("image") else set()), 

786 ) 

787 

788 def get_job_logs( 

789 self, 

790 job_name: str, 

791 namespace: str, 

792 region: str | None = None, 

793 tail_lines: int = 100, 

794 follow: bool = False, 

795 since_hours: int = 24, 

796 node: int = 0, 

797 ) -> str: 

798 """ 

799 Get logs from a job. 

800 

801 Tries the Kubernetes API first (via the GCO API). A name the batch 

802 Job endpoint does not know is retried as a Kubeflow TrainJob: the 

803 torch runtime runs the whole job as one child Job named 

804 ``<name>-node-0`` whose indexed pods are the node ranks, so the 

805 rank-``node`` pod's logs are fetched (rank 0 by default). If the pod 

806 is no longer available (completed/deleted), falls back to CloudWatch 

807 Logs where Container Insights stores application logs. 

808 

809 Args: 

810 job_name: Name of the job 

811 namespace: Namespace of the job 

812 region: Region where the job is running 

813 tail_lines: Number of lines to return 

814 follow: Stream logs (not implemented yet) 

815 since_hours: Hours to look back in CloudWatch (default 24) 

816 node: Node rank to fetch for a distributed TrainJob (default 0) 

817 

818 Returns: 

819 Log content as string 

820 """ 

821 if follow: 

822 raise NotImplementedError("Log streaming not yet implemented") 

823 

824 target_region = region or self.config.default_region 

825 

826 try: 

827 return self._aws_client.get_job_logs( 

828 job_name=job_name, 

829 namespace=namespace, 

830 region=target_region, 

831 tail_lines=tail_lines, 

832 ) 

833 except RuntimeError as e: 

834 error_msg = str(e) 

835 lowered = error_msg.lower() 

836 # An unknown batch Job name may be a TrainJob; its pods run 

837 # under the JobSet child Job instead. 

838 if "not found" in lowered: 

839 try: 

840 return self._get_trainjob_node_logs( 

841 job_name=job_name, 

842 namespace=namespace, 

843 region=target_region, 

844 node=node, 

845 tail_lines=tail_lines, 

846 ) 

847 except Exception as tj_err: 

848 logger.debug("TrainJob logs fallback failed: %s", tj_err) 

849 # If the pod is gone or pending, try CloudWatch 

850 if any( 

851 hint in lowered 

852 for hint in ["not found", "pending", "no pods", "terminated", "completed"] 

853 ): 

854 logger.info("Pod not available, falling back to CloudWatch Logs") 

855 try: 

856 return self._get_cloudwatch_logs( 

857 job_name=job_name, 

858 region=target_region, 

859 tail_lines=tail_lines, 

860 since_hours=since_hours, 

861 ) 

862 except Exception as cw_err: 

863 logger.debug("CloudWatch fallback failed: %s", cw_err) 

864 raise RuntimeError( 

865 f"{error_msg}\n\n" 

866 f"CloudWatch Logs fallback also failed: {cw_err}\n" 

867 f"Tip: Container logs appear in CloudWatch within a few minutes. " 

868 f"If the job just finished, try again shortly." 

869 ) from e 

870 raise 

871 

872 def _get_trainjob_node_logs( 

873 self, 

874 job_name: str, 

875 namespace: str, 

876 region: str, 

877 node: int, 

878 tail_lines: int, 

879 ) -> str: 

880 """Fetch logs from the rank-``node`` pod of a TrainJob. 

881 

882 The shipped torch-distributed runtime materializes a TrainJob as a 

883 JobSet with a single replicated Job ``<name>-node-0`` running 

884 ``numNodes`` indexed pods; the completion index is the node rank 

885 (``PET_NODE_RANK``). Raises if no rank-``node`` pod exists. 

886 """ 

887 child_job = f"{job_name}-node-0" 

888 pods_response = self._aws_client.get_job_pods(child_job, namespace, region) 

889 pods = pods_response.get("pods", []) or [] 

890 if not pods: 

891 raise RuntimeError(f"no pods found for TrainJob child job '{child_job}'") 

892 

893 target_pod = None 

894 for pod in pods: 

895 metadata = pod.get("metadata", {}) or {} 

896 labels = metadata.get("labels", {}) or {} 

897 if labels.get("batch.kubernetes.io/job-completion-index") == str(node): 

898 target_pod = metadata.get("name") 

899 break 

900 if target_pod is None: 

901 # Older control planes may omit the index label; fall back to the 

902 # deterministic indexed-pod name prefix '<child>-<index>-'. 

903 prefix = f"{child_job}-{node}-" 

904 for pod in pods: 

905 pod_name = (pod.get("metadata", {}) or {}).get("name", "") 

906 if pod_name.startswith(prefix): 

907 target_pod = pod_name 

908 break 

909 if target_pod is None: 

910 raise RuntimeError( 

911 f"no rank-{node} pod found for TrainJob '{job_name}' " 

912 f"({len(pods)} pod(s) under child job '{child_job}'); " 

913 f"list them with: gco jobs pods {child_job} -n {namespace} -r {region}" 

914 ) 

915 

916 logger.info( 

917 "Job '%s' resolved as TrainJob; fetching logs from rank-%d pod '%s'", 

918 job_name, 

919 node, 

920 target_pod, 

921 ) 

922 response = self._aws_client.get_pod_logs( 

923 job_name=child_job, 

924 pod_name=target_pod, 

925 namespace=namespace, 

926 region=region, 

927 tail_lines=tail_lines, 

928 ) 

929 return str(response.get("logs", "")) 

930 

931 def _get_cloudwatch_logs( 

932 self, 

933 job_name: str, 

934 region: str, 

935 tail_lines: int = 100, 

936 since_hours: int = 24, 

937 ) -> str: 

938 """ 

939 Fetch job logs from CloudWatch Logs (Container Insights). 

940 

941 The CloudWatch Observability addon ships container stdout/stderr to: 

942 /aws/containerinsights/{cluster_name}/application 

943 

944 Args: 

945 job_name: Name of the job (used to filter log streams) 

946 region: AWS region 

947 tail_lines: Number of log lines to return 

948 since_hours: Hours to look back (default 24) 

949 

950 Returns: 

951 Log content as string 

952 """ 

953 cluster_name = f"{self.config.project_name}-{region}" 

954 log_group = f"/aws/containerinsights/{cluster_name}/application" 

955 

956 logs_client = self._aws_client._session.client("logs", region_name=region) 

957 

958 import time 

959 

960 now = int(time.time()) 

961 start_time = now - (since_hours * 3600) 

962 

963 query = ( 

964 f"fields @timestamp, @message " 

965 f'| filter @logStream like "{job_name}" ' 

966 f"| sort @timestamp asc " 

967 f"| limit {tail_lines}" 

968 ) 

969 

970 start_query = logs_client.start_query( 

971 logGroupName=log_group, 

972 startTime=start_time, 

973 endTime=now, 

974 queryString=query, 

975 ) 

976 query_id = start_query["queryId"] 

977 

978 # Poll for results (CloudWatch Insights is async) 

979 result = None 

980 for _ in range(30): # up to 30 seconds 

981 time.sleep(1) 

982 result = logs_client.get_query_results(queryId=query_id) 

983 if result["status"] in ("Complete", "Failed", "Cancelled"): 

984 break 

985 

986 if result is None or result["status"] != "Complete": 

987 status = result["status"] if result else "unknown" 

988 raise RuntimeError( 

989 f"CloudWatch Logs query did not complete (status: {status}). Try again in a moment." 

990 ) 

991 

992 if not result["results"]: 

993 raise RuntimeError( 

994 f"No logs found in CloudWatch for job '{job_name}' " 

995 f"in the last {since_hours} hours (log group: {log_group}). " 

996 f"Logs may take 1-2 minutes to appear after a pod runs. " 

997 f"Use --since to search further back, or check the job name " 

998 f"with: gco jobs list -r {region}" 

999 ) 

1000 

1001 # Extract log messages from results. 

1002 # CloudWatch Container Insights wraps logs in a JSON envelope: 

1003 # {"time":"...","stream":"stdout","log":"actual message","kubernetes":{...}} 

1004 # We parse out the "log" field for clean output, falling back to the 

1005 # raw message if it's not JSON. 

1006 import json as _json 

1007 

1008 lines = [] 

1009 for row in result["results"]: 

1010 for entry in row: 

1011 if entry["field"] == "@message": 

1012 raw = entry["value"].rstrip() 

1013 try: 

1014 parsed = _json.loads(raw) 

1015 message = parsed.get("log", raw) if isinstance(parsed, dict) else raw 

1016 lines.append(message.rstrip() if isinstance(message, str) else raw) 

1017 except ValueError, TypeError: 

1018 lines.append(raw) 

1019 break 

1020 

1021 header = f"[CloudWatch Logs — {log_group}]\n" 

1022 return header + "\n".join(lines) 

1023 

1024 def delete_job( 

1025 self, 

1026 job_name: str, 

1027 namespace: str, 

1028 region: str | None = None, 

1029 expected_uid: str | None = None, 

1030 ) -> dict[str, Any]: 

1031 """ 

1032 Delete a job. 

1033 

1034 A name the batch Job endpoint does not know (404) is retried as a 

1035 Kubeflow TrainJob through the generic manifests endpoint. Deleting 

1036 the TrainJob cascades to its JobSet, child Jobs, and pods via owner 

1037 references. ``expected_uid`` (a batch-Job concurrency guard) is not 

1038 supported on the TrainJob path. 

1039 

1040 Args: 

1041 job_name: Name of the job 

1042 namespace: Namespace of the job 

1043 region: Region where the job is running 

1044 

1045 Returns: 

1046 Deletion result 

1047 """ 

1048 target_region = region or self.config.default_region 

1049 try: 

1050 return self._aws_client.delete_job( 

1051 job_name=job_name, 

1052 namespace=namespace, 

1053 region=target_region, 

1054 expected_uid=expected_uid, 

1055 ) 

1056 except requests.exceptions.HTTPError as e: 

1057 if e.response is not None and e.response.status_code == 404: 

1058 trainjob_result = self._delete_trainjob(job_name, namespace, target_region) 

1059 if trainjob_result is not None: 

1060 return trainjob_result 

1061 raise 

1062 

1063 def _delete_trainjob(self, job_name: str, namespace: str, region: str) -> dict[str, Any] | None: 

1064 """Delete a TrainJob via the manifests endpoint, or None if that fails. 

1065 

1066 Returning None (rather than raising) lets the caller surface the 

1067 original batch-Job 404, which is the right error for a typo'd name. 

1068 """ 

1069 try: 

1070 return self._aws_client.call_api( 

1071 "DELETE", 

1072 f"/api/v1/manifests/{quote(namespace, safe='')}/{quote(job_name, safe='')}", 

1073 region=region, 

1074 params={"api_version": TRAINJOB_API_VERSION, "kind": "TrainJob"}, 

1075 ) 

1076 except RuntimeError as e: 

1077 logger.debug("TrainJob delete fallback failed for %s: %s", job_name, e) 

1078 return None 

1079 

1080 def wait_for_job( 

1081 self, 

1082 job_name: str, 

1083 namespace: str, 

1084 region: str | None = None, 

1085 timeout_seconds: int = 3600, 

1086 poll_interval: int = 10, 

1087 progress_callback: Callable[[JobInfo, int], None] | None = None, 

1088 ) -> JobInfo: 

1089 """ 

1090 Wait for a job to complete with progress reporting. 

1091 

1092 Args: 

1093 job_name: Name of the job 

1094 namespace: Namespace of the job 

1095 region: Region where the job is running 

1096 timeout_seconds: Maximum time to wait 

1097 poll_interval: Seconds between status checks 

1098 progress_callback: Optional callable(JobInfo, elapsed_seconds) for progress updates. 

1099 If None, a default stderr progress line is printed. 

1100 

1101 Returns: 

1102 Final JobInfo 

1103 

1104 Raises: 

1105 TimeoutError: If job doesn't complete within timeout 

1106 """ 

1107 import sys 

1108 import time 

1109 

1110 start_time = time.time() 

1111 

1112 while True: 

1113 job = self.get_job(job_name, namespace, region) 

1114 

1115 if job is None: 

1116 raise ValueError(f"Job {job_name} not found in namespace {namespace}") 

1117 

1118 elapsed = time.time() - start_time 

1119 elapsed_str = _format_duration(int(elapsed)) 

1120 

1121 if job.is_complete: 

1122 # Clear the progress line and return 

1123 sys.stderr.write("\r\033[K") 

1124 sys.stderr.flush() 

1125 return job 

1126 

1127 # Build progress message 

1128 pods_info = ( 

1129 f"{job.active_pods} active, {job.succeeded_pods}/{job.completions} succeeded" 

1130 ) 

1131 if job.failed_pods: 

1132 pods_info += f", {job.failed_pods} failed" 

1133 

1134 status_line = f"{job.status.capitalize()}{pods_info}{elapsed_str} elapsed" 

1135 

1136 if progress_callback: 

1137 progress_callback(job, int(elapsed)) 

1138 else: 

1139 # Overwrite the same line on stderr 

1140 sys.stderr.write(f"\r\033[K{status_line}") 

1141 sys.stderr.flush() 

1142 

1143 if elapsed >= timeout_seconds: 

1144 sys.stderr.write("\r\033[K") 

1145 sys.stderr.flush() 

1146 raise TimeoutError( 

1147 f"Job {job_name} did not complete within {timeout_seconds} seconds " 

1148 f"(last status: {job.status}, pods: {pods_info})" 

1149 ) 

1150 

1151 time.sleep(poll_interval) # nosemgrep: arbitrary-sleep - intentional polling delay 

1152 

1153 def submit_job_sqs( 

1154 self, 

1155 manifests: str | list[dict[str, Any]], 

1156 region: str, 

1157 namespace: str | None = None, 

1158 labels: dict[str, str] | None = None, 

1159 priority: int = 0, 

1160 ) -> dict[str, Any]: 

1161 """ 

1162 Submit a job to a regional SQS queue for processing. 

1163 

1164 This is the recommended way to submit jobs as it: 

1165 - Decouples submission from processing 

1166 - Enables KEDA-based autoscaling 

1167 - Provides better fault tolerance 

1168 

1169 Args: 

1170 manifests: Path to manifest file/directory or list of manifest dicts 

1171 region: Target region for job submission (required) 

1172 namespace: Fallback namespace for manifests that don't declare 

1173 their own. When set, each manifest's ``metadata.namespace`` is 

1174 filled in only if missing — existing values are preserved so 

1175 users who've declared a target namespace in the manifest can 

1176 rely on it reaching the queue processor untouched. Server-side 

1177 validation enforces the allowlist. 

1178 labels: Additional labels to add to manifests 

1179 priority: Job priority (higher = more important) 

1180 

1181 Returns: 

1182 Submission result dictionary with message_id and queue info 

1183 """ 

1184 import json 

1185 import uuid 

1186 

1187 import boto3 

1188 

1189 # Load manifests if path provided 

1190 manifest_list = self.load_manifests(manifests) if isinstance(manifests, str) else manifests 

1191 

1192 # Apply namespace as a fallback only — preserve any namespace the 

1193 # manifest declared itself. 

1194 if namespace: 

1195 for manifest in manifest_list: 

1196 if "metadata" not in manifest: 

1197 manifest["metadata"] = {} 

1198 manifest["metadata"].setdefault("namespace", namespace) 

1199 

1200 # Apply additional labels 

1201 if labels: 

1202 for manifest in manifest_list: 

1203 if "metadata" not in manifest: 

1204 manifest["metadata"] = {} 

1205 if "labels" not in manifest["metadata"]: 

1206 manifest["metadata"]["labels"] = {} 

1207 manifest["metadata"]["labels"].update(labels) 

1208 

1209 # Get queue URL from stack 

1210 stack = self._aws_client.get_regional_stack(region) 

1211 if not stack: 

1212 raise ValueError(f"No GCO stack found in region {region}") 

1213 

1214 # Get queue URL from CloudFormation outputs 

1215 cfn = boto3.client("cloudformation", region_name=region) 

1216 response = cfn.describe_stacks(StackName=stack.stack_name) 

1217 outputs = { 

1218 o["OutputKey"]: o["OutputValue"] for o in response["Stacks"][0].get("Outputs", []) 

1219 } 

1220 queue_url = outputs.get("JobQueueUrl") 

1221 

1222 if not queue_url: 

1223 raise ValueError(f"Job queue not found in stack {stack.stack_name}") 

1224 

1225 # Create SQS message. The ``namespace`` field in the envelope is 

1226 # informational only — the queue processor reads each manifest's 

1227 # own ``metadata.namespace`` for validation and application. Report 

1228 # the first manifest's namespace here so the submission response 

1229 # matches reality when the user doesn't pass ``--namespace``. 

1230 job_id = str(uuid.uuid4())[:8] 

1231 envelope_namespace = namespace or _first_manifest_namespace(manifest_list) or "gco-jobs" 

1232 message_body = { 

1233 "job_id": job_id, 

1234 "manifests": manifest_list, 

1235 "namespace": envelope_namespace, 

1236 "priority": priority, 

1237 "submitted_at": datetime.now(UTC).isoformat(), 

1238 } 

1239 

1240 # Send to SQS 

1241 sqs = boto3.client("sqs", region_name=region) 

1242 response = sqs.send_message( 

1243 QueueUrl=queue_url, 

1244 MessageBody=json.dumps(message_body), 

1245 MessageAttributes={ 

1246 "Priority": {"DataType": "Number", "StringValue": str(priority)}, 

1247 "JobId": {"DataType": "String", "StringValue": job_id}, 

1248 }, 

1249 ) 

1250 

1251 # Get job name from first manifest 

1252 job_name = None 

1253 for manifest in manifest_list: 

1254 if manifest.get("kind") == "Job": 

1255 job_name = manifest.get("metadata", {}).get("name") 

1256 break 

1257 

1258 return { 

1259 "status": "queued", 

1260 "method": "sqs", 

1261 "message_id": response["MessageId"], 

1262 "job_id": job_id, 

1263 "job_name": job_name, 

1264 "queue_url": queue_url, 

1265 "region": region, 

1266 "namespace": envelope_namespace, 

1267 "priority": priority, 

1268 } 

1269 

1270 def get_queue_status(self, region: str) -> dict[str, Any]: 

1271 """ 

1272 Get the status of the job queue in a region. 

1273 

1274 Args: 

1275 region: AWS region 

1276 

1277 Returns: 

1278 Queue status including message counts 

1279 """ 

1280 import boto3 

1281 

1282 stack = self._aws_client.get_regional_stack(region) 

1283 if not stack: 

1284 raise ValueError(f"No GCO stack found in region {region}") 

1285 

1286 # Get queue URLs from CloudFormation outputs 

1287 cfn = boto3.client("cloudformation", region_name=region) 

1288 response = cfn.describe_stacks(StackName=stack.stack_name) 

1289 outputs = { 

1290 o["OutputKey"]: o["OutputValue"] for o in response["Stacks"][0].get("Outputs", []) 

1291 } 

1292 

1293 queue_url = outputs.get("JobQueueUrl") 

1294 dlq_url = outputs.get("JobDlqUrl") 

1295 

1296 if not queue_url: 

1297 raise ValueError(f"Job queue not found in stack {stack.stack_name}") 

1298 

1299 sqs = boto3.client("sqs", region_name=region) 

1300 

1301 # Get main queue attributes 

1302 queue_attrs = sqs.get_queue_attributes( 

1303 QueueUrl=queue_url, 

1304 AttributeNames=[ 

1305 "ApproximateNumberOfMessages", 

1306 "ApproximateNumberOfMessagesNotVisible", 

1307 "ApproximateNumberOfMessagesDelayed", 

1308 ], 

1309 )["Attributes"] 

1310 

1311 result = { 

1312 "region": region, 

1313 "queue_url": queue_url, 

1314 "messages_available": int(queue_attrs.get("ApproximateNumberOfMessages", 0)), 

1315 "messages_in_flight": int(queue_attrs.get("ApproximateNumberOfMessagesNotVisible", 0)), 

1316 "messages_delayed": int(queue_attrs.get("ApproximateNumberOfMessagesDelayed", 0)), 

1317 } 

1318 

1319 # Get DLQ attributes if available 

1320 if dlq_url: 

1321 dlq_attrs = sqs.get_queue_attributes( 

1322 QueueUrl=dlq_url, 

1323 AttributeNames=["ApproximateNumberOfMessages"], 

1324 )["Attributes"] 

1325 result["dlq_url"] = dlq_url 

1326 result["dlq_messages"] = int(dlq_attrs.get("ApproximateNumberOfMessages", 0)) 

1327 

1328 return result 

1329 

1330 def list_jobs_global( 

1331 self, 

1332 namespace: str | None = None, 

1333 status: str | None = None, 

1334 limit: int = 50, 

1335 ) -> dict[str, Any]: 

1336 """ 

1337 List jobs across all regions via the global API endpoint. 

1338 

1339 This uses the cross-region aggregator Lambda to query all regional 

1340 clusters in parallel and return a unified view. 

1341 

1342 Args: 

1343 namespace: Filter by namespace 

1344 status: Filter by status 

1345 limit: Maximum jobs to return 

1346 

1347 Returns: 

1348 Aggregated job list with region information 

1349 """ 

1350 return self._aws_client.get_global_jobs( 

1351 namespace=namespace, 

1352 status=status, 

1353 limit=limit, 

1354 ) 

1355 

1356 def get_global_health(self) -> dict[str, Any]: 

1357 """ 

1358 Get health status across all regions. 

1359 

1360 Returns: 

1361 Aggregated health status from all regional clusters 

1362 """ 

1363 return self._aws_client.get_global_health() 

1364 

1365 def get_global_status(self) -> dict[str, Any]: 

1366 """ 

1367 Get cluster status across all regions. 

1368 

1369 Returns: 

1370 Aggregated status from all regional clusters 

1371 """ 

1372 return self._aws_client.get_global_status() 

1373 

1374 def bulk_delete_global( 

1375 self, 

1376 namespace: str | None = None, 

1377 status: str | None = None, 

1378 older_than_days: int | None = None, 

1379 label_selector: str | None = None, 

1380 dry_run: bool = True, 

1381 ) -> dict[str, Any]: 

1382 """ 

1383 Bulk delete jobs across all regions. 

1384 

1385 Args: 

1386 namespace: Filter by namespace 

1387 status: Filter by status 

1388 older_than_days: Delete jobs older than N days 

1389 label_selector: Kubernetes label selector 

1390 dry_run: If True, only return what would be deleted 

1391 

1392 Returns: 

1393 Deletion results from all regions 

1394 """ 

1395 return self._aws_client.bulk_delete_global( 

1396 namespace=namespace, 

1397 status=status, 

1398 older_than_days=older_than_days, 

1399 label_selector=label_selector, 

1400 dry_run=dry_run, 

1401 ) 

1402 

1403 def get_job_events( 

1404 self, 

1405 job_name: str, 

1406 namespace: str, 

1407 region: str | None = None, 

1408 ) -> dict[str, Any]: 

1409 """ 

1410 Get Kubernetes events for a job. 

1411 

1412 Args: 

1413 job_name: Name of the job 

1414 namespace: Namespace of the job 

1415 region: Region where the job is running 

1416 

1417 Returns: 

1418 Events related to the job 

1419 """ 

1420 return self._aws_client.get_job_events( 

1421 job_name=job_name, 

1422 namespace=namespace, 

1423 region=region or self.config.default_region, 

1424 ) 

1425 

1426 def get_job_pods( 

1427 self, 

1428 job_name: str, 

1429 namespace: str, 

1430 region: str | None = None, 

1431 ) -> dict[str, Any]: 

1432 """ 

1433 Get pods for a job. 

1434 

1435 Args: 

1436 job_name: Name of the job 

1437 namespace: Namespace of the job 

1438 region: Region where the job is running 

1439 

1440 Returns: 

1441 Pod details for the job 

1442 """ 

1443 return self._aws_client.get_job_pods( 

1444 job_name=job_name, 

1445 namespace=namespace, 

1446 region=region or self.config.default_region, 

1447 ) 

1448 

1449 def get_pod_logs( 

1450 self, 

1451 job_name: str, 

1452 pod_name: str, 

1453 namespace: str, 

1454 region: str | None = None, 

1455 tail_lines: int = 100, 

1456 container: str | None = None, 

1457 ) -> dict[str, Any]: 

1458 """ 

1459 Get logs from a specific pod of a job. 

1460 

1461 Args: 

1462 job_name: Name of the job 

1463 pod_name: Name of the pod 

1464 namespace: Namespace of the job 

1465 region: Region where the job is running 

1466 tail_lines: Number of lines to return 

1467 container: Container name (for multi-container pods) 

1468 

1469 Returns: 

1470 Pod logs response 

1471 """ 

1472 return self._aws_client.get_pod_logs( 

1473 job_name=job_name, 

1474 pod_name=pod_name, 

1475 namespace=namespace, 

1476 region=region or self.config.default_region, 

1477 tail_lines=tail_lines, 

1478 container=container, 

1479 ) 

1480 

1481 def get_job_metrics( 

1482 self, 

1483 job_name: str, 

1484 namespace: str, 

1485 region: str | None = None, 

1486 ) -> dict[str, Any]: 

1487 """ 

1488 Get resource metrics for a job. 

1489 

1490 Args: 

1491 job_name: Name of the job 

1492 namespace: Namespace of the job 

1493 region: Region where the job is running 

1494 

1495 Returns: 

1496 Resource usage metrics for the job's pods 

1497 """ 

1498 return self._aws_client.get_job_metrics( 

1499 job_name=job_name, 

1500 namespace=namespace, 

1501 region=region or self.config.default_region, 

1502 ) 

1503 

1504 def retry_job( 

1505 self, 

1506 job_name: str, 

1507 namespace: str, 

1508 region: str | None = None, 

1509 ) -> dict[str, Any]: 

1510 """ 

1511 Retry a failed job. 

1512 

1513 Creates a new job from the failed job's spec with a new name. 

1514 

1515 Args: 

1516 job_name: Name of the failed job 

1517 namespace: Namespace of the job 

1518 region: Region where the job is running 

1519 

1520 Returns: 

1521 Result with new job name 

1522 """ 

1523 return self._aws_client.retry_job( 

1524 job_name=job_name, 

1525 namespace=namespace, 

1526 region=region or self.config.default_region, 

1527 ) 

1528 

1529 def bulk_delete_jobs( 

1530 self, 

1531 namespace: str | None = None, 

1532 status: str | None = None, 

1533 older_than_days: int | None = None, 

1534 label_selector: str | None = None, 

1535 region: str | None = None, 

1536 dry_run: bool = True, 

1537 ) -> dict[str, Any]: 

1538 """ 

1539 Bulk delete jobs in a region. 

1540 

1541 Args: 

1542 namespace: Filter by namespace 

1543 status: Filter by status 

1544 older_than_days: Delete jobs older than N days 

1545 label_selector: Kubernetes label selector 

1546 region: Target region 

1547 dry_run: If True, only return what would be deleted 

1548 

1549 Returns: 

1550 Deletion results 

1551 """ 

1552 return self._aws_client.bulk_delete_jobs( 

1553 namespace=namespace, 

1554 status=status, 

1555 older_than_days=older_than_days, 

1556 label_selector=label_selector, 

1557 region=region or self.config.default_region, 

1558 dry_run=dry_run, 

1559 ) 

1560 

1561 

1562def get_job_manager(config: GCOConfig | None = None) -> JobManager: 

1563 """Get a configured job manager instance.""" 

1564 return JobManager(config)