Coverage for cli / commands / jobs_cmd.py: 100.00%

748 statements  

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

1"""Job management commands.""" 

2 

3import logging 

4import sys 

5from collections.abc import Mapping 

6from typing import Any 

7 

8import click 

9 

10from ..config import GCOConfig 

11from ..jobs import JobInfo, get_job_manager, resolve_submission_identity 

12from ..output import confirm, format_job_table, get_output_formatter 

13 

14logger = logging.getLogger(__name__) 

15 

16pass_config = click.make_pass_decorator(GCOConfig, ensure=True) 

17 

18 

19def _resolve_result_namespace(result: Any, fallback: str) -> str: 

20 """Pick the submitted Job namespace without assuming mapping resources.""" 

21 _job_name, namespace = resolve_submission_identity(result, fallback_namespace=fallback) 

22 return namespace or fallback 

23 

24 

25def _resolve_result_job_name(result: Any) -> str | None: 

26 """Pick the generated/submitted Job name from a submission response.""" 

27 job_name, _namespace = resolve_submission_identity(result) 

28 return job_name 

29 

30 

31@click.group() 

32@pass_config 

33def jobs(config: Any) -> None: 

34 """Manage jobs across GCO clusters.""" 

35 

36 

37@jobs.command("submit") 

38@click.argument("manifest_path", type=click.Path(exists=True)) 

39@click.option( 

40 "--namespace", 

41 "-n", 

42 help="Fallback namespace for manifests that don't declare their own", 

43) 

44@click.option("--region", "-r", "target_region", help="Target specific region") 

45@click.option("--dry-run", is_flag=True, help="Validate without applying") 

46@click.option( 

47 "--check-policy", 

48 is_flag=True, 

49 help=( 

50 "Before submitting, check the manifests against the policy the target " 

51 "region actually enforces and report anything that would be rejected. " 

52 "Advisory: findings are printed and submission continues" 

53 ), 

54) 

55@click.option("--label", "-l", multiple=True, help="Add labels (key=value)") 

56@click.option("--wait", "-w", is_flag=True, help="Wait for job completion") 

57@click.option("--timeout", default=3600, help="Wait timeout in seconds") 

58@pass_config 

59def submit_job( 

60 config: Any, 

61 manifest_path: Any, 

62 namespace: Any, 

63 target_region: Any, 

64 dry_run: Any, 

65 check_policy: Any, 

66 label: Any, 

67 wait: Any, 

68 timeout: Any, 

69) -> None: 

70 """Submit a job to GCO. 

71 

72 MANIFEST_PATH can be a YAML file or directory containing YAML files. 

73 """ 

74 formatter = get_output_formatter(config) 

75 job_manager = get_job_manager(config) 

76 

77 # Parse and validate labels at the CLI boundary. Silently dropping a 

78 # malformed value would submit a differently labelled workload than the 

79 # operator requested; values may still contain additional ``=`` bytes. 

80 labels = {} 

81 for lbl in label: 

82 key, separator, value = lbl.partition("=") 

83 if not separator or not key: 

84 raise click.BadParameter( 

85 "labels must use key=value with a non-empty key", 

86 param_hint="--label", 

87 ) 

88 labels[key] = value 

89 

90 if check_policy: 

91 _run_pre_submit_policy_check( 

92 config, 

93 job_manager, 

94 formatter, 

95 manifest_path=manifest_path, 

96 namespace=namespace, 

97 target_region=target_region, 

98 ) 

99 

100 try: 

101 result = job_manager.submit_job( 

102 manifests=manifest_path, 

103 namespace=namespace, 

104 target_region=target_region, 

105 dry_run=dry_run, 

106 labels=labels if labels else None, 

107 ) 

108 

109 if dry_run: 

110 formatter.print_success("Dry run successful - manifests are valid") 

111 else: 

112 formatter.print_success("Job submitted successfully") 

113 

114 # Surface any rename warnings from mapping-shaped API resources. 

115 # Direct kubectl responses contain strings in ``resources``. 

116 resources = result.get("resources", []) if isinstance(result, Mapping) else [] 

117 for resource in resources: 

118 if not isinstance(resource, Mapping): 

119 continue 

120 msg = str(resource.get("message", "")) 

121 if "renamed" in msg.lower() or "still running" in msg.lower(): 

122 formatter.print_warning(msg) 

123 

124 formatter.print(result) 

125 

126 # Wait for completion if requested 

127 if wait and not dry_run: 

128 job_name = _resolve_result_job_name(result) 

129 if job_name: 

130 # The API response tells us exactly where the resource landed 

131 # (may differ from --namespace since the manifest's own value 

132 # takes precedence). Fall back to the CLI flag or the config 

133 # default only if the response didn't include a namespace. 

134 resolved_ns = _resolve_result_namespace( 

135 result, fallback=namespace or config.default_namespace 

136 ) 

137 formatter.print_info(f"Waiting for job {job_name} to complete...") 

138 final_job = job_manager.wait_for_job( 

139 job_name=job_name, 

140 namespace=resolved_ns, 

141 region=target_region, 

142 timeout_seconds=timeout, 

143 ) 

144 formatter.print_success(f"Job completed with status: {final_job.status}") 

145 

146 except Exception as e: 

147 formatter.print_error(f"Failed to submit job: {e}") 

148 sys.exit(1) 

149 

150 

151@jobs.command("submit-direct") 

152@click.argument("manifest_path", type=click.Path(exists=True)) 

153@click.option("--region", "-r", required=True, help="Target region for direct submission") 

154@click.option( 

155 "--namespace", 

156 "-n", 

157 help="Fallback namespace for manifests that don't declare their own", 

158) 

159@click.option("--dry-run", is_flag=True, help="Validate without applying") 

160@click.option("--label", "-l", multiple=True, help="Add labels (key=value)") 

161@click.option("--wait", "-w", is_flag=True, help="Wait for job completion") 

162@click.option("--timeout", default=3600, help="Wait timeout in seconds") 

163@pass_config 

164def submit_job_direct( 

165 config: Any, 

166 manifest_path: Any, 

167 region: Any, 

168 namespace: Any, 

169 dry_run: Any, 

170 label: Any, 

171 wait: Any, 

172 timeout: Any, 

173) -> None: 

174 """Submit a job directly to a regional cluster using kubectl. 

175 

176 This bypasses the API Gateway and submits directly to the EKS cluster. 

177 

178 REQUIREMENTS: 

179 - kubectl installed and in PATH 

180 - EKS access entry configured for your IAM principal 

181 - AWS credentials with eks:DescribeCluster permission 

182 

183 To configure EKS access, run: 

184 

185 aws eks create-access-entry --cluster-name gco-REGION --principal-arn YOUR_ARN 

186 

187 aws eks associate-access-policy --cluster-name gco-REGION \\ 

188 --principal-arn YOUR_ARN \\ 

189 --policy-arn arn:<partition>:eks::aws:cluster-access-policy/AmazonEKSClusterAdminPolicy \\ 

190 --access-scope type=cluster 

191 

192 Examples: 

193 gco jobs submit-direct job.yaml --region us-east-1 

194 gco jobs submit-direct job.yaml -r us-west-2 -n gco-jobs --wait 

195 """ 

196 formatter = get_output_formatter(config) 

197 job_manager = get_job_manager(config) 

198 

199 # Parse and validate labels at the CLI boundary. Silently dropping a 

200 # malformed value would submit a differently labelled workload than the 

201 # operator requested; values may still contain additional ``=`` bytes. 

202 labels = {} 

203 for lbl in label: 

204 key, separator, value = lbl.partition("=") 

205 if not separator or not key: 

206 raise click.BadParameter( 

207 "labels must use key=value with a non-empty key", 

208 param_hint="--label", 

209 ) 

210 labels[key] = value 

211 

212 try: 

213 formatter.print_info(f"Submitting directly to cluster in {region} via kubectl...") 

214 

215 result = job_manager.submit_job_direct( 

216 manifests=manifest_path, 

217 region=region, 

218 namespace=namespace, 

219 dry_run=dry_run, 

220 labels=labels if labels else None, 

221 ) 

222 

223 if dry_run: 

224 formatter.print_success("Dry run successful - manifests are valid") 

225 else: 

226 formatter.print_success(f"Job submitted directly to {region}") 

227 

228 # Surface any warnings (e.g. job was renamed due to name collision) 

229 # without mutating or assuming the shape of the direct result. 

230 warnings = result.get("warnings", []) if isinstance(result, Mapping) else [] 

231 for warning in warnings: 

232 formatter.print_warning(str(warning)) 

233 

234 formatter.print(result) 

235 

236 # Wait for completion if requested 

237 if wait and not dry_run: 

238 job_name = _resolve_result_job_name(result) 

239 if job_name: 

240 resolved_ns = _resolve_result_namespace( 

241 result, fallback=namespace or config.default_namespace 

242 ) 

243 formatter.print_info(f"Waiting for job {job_name} to complete...") 

244 final_job = job_manager.wait_for_job( 

245 job_name=job_name, 

246 namespace=resolved_ns, 

247 region=region, 

248 timeout_seconds=timeout, 

249 ) 

250 formatter.print_success(f"Job completed with status: {final_job.status}") 

251 

252 except Exception as e: 

253 formatter.print_error(f"Failed to submit job directly: {e}") 

254 sys.exit(1) 

255 

256 

257@jobs.command("submit-sqs") 

258@click.argument("manifest_path", type=click.Path(exists=True)) 

259@click.option("--region", "-r", help="Target region (auto-selects optimal if not specified)") 

260@click.option( 

261 "--namespace", 

262 "-n", 

263 help="Fallback namespace for manifests that don't declare their own", 

264) 

265@click.option("--label", "-l", multiple=True, help="Add labels (key=value)") 

266@click.option("--priority", "-p", default=0, help="Job priority (higher = more important)") 

267@click.option("--auto-region", is_flag=True, help="Auto-select optimal region based on capacity") 

268@pass_config 

269def submit_job_sqs( 

270 config: Any, 

271 manifest_path: Any, 

272 region: Any, 

273 namespace: Any, 

274 label: Any, 

275 priority: Any, 

276 auto_region: Any, 

277) -> None: 

278 """Submit a job to a regional SQS queue for processing. 

279 

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

281 - Decouples submission from processing 

282 - Enables KEDA-based autoscaling 

283 - Provides better fault tolerance 

284 

285 If --auto-region is specified, the CLI will analyze capacity across all 

286 regions and submit to the optimal one. 

287 

288 Examples: 

289 gco jobs submit-sqs job.yaml --region us-east-1 

290 gco jobs submit-sqs job.yaml --auto-region 

291 gco jobs submit-sqs job.yaml -r us-west-2 --priority 10 

292 """ 

293 formatter = get_output_formatter(config) 

294 job_manager = get_job_manager(config) 

295 

296 # Parse and validate labels at the CLI boundary. Silently dropping a 

297 # malformed value would submit a differently labelled workload than the 

298 # operator requested; values may still contain additional ``=`` bytes. 

299 labels = {} 

300 for lbl in label: 

301 key, separator, value = lbl.partition("=") 

302 if not separator or not key: 

303 raise click.BadParameter( 

304 "labels must use key=value with a non-empty key", 

305 param_hint="--label", 

306 ) 

307 labels[key] = value 

308 

309 try: 

310 # Auto-select region if requested 

311 if auto_region and not region: 

312 formatter.print_info("Analyzing capacity across regions...") 

313 from ..capacity import get_capacity_checker 

314 

315 checker = get_capacity_checker(config) 

316 recommendation = checker.recommend_region_for_job() 

317 region = recommendation["region"] 

318 formatter.print_info(f"Selected region: {region} ({recommendation['reason']})") 

319 elif not region: 

320 region = config.default_region 

321 

322 formatter.print_info(f"Submitting job to SQS queue in {region}...") 

323 

324 result = job_manager.submit_job_sqs( 

325 manifests=manifest_path, 

326 region=region, 

327 namespace=namespace, 

328 labels=labels if labels else None, 

329 priority=priority, 

330 ) 

331 

332 formatter.print_success(f"Job queued successfully in {region}") 

333 formatter.print(result) 

334 

335 except Exception as e: 

336 formatter.print_error(f"Failed to submit job to SQS: {e}") 

337 sys.exit(1) 

338 

339 

340@jobs.command("queue-status") 

341@click.option("--region", "-r", help="Specific region to check") 

342@click.option("--all-regions", "-a", is_flag=True, help="Check all regions") 

343@pass_config 

344def queue_status(config: Any, region: Any, all_regions: Any) -> None: 

345 """Show job queue status across regions. 

346 

347 Displays the number of pending, in-flight, and failed messages 

348 in the job queues. 

349 

350 Examples: 

351 gco jobs queue-status --region us-east-1 

352 gco jobs queue-status --all-regions 

353 """ 

354 formatter = get_output_formatter(config) 

355 job_manager = get_job_manager(config) 

356 

357 try: 

358 if all_regions: 

359 from ..aws_client import get_aws_client 

360 

361 aws_client = get_aws_client(config) 

362 stacks = aws_client.discover_regional_stacks() 

363 

364 results = [] 

365 for stack_region in stacks: 

366 try: 

367 status = job_manager.get_queue_status(stack_region) 

368 results.append(status) 

369 except Exception as e: 

370 logger.debug("Failed to get queue status for %s: %s", stack_region, e) 

371 continue 

372 

373 if not results: 

374 formatter.print_warning("No queue status available") 

375 return 

376 

377 # Format as table 

378 print("\n REGION PENDING IN-FLIGHT DELAYED DLQ") 

379 print(" " + "-" * 55) 

380 for r in results: 

381 dlq = r.get("dlq_messages", 0) 

382 print( 

383 f" {r['region']:<15} {r['messages_available']:>7} " 

384 f"{r['messages_in_flight']:>9} {r['messages_delayed']:>7} {dlq:>3}" 

385 ) 

386 else: 

387 target_region = region or config.default_region 

388 status = job_manager.get_queue_status(target_region) 

389 formatter.print(status) 

390 

391 except Exception as e: 

392 formatter.print_error(f"Failed to get queue status: {e}") 

393 sys.exit(1) 

394 

395 

396@jobs.command("list") 

397@click.option("--namespace", "-n", help="Filter by namespace") 

398@click.option("--region", "-r", help="Target region (required unless --all-regions)") 

399@click.option("--status", "-s", type=click.Choice(["pending", "running", "succeeded", "failed"])) 

400@click.option("--all-regions", "-a", is_flag=True, help="Query all regions via global API") 

401@click.option("--limit", "-l", default=50, help="Maximum jobs to return") 

402@pass_config 

403def list_jobs( 

404 config: Any, namespace: Any, region: Any, status: Any, all_regions: Any, limit: Any 

405) -> None: 

406 """List jobs in GCO clusters. 

407 

408 You must specify either --region for a specific cluster or --all-regions 

409 to query all clusters via the global aggregation API. 

410 

411 Examples: 

412 gco jobs list --region us-east-1 

413 gco jobs list --all-regions 

414 gco jobs list -r us-west-2 -n gco-jobs --status running 

415 """ 

416 formatter = get_output_formatter(config) 

417 job_manager = get_job_manager(config) 

418 

419 # Require explicit region or --all-regions 

420 if not region and not all_regions: 

421 formatter.print_error("You must specify --region or --all-regions") 

422 formatter.print_info(" Use --region/-r to query a specific cluster") 

423 formatter.print_info(" Use --all-regions/-a to query all clusters") 

424 sys.exit(1) 

425 

426 try: 

427 if all_regions: 

428 # Use global aggregation API 

429 result = job_manager.list_jobs_global( 

430 namespace=namespace, 

431 status=status, 

432 limit=limit, 

433 ) 

434 

435 if config.output_format == "table": 

436 # Print summary 

437 print("\n Global Jobs Summary") 

438 print(" " + "-" * 50) 

439 print(f" Total jobs: {result.get('total', 0)}") 

440 print(f" Regions queried: {result.get('regions_queried', 0)}") 

441 print(f" Regions successful: {result.get('regions_successful', 0)}") 

442 

443 # Print region summaries 

444 if result.get("region_summaries"): 

445 print("\n REGION COUNT TOTAL") 

446 print(" " + "-" * 35) 

447 for r in result["region_summaries"]: 

448 print(f" {r['region']:<15} {r['count']:>5} {r['total']:>5}") 

449 

450 # Print jobs 

451 jobs_data = result.get("jobs", []) 

452 if jobs_data: 

453 print( 

454 "\n NAME NAMESPACE REGION STATUS" 

455 ) 

456 print(" " + "-" * 75) 

457 for job in jobs_data[:limit]: 

458 name = job.get("metadata", {}).get("name", "")[:30] 

459 ns = job.get("metadata", {}).get("namespace", "")[:14] 

460 job_region = job.get("_source_region", "")[:14] 

461 job_status = job.get("computed_status", "unknown")[:10] 

462 print(f" {name:<30} {ns:<15} {job_region:<15} {job_status}") 

463 

464 # Print errors if any 

465 if result.get("errors"): 

466 print("\n Errors:") 

467 for err in result["errors"]: 

468 formatter.print_warning(f" {err['region']}: {err['error']}") 

469 else: 

470 formatter.print(result) 

471 else: 

472 # Query specific region 

473 jobs_list = job_manager.list_jobs( 

474 region=region, namespace=namespace, status=status, all_regions=False 

475 ) 

476 

477 if config.output_format == "table": 

478 print(format_job_table(jobs_list)) 

479 else: 

480 formatter.print(jobs_list) 

481 

482 except Exception as e: 

483 formatter.print_error(f"Failed to list jobs: {e}") 

484 sys.exit(1) 

485 

486 

487def _print_job_placement(job: JobInfo) -> None: 

488 """Print where the job's pods landed, and on what hardware. 

489 

490 The wide generic table renders ``node_labels``/``nodes`` as placeholders, 

491 so the hardware — the reason those fields exist — gets its own block. 

492 """ 

493 if not job.nodes and not job.node_name: 

494 return 

495 

496 print("\n Placement") 

497 print(" " + "-" * 78) 

498 print(" NODE INSTANCE TYPE CAPACITY PODS") 

499 print(" " + "-" * 78) 

500 for node in job.nodes or [{"name": job.node_name, "pods": []}]: 

501 name = str(node.get("name") or "-")[:40] 

502 instance_type = str(node.get("instance_type") or "-")[:17] 

503 capacity_type = str(node.get("capacity_type") or "-")[:10] 

504 pod_count = len(node.get("pods") or []) 

505 print(f" {name:<41} {instance_type:<17} {capacity_type:<10} {pod_count}") 

506 

507 if job.node_labels: 

508 for key in sorted(job.node_labels): 

509 print(f" {key}: {job.node_labels[key]}") 

510 

511 

512def _print_unscheduled_pods(job: JobInfo) -> None: 

513 """Print the pods that have no node yet, and why Kubernetes says so. 

514 

515 The Job's ``status`` says ``running`` as long as a pod exists, placed or 

516 not; this block is what tells an operator the run is actually waiting on 

517 capacity. Absent (not zero) counts come from a bridge that predates the 

518 fields, so nothing is printed rather than a wrong verdict. 

519 """ 

520 if not job.unscheduled_pods and not job.unscheduled: 

521 return 

522 print("\n Unscheduled pods") 

523 print(" " + "-" * 78) 

524 entries = job.unscheduled or [{}] * (job.unscheduled_pods or 0) 

525 for pod in entries: 

526 name = str(pod.get("name") or "-")[:60] 

527 phase = str(pod.get("phase") or "-") 

528 reason = str(pod.get("reason") or "-") 

529 print(f" {name:<61} {phase}") 

530 print(f" reason: {reason}") 

531 if pod.get("since"): 

532 print(f" since: {pod['since']}") 

533 if pod.get("message"): 

534 print(f" message: {pod['message']}") 

535 

536 

537@jobs.command("get") 

538@click.argument("job_name") 

539@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

540@click.option("--region", "-r", required=True, help="Job region (required)") 

541@pass_config 

542def get_job(config: Any, job_name: Any, namespace: Any, region: Any) -> None: 

543 """Get details of a specific job. 

544 

545 Reports the node each pod landed on along with that node's instance type 

546 and spot/on-demand capacity type, so a job authorized to run on a set of 

547 interchangeable instance types shows which one it actually used. Pods that 

548 have no node yet are listed with the scheduler's own reason, so a Job that 

549 is waiting on capacity is distinguishable from one that is training. 

550 

551 Examples: 

552 gco jobs get my-job --region us-east-1 

553 gco jobs get training-job -r us-west-2 -n ml-jobs 

554 """ 

555 formatter = get_output_formatter(config) 

556 job_manager = get_job_manager(config) 

557 

558 try: 

559 job = job_manager.get_job(job_name, namespace, region) 

560 if job: 

561 formatter.print(job) 

562 if config.output_format == "table" and isinstance(job, JobInfo): 

563 _print_job_placement(job) 

564 _print_unscheduled_pods(job) 

565 else: 

566 formatter.print_error(f"Job {job_name} not found") 

567 sys.exit(1) 

568 except Exception as e: 

569 formatter.print_error(f"Failed to get job: {e}") 

570 sys.exit(1) 

571 

572 

573@jobs.command("logs") 

574@click.argument("job_name") 

575@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

576@click.option("--region", "-r", required=True, help="Job region (required)") 

577@click.option("--tail", "-t", default=100, help="Number of lines to show") 

578@click.option( 

579 "--since", "-s", default=24, type=int, help="Hours to look back in CloudWatch (default: 24)" 

580) 

581@click.option("--container", "-c", help="Container name (for multi-container pods)") 

582@click.option( 

583 "--node", 

584 default=0, 

585 type=int, 

586 help="Node rank to fetch for a distributed TrainJob (default: 0)", 

587) 

588@pass_config 

589def get_logs( 

590 config: Any, 

591 job_name: Any, 

592 namespace: Any, 

593 region: Any, 

594 tail: Any, 

595 since: Any, 

596 container: Any, 

597 node: Any, 

598) -> None: 

599 """Get logs from a job. 

600 

601 Fetches logs from the Kubernetes API if the pod is still running. 

602 If the pod is gone, falls back to CloudWatch Logs automatically. 

603 Use --since to control how far back CloudWatch searches. 

604 

605 Kubeflow TrainJobs are resolved automatically; use --node to pick a 

606 node rank other than 0. 

607 

608 Examples: 

609 gco jobs logs my-job --region us-east-1 

610 gco jobs logs training-job -r us-west-2 -n ml-jobs --tail 500 

611 gco jobs logs old-job -r us-east-1 --since 72 

612 gco jobs logs multi-container-job -r us-east-1 --container sidecar 

613 gco jobs logs my-trainjob -r us-east-1 --node 1 

614 """ 

615 formatter = get_output_formatter(config) 

616 job_manager = get_job_manager(config) 

617 

618 try: 

619 logs = job_manager.get_job_logs( 

620 job_name, namespace, region, tail_lines=tail, since_hours=since, node=node 

621 ) 

622 print(logs) 

623 except Exception as e: 

624 formatter.print_error(f"Failed to get logs: {e}") 

625 sys.exit(1) 

626 

627 

628@jobs.command("delete") 

629@click.argument("job_name") 

630@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

631@click.option("--region", "-r", required=True, help="Job region (required)") 

632@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

633@pass_config 

634def delete_job(config: Any, job_name: Any, namespace: Any, region: Any, yes: Any) -> None: 

635 """Delete a job. 

636 

637 Examples: 

638 gco jobs delete my-job --region us-east-1 

639 gco jobs delete old-job -r us-west-2 -n ml-jobs -y 

640 """ 

641 formatter = get_output_formatter(config) 

642 job_manager = get_job_manager(config) 

643 

644 if not yes: 

645 confirm( 

646 f"Delete job {job_name} in namespace {namespace} ({region})?", 

647 abort=True, 

648 err=config.output_format != "table", 

649 ) 

650 

651 try: 

652 result = job_manager.delete_job(job_name, namespace, region) 

653 if config.output_format == "table": 

654 formatter.print_success(f"Job {job_name} deleted") 

655 else: 

656 formatter.print( 

657 { 

658 **result, 

659 "deleted": True, 

660 "job_name": job_name, 

661 "namespace": namespace, 

662 "region": region, 

663 } 

664 ) 

665 except Exception as e: 

666 formatter.print_error(f"Failed to delete job: {e}") 

667 sys.exit(1) 

668 

669 

670@jobs.command("events") 

671@click.argument("job_name") 

672@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

673@click.option("--region", "-r", required=True, help="Job region (required)") 

674@pass_config 

675def get_job_events(config: Any, job_name: Any, namespace: Any, region: Any) -> None: 

676 """Get Kubernetes events for a job. 

677 

678 Shows events related to the job and its pods, useful for debugging 

679 scheduling issues, resource problems, or startup failures. 

680 

681 Examples: 

682 gco jobs events my-job --region us-east-1 

683 gco jobs events training-job -n ml-jobs -r us-west-2 

684 """ 

685 formatter = get_output_formatter(config) 

686 job_manager = get_job_manager(config) 

687 

688 try: 

689 result = job_manager.get_job_events(job_name, namespace, region) 

690 

691 if config.output_format == "table": 

692 events = result.get("events", []) 

693 if not events: 

694 formatter.print_info("No events found for this job") 

695 return 

696 

697 print(f"\n Events for {job_name} ({result.get('count', 0)} total)") 

698 print(" " + "-" * 70) 

699 for event in events: 

700 event_type = event.get("type") or "Normal" 

701 reason = (event.get("reason") or "")[:20] 

702 message = (event.get("message") or "")[:50] 

703 timestamp = (event.get("lastTimestamp") or event.get("firstTimestamp") or "")[:19] 

704 marker = "⚠" if event_type == "Warning" else "✓" 

705 print(f" {marker} [{timestamp}] {reason:<20} {message}") 

706 else: 

707 formatter.print(result) 

708 

709 except Exception as e: 

710 formatter.print_error(f"Failed to get job events: {e}") 

711 sys.exit(1) 

712 

713 

714@jobs.command("pods") 

715@click.argument("job_name") 

716@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

717@click.option("--region", "-r", required=True, help="Job region (required)") 

718@pass_config 

719def get_job_pods(config: Any, job_name: Any, namespace: Any, region: Any) -> None: 

720 """Get pod details for a job. 

721 

722 Shows all pods created by the job with their status, node placement, 

723 the instance type each node is, and container information. 

724 

725 Examples: 

726 gco jobs pods my-job -r us-east-1 

727 gco jobs pods training-job -n ml-jobs -r us-west-2 

728 """ 

729 formatter = get_output_formatter(config) 

730 job_manager = get_job_manager(config) 

731 

732 try: 

733 result = job_manager.get_job_pods(job_name, namespace, region) 

734 

735 if config.output_format == "table": 

736 pods = result.get("pods", []) 

737 if not pods: 

738 formatter.print_info("No pods found for this job") 

739 return 

740 

741 print(f"\n Pods for {job_name} ({result.get('count', 0)} total)") 

742 print(" " + "-" * 96) 

743 print( 

744 " NAME NODE " 

745 "INSTANCE TYPE STATUS RESTARTS" 

746 ) 

747 print(" " + "-" * 96) 

748 for pod in pods: 

749 name = (pod.get("metadata", {}).get("name") or "")[:40] 

750 node = (pod.get("spec", {}).get("nodeName") or "")[:22] 

751 node_info = pod.get("node") or {} 

752 instance_type = str(node_info.get("instance_type") or "-")[:17] 

753 phase = (pod.get("status", {}).get("phase") or "Unknown")[:10] 

754 restarts = sum( 

755 c.get("restartCount", 0) 

756 for c in (pod.get("status", {}).get("containerStatuses") or []) 

757 ) 

758 print(f" {name:<40} {node:<23} {instance_type:<17} {phase:<10} {restarts}") 

759 else: 

760 formatter.print(result) 

761 

762 except Exception as e: 

763 formatter.print_error(f"Failed to get job pods: {e}") 

764 sys.exit(1) 

765 

766 

767@jobs.command("pod-logs") 

768@click.argument("job_name") 

769@click.argument("pod_name") 

770@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

771@click.option("--region", "-r", required=True, help="Job region (required)") 

772@click.option("--tail", "-t", default=100, help="Number of lines to show") 

773@click.option("--container", "-c", help="Container name (for multi-container pods)") 

774@pass_config 

775def get_pod_logs_cmd( 

776 config: Any, 

777 job_name: Any, 

778 pod_name: Any, 

779 namespace: Any, 

780 region: Any, 

781 tail: Any, 

782 container: Any, 

783) -> None: 

784 """Get logs from a specific pod of a job. 

785 

786 Use 'gco jobs pods' first to list available pods, then use this 

787 command to get logs from a specific pod. 

788 

789 Examples: 

790 gco jobs pod-logs my-job my-job-abc123 -r us-east-1 

791 gco jobs pod-logs training-job training-job-xyz789 -r us-west-2 --tail 500 

792 gco jobs pod-logs multi-job multi-job-pod1 -r us-east-1 --container sidecar 

793 """ 

794 formatter = get_output_formatter(config) 

795 job_manager = get_job_manager(config) 

796 

797 try: 

798 result = job_manager.get_pod_logs( 

799 job_name=job_name, 

800 pod_name=pod_name, 

801 namespace=namespace, 

802 region=region, 

803 tail_lines=tail, 

804 container=container, 

805 ) 

806 

807 # Print logs directly 

808 logs = result.get("logs", "") 

809 if logs: 

810 print(logs) 

811 else: 

812 formatter.print_info("No logs available") 

813 

814 except Exception as e: 

815 formatter.print_error(f"Failed to get pod logs: {e}") 

816 sys.exit(1) 

817 

818 

819@jobs.command("metrics") 

820@click.argument("job_name") 

821@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

822@click.option("--region", "-r", required=True, help="Job region (required)") 

823@pass_config 

824def get_job_metrics(config: Any, job_name: Any, namespace: Any, region: Any) -> None: 

825 """Get resource usage metrics for a job. 

826 

827 Shows CPU and memory usage for all pods in the job. Requires 

828 metrics-server to be installed in the cluster. 

829 

830 Examples: 

831 gco jobs metrics my-job --region us-east-1 

832 gco jobs metrics training-job -n ml-jobs -r us-west-2 

833 """ 

834 formatter = get_output_formatter(config) 

835 job_manager = get_job_manager(config) 

836 

837 try: 

838 result = job_manager.get_job_metrics(job_name, namespace, region) 

839 

840 if config.output_format == "table": 

841 summary = result.get("summary", {}) 

842 pods = result.get("pods", []) 

843 

844 print(f"\n Resource Metrics for {job_name}") 

845 print(" " + "-" * 50) 

846 print(f" Total CPU: {summary.get('total_cpu_millicores', 0)}m") 

847 print(f" Total Memory: {summary.get('total_memory_mib', 0):.1f} MiB") 

848 print(f" Pod Count: {summary.get('pod_count', 0)}") 

849 

850 if pods: 

851 print("\n POD CPU(m) MEMORY(MiB)") 

852 print(" " + "-" * 65) 

853 for pod in pods: 

854 pod_name = pod.get("pod_name", "")[:40] 

855 cpu = sum(c.get("cpu_millicores", 0) for c in pod.get("containers", [])) 

856 mem = sum(c.get("memory_mib", 0) for c in pod.get("containers", [])) 

857 print(f" {pod_name:<40} {cpu:>6} {mem:>10.1f}") 

858 else: 

859 formatter.print(result) 

860 

861 except Exception as e: 

862 formatter.print_error(f"Failed to get job metrics: {e}") 

863 sys.exit(1) 

864 

865 

866@jobs.command("retry") 

867@click.argument("job_name") 

868@click.option("--namespace", "-n", default="gco-jobs", help="Job namespace") 

869@click.option("--region", "-r", required=True, help="Job region (required)") 

870@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

871@pass_config 

872def retry_job(config: Any, job_name: Any, namespace: Any, region: Any, yes: Any) -> None: 

873 """Retry a failed job. 

874 

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

876 The original job is preserved for debugging. 

877 

878 Examples: 

879 gco jobs retry failed-job --region us-east-1 

880 gco jobs retry training-job -n ml-jobs -r us-west-2 -y 

881 """ 

882 formatter = get_output_formatter(config) 

883 job_manager = get_job_manager(config) 

884 

885 if not yes: 

886 confirm(f"Retry job {job_name} in namespace {namespace} ({region})?", abort=True) 

887 

888 try: 

889 result = job_manager.retry_job(job_name, namespace, region) 

890 

891 if result.get("success"): 

892 formatter.print_success(f"Job retry created: {result.get('new_job')}") 

893 else: 

894 formatter.print_error(f"Failed to retry job: {result.get('message')}") 

895 sys.exit(1) 

896 

897 formatter.print(result) 

898 

899 except Exception as e: 

900 formatter.print_error(f"Failed to retry job: {e}") 

901 sys.exit(1) 

902 

903 

904@jobs.command("bulk-delete") 

905@click.option("--namespace", "-n", help="Filter by namespace") 

906@click.option("--status", "-s", type=click.Choice(["completed", "succeeded", "failed"])) 

907@click.option("--older-than-days", "-d", type=int, help="Delete jobs older than N days") 

908@click.option("--label-selector", "-l", help="Kubernetes label selector") 

909@click.option("--region", "-r", help="Target region (required unless --all-regions)") 

910@click.option("--all-regions", "-a", is_flag=True, help="Delete across all regions") 

911@click.option("--dry-run", is_flag=True, default=True, help="Only show what would be deleted") 

912@click.option("--execute", is_flag=True, help="Actually delete (disables dry-run)") 

913@click.option("--yes", "-y", is_flag=True, help="Skip confirmation") 

914@pass_config 

915def bulk_delete_jobs( 

916 config: Any, 

917 namespace: Any, 

918 status: Any, 

919 older_than_days: Any, 

920 label_selector: Any, 

921 region: Any, 

922 all_regions: Any, 

923 dry_run: Any, 

924 execute: Any, 

925 yes: Any, 

926) -> None: 

927 """Bulk delete jobs based on filters. 

928 

929 You must specify either --region for a specific cluster or --all-regions 

930 to delete across all clusters. 

931 

932 By default runs in dry-run mode. Use --execute to actually delete. 

933 

934 Examples: 

935 gco jobs bulk-delete --region us-east-1 --status completed --older-than-days 7 

936 gco jobs bulk-delete -r us-west-2 -n gco-jobs -s failed --execute -y 

937 gco jobs bulk-delete --all-regions --status failed --older-than-days 30 --execute 

938 """ 

939 formatter = get_output_formatter(config) 

940 job_manager = get_job_manager(config) 

941 

942 # Require explicit region or --all-regions 

943 if not region and not all_regions: 

944 formatter.print_error("You must specify --region or --all-regions") 

945 formatter.print_info(" Use --region/-r to delete from a specific cluster") 

946 formatter.print_info(" Use --all-regions/-a to delete across all clusters") 

947 sys.exit(1) 

948 

949 # --execute disables dry-run 

950 if execute: 

951 dry_run = False 

952 

953 if not dry_run and not yes: 

954 scope = f"region {region}" if region else "ALL regions" 

955 confirm(f"This will permanently delete matching jobs in {scope}. Continue?", abort=True) 

956 

957 try: 

958 if region: 

959 # Single region delete 

960 result = job_manager.bulk_delete_jobs( 

961 namespace=namespace, 

962 status=status, 

963 older_than_days=older_than_days, 

964 label_selector=label_selector, 

965 region=region, 

966 dry_run=dry_run, 

967 ) 

968 else: 

969 # Global delete across all regions 

970 result = job_manager.bulk_delete_global( 

971 namespace=namespace, 

972 status=status, 

973 older_than_days=older_than_days, 

974 label_selector=label_selector, 

975 dry_run=dry_run, 

976 ) 

977 

978 if dry_run: 

979 formatter.print_info("DRY RUN - No jobs were deleted") 

980 formatter.print_info(f"Would delete {result.get('total_matched', 0)} jobs") 

981 else: 

982 formatter.print_success( 

983 f"Deleted {result.get('deleted_count', result.get('total_deleted', 0))} jobs" 

984 ) 

985 

986 formatter.print(result) 

987 

988 except Exception as e: 

989 formatter.print_error(f"Failed to bulk delete jobs: {e}") 

990 sys.exit(1) 

991 

992 

993@jobs.command("health") 

994@click.option("--region", "-r", help="Target region (required unless --all-regions)") 

995@click.option("--all-regions", "-a", is_flag=True, help="Get health across all regions") 

996@pass_config 

997def job_health(config: Any, region: Any, all_regions: Any) -> None: 

998 """Get health status of GCO clusters. 

999 

1000 You must specify either --region for a specific cluster or --all-regions 

1001 to get health status across all clusters. 

1002 

1003 Examples: 

1004 gco jobs health --region us-east-1 

1005 gco jobs health --all-regions 

1006 """ 

1007 formatter = get_output_formatter(config) 

1008 job_manager = get_job_manager(config) 

1009 

1010 # Require explicit region or --all-regions 

1011 if not region and not all_regions: 

1012 formatter.print_error("You must specify --region or --all-regions") 

1013 formatter.print_info(" Use --region/-r to check a specific cluster") 

1014 formatter.print_info(" Use --all-regions/-a to check all clusters") 

1015 sys.exit(1) 

1016 

1017 try: 

1018 if all_regions: 

1019 result = job_manager.get_global_health() 

1020 

1021 if config.output_format == "table": 

1022 print( 

1023 f"\n Global Health Status: {result.get('overall_status', 'unknown').upper()}" 

1024 ) 

1025 print(" " + "-" * 50) 

1026 print( 

1027 f" Healthy regions: {result.get('healthy_regions', 0)}/{result.get('total_regions', 0)}" 

1028 ) 

1029 

1030 regions = result.get("regions", []) 

1031 if regions: 

1032 print("\n REGION STATUS CLUSTER") 

1033 print(" " + "-" * 50) 

1034 for r in regions: 

1035 status_icon = "✓" if r.get("status") == "healthy" else "✗" 

1036 print( 

1037 f" {status_icon} {r.get('region', ''):<13} {r.get('status', ''):<12} {r.get('cluster_id', '')}" 

1038 ) 

1039 else: 

1040 formatter.print(result) 

1041 else: 

1042 # Single region health check via API 

1043 result = job_manager._aws_client.get_health(region=region) 

1044 formatter.print(result) 

1045 

1046 except Exception as e: 

1047 formatter.print_error(f"Failed to get health status: {e}") 

1048 sys.exit(1) 

1049 

1050 

1051@jobs.command("policy") 

1052@click.option("--region", "-r", required=True, help="Target region") 

1053@pass_config 

1054def job_policy(config: Any, region: Any) -> None: 

1055 """Show the job validation policy a region actually enforces. 

1056 

1057 Reads the deployed manifest processor, not your local cdk.json — those 

1058 diverge whenever the stack was deployed from a different checkout, and CDK 

1059 adds the project's own ECR registries to the trusted list at synth time. 

1060 

1061 Use this before submitting to know whether a manifest will be admitted. 

1062 Three layers must all pass: the front-door policy, the namespace 

1063 LimitRange (per container), and the namespace ResourceQuota (aggregate). 

1064 

1065 Examples: 

1066 gco jobs policy --region us-east-1 

1067 gco jobs policy -r us-east-1 -o json 

1068 """ 

1069 formatter = get_output_formatter(config) 

1070 job_manager = get_job_manager(config) 

1071 

1072 try: 

1073 result = job_manager._aws_client.get_job_validation_policy(region=region) 

1074 

1075 if config.output_format != "table": 

1076 formatter.print(result) 

1077 return 

1078 

1079 policy = result.get("policy", {}) 

1080 caps = policy.get("manifest_caps", {}) 

1081 

1082 print(f"\n Job Validation Policy — {result.get('region', region)}") 

1083 print(f" Cluster: {result.get('cluster_id', 'unknown')}") 

1084 print(" " + "-" * 60) 

1085 print(f" Validation enabled: {policy.get('validation_enabled')}") 

1086 print("\n PER-MANIFEST CAPS (front door)") 

1087 print(f" max CPU: {caps.get('max_cpu_millicores')}m") 

1088 print(f" max memory: {caps.get('max_memory_bytes')} bytes") 

1089 print(f" max GPU: {caps.get('max_gpu_count')}") 

1090 print("\n ALLOWLISTS") 

1091 print(f" namespaces: {', '.join(policy.get('allowed_namespaces', []))}") 

1092 print(f" kinds: {', '.join(policy.get('allowed_kinds', []))}") 

1093 print(f" registries: {', '.join(policy.get('trusted_registries', []))}") 

1094 print(f" dockerhub orgs: {', '.join(policy.get('trusted_dockerhub_orgs', []))}") 

1095 print("\n OTHER CHECKS") 

1096 print( 

1097 f" accelerator toleration required: {policy.get('require_accelerator_toleration')}" 

1098 ) 

1099 print(f" YAML max depth: {policy.get('yaml_max_depth')}") 

1100 

1101 security = policy.get("manifest_security_policy", {}) 

1102 if security: 

1103 enabled = sorted(name for name, on in security.items() if on) 

1104 disabled = sorted(name for name, on in security.items() if not on) 

1105 print(f" blocked: {', '.join(enabled) if enabled else 'none'}") 

1106 print(f" not blocked: {', '.join(disabled) if disabled else 'none'}") 

1107 

1108 enforcement = result.get("cluster_enforcement", {}) 

1109 if enforcement: 

1110 print("\n CLUSTER ENFORCEMENT (live from the Kubernetes API)") 

1111 for namespace, layer in sorted(enforcement.items()): 

1112 status = layer.get("status", "unknown") 

1113 if status != "ok": 

1114 print(f" {namespace}: {status} — {layer.get('reason', 'no reason given')}") 

1115 continue 

1116 for name, hard in sorted(layer.get("resource_quotas", {}).items()): 

1117 print(f" {namespace} ResourceQuota/{name}:") 

1118 for key, value in sorted(hard.items()): 

1119 print(f" {key}: {value}") 

1120 for name, limits in sorted(layer.get("limit_ranges", {}).items()): 

1121 print(f" {namespace} LimitRange/{name}:") 

1122 for limit in limits: 

1123 maximum = limit.get("max", {}) 

1124 if maximum: 

1125 print(f" {limit.get('type', '?')} max: {maximum}") 

1126 print() 

1127 

1128 except Exception as e: 

1129 formatter.print_error(f"Failed to get job validation policy: {e}") 

1130 sys.exit(1) 

1131 

1132 

1133# --------------------------------------------------------------------------- 

1134# Policy pre-checks (advisory) 

1135# --------------------------------------------------------------------------- 

1136 

1137 

1138def _policy_regions(config: Any, requested: tuple[str, ...] | None) -> list[str]: 

1139 """Regions to read policy from: those asked for, else the configured set.""" 

1140 if requested: 

1141 seen: dict[str, None] = {} 

1142 for region in requested: 

1143 seen.setdefault(region, None) 

1144 return list(seen) 

1145 

1146 from ..status import _workload_regions, resolve_regions 

1147 

1148 regions = _workload_regions(resolve_regions(config)) 

1149 return regions or [config.default_region] 

1150 

1151 

1152def _render_verdicts(verdicts: Any, *, indent: str = " ") -> None: 

1153 """Print one line per region plus its reasons, for a terminal reader.""" 

1154 from ..job_policy import VERDICT_ADMIT, VERDICT_REJECT, VERDICT_UNKNOWN 

1155 

1156 marks = {VERDICT_ADMIT: "admit ", VERDICT_REJECT: "REJECT", VERDICT_UNKNOWN: " ? "} 

1157 for verdict in verdicts: 

1158 print(f"{indent}[{marks.get(verdict.verdict, '?')}] {verdict.region}") 

1159 if verdict.verdict == VERDICT_UNKNOWN: 

1160 print(f"{indent} policy unreadable: {verdict.reason}") 

1161 continue 

1162 for issue in verdict.issues: 

1163 where = f"{issue.manifest} " if issue.manifest else "" 

1164 print(f"{indent} {where}[{issue.check}] {issue.message}") 

1165 if verdict.enforcement_gaps: 

1166 print( 

1167 f"{indent} note: live quota/LimitRange unreadable for " 

1168 f"{', '.join(verdict.enforcement_gaps)} — only the front-door " 

1169 f"caps were checked" 

1170 ) 

1171 

1172 

1173def _run_pre_submit_policy_check( 

1174 config: Any, 

1175 job_manager: Any, 

1176 formatter: Any, 

1177 *, 

1178 manifest_path: str, 

1179 namespace: str | None, 

1180 target_region: str | None, 

1181) -> None: 

1182 """Advisory pre-submit check against the target region's live policy. 

1183 

1184 Deliberately non-blocking. The cluster is the authoritative gate and this 

1185 reads a snapshot of its policy over the network, so a check that refused to 

1186 submit on its own opinion would block valid jobs whenever it is stale or 

1187 wrong. It prints what it found and returns. 

1188 

1189 A failure to read the policy is also non-fatal for the same reason: not 

1190 being able to check is not evidence of a problem, and the submission that 

1191 follows would have happened anyway without the flag. 

1192 """ 

1193 from ..job_policy import VERDICT_REJECT, fetch_region_policies, region_verdicts 

1194 

1195 try: 

1196 manifests = job_manager.load_manifests(manifest_path) 

1197 # Match what the server will see: submit_job fills in the namespace for 

1198 # manifests that do not declare one, and the namespace allowlist check 

1199 # is against that resolved value. 

1200 effective_namespace = namespace or config.default_namespace 

1201 for manifest in manifests: 

1202 if isinstance(manifest, dict): 

1203 manifest.setdefault("metadata", {}).setdefault("namespace", effective_namespace) 

1204 

1205 regions = [target_region] if target_region else _policy_regions(config, None) 

1206 policies = fetch_region_policies(job_manager._aws_client, regions) 

1207 verdicts = region_verdicts(manifests, policies) 

1208 except Exception as e: 

1209 formatter.print_warning(f"Policy pre-check could not run ({e}); submitting anyway") 

1210 return 

1211 

1212 rejecting = [v for v in verdicts if v.verdict == VERDICT_REJECT] 

1213 if rejecting: 

1214 formatter.print_warning( 

1215 f"Policy pre-check: {len(rejecting)} of {len(verdicts)} region(s) would " 

1216 f"reject these manifests. Submitting anyway (advisory)." 

1217 ) 

1218 _render_verdicts(verdicts) 

1219 else: 

1220 readable = [v for v in verdicts if v.verdict != "unknown"] 

1221 if readable: 

1222 formatter.print_success( 

1223 f"Policy pre-check: admissible in {', '.join(v.region for v in readable)}" 

1224 ) 

1225 else: 

1226 formatter.print_warning( 

1227 "Policy pre-check: no region's policy could be read; submitting anyway" 

1228 ) 

1229 _render_verdicts(verdicts) 

1230 

1231 

1232def _cdk_job_validation_policy() -> tuple[dict[str, Any], str]: 

1233 """Read ``context.job_validation_policy`` out of the local cdk.json. 

1234 

1235 Returns the raw sub-document and the path it came from. Raises when there is 

1236 no cdk.json to read, because silently checking against shipped defaults 

1237 would look like a successful check of the user's configuration. 

1238 """ 

1239 import json 

1240 from pathlib import Path 

1241 

1242 path = Path.cwd() / "cdk.json" 

1243 if not path.is_file(): 

1244 raise FileNotFoundError( 

1245 f"no cdk.json at {path}; --offline reads the policy from a checkout" 

1246 ) 

1247 context = json.loads(path.read_text(encoding="utf-8")).get("context", {}) or {} 

1248 policy = context.get("job_validation_policy", {}) or {} 

1249 return policy, str(path) 

1250 

1251 

1252def _check_policy_offline( 

1253 config: Any, 

1254 job_manager: Any, 

1255 formatter: Any, 

1256 *, 

1257 manifest_path: str | None, 

1258 namespace: str | None, 

1259 fail_on_reject: bool, 

1260) -> None: 

1261 """Judge manifests against cdk.json, with no AWS calls. 

1262 

1263 Strictly weaker than the online path and says so in its output. Two reasons 

1264 it cannot be authoritative, both real rather than theoretical: a region may 

1265 have been deployed from a different checkout of this file, and CDK appends 

1266 the project's own ECR registry hostnames to ``trusted_registries`` at synth 

1267 time, so a deployed region trusts registries that appear nowhere here. An 

1268 image-provenance rejection offline is therefore a maybe, not a no. 

1269 """ 

1270 import dataclasses 

1271 

1272 from gco.job_admission import JobValidationPolicy 

1273 

1274 from ..job_policy import evaluate_manifests 

1275 

1276 if not manifest_path: 

1277 formatter.print_error("--offline needs a MANIFEST_PATH to check") 

1278 sys.exit(1) 

1279 

1280 try: 

1281 configured, source = _cdk_job_validation_policy() 

1282 policy = JobValidationPolicy.from_cdk_context(configured) 

1283 manifests = job_manager.load_manifests(manifest_path) 

1284 effective_namespace = namespace or config.default_namespace 

1285 for manifest in manifests: 

1286 if isinstance(manifest, dict): 

1287 manifest.setdefault("metadata", {}).setdefault("namespace", effective_namespace) 

1288 issues = evaluate_manifests(manifests, policy) 

1289 except Exception as e: 

1290 formatter.print_error(f"Offline policy check failed: {e}") 

1291 sys.exit(1) 

1292 

1293 caveat = ( 

1294 "checked the CONFIGURED policy from cdk.json, not what any region has " 

1295 "deployed; CDK also adds project ECR registries at synth time, so an " 

1296 "image rejection here may pass in a real region" 

1297 ) 

1298 

1299 if config.output_format != "table": 

1300 formatter.print( 

1301 { 

1302 "source": source, 

1303 "mode": "offline", 

1304 "caveat": caveat, 

1305 "admissible": not issues, 

1306 "issues": [dataclasses.asdict(issue) for issue in issues], 

1307 } 

1308 ) 

1309 if fail_on_reject and issues: 

1310 sys.exit(1) 

1311 return 

1312 

1313 print(f"\n Offline policy check — {source}") 

1314 print(" " + "-" * 60) 

1315 if issues: 

1316 for issue in issues: 

1317 where = f"{issue.manifest} " if issue.manifest else "" 

1318 print(f" {where}[{issue.check}] {issue.message}") 

1319 else: 

1320 print(" no violations of the configured policy") 

1321 print(f"\n Note: {caveat}") 

1322 print() 

1323 

1324 if fail_on_reject and issues: 

1325 sys.exit(1) 

1326 

1327 

1328@jobs.command("check-policy") 

1329@click.argument("manifest_path", type=click.Path(exists=True), required=False) 

1330@click.option( 

1331 "--region", 

1332 "-r", 

1333 "regions", 

1334 multiple=True, 

1335 help="Region to check (repeatable). Defaults to every configured region.", 

1336) 

1337@click.option( 

1338 "--namespace", 

1339 "-n", 

1340 help="Namespace to assume for manifests that don't declare their own", 

1341) 

1342@click.option( 

1343 "--offline", 

1344 is_flag=True, 

1345 help=( 

1346 "Check against cdk.json instead of calling AWS. Needs no credentials, " 

1347 "but reports the CONFIGURED policy, not the deployed one" 

1348 ), 

1349) 

1350@click.option( 

1351 "--fail-on-reject", 

1352 is_flag=True, 

1353 help="Exit 1 when any checked region would reject (after printing)", 

1354) 

1355@pass_config 

1356def check_policy( 

1357 config: Any, 

1358 manifest_path: Any, 

1359 regions: Any, 

1360 namespace: Any, 

1361 offline: Any, 

1362 fail_on_reject: Any, 

1363) -> None: 

1364 """Check which regions would admit a manifest, and compare their policies. 

1365 

1366 Reads the policy each region actually enforces and evaluates the manifest 

1367 against it with the same code the manifest processor runs. Two things this 

1368 answers that submitting cannot: 

1369 

1370 A job can be admissible in one region and over-cap in another. Without 

1371 this you discover that by submitting and being rejected. 

1372 

1373 There are no per-region policy overrides, so any field that differs 

1374 between regions means a region was deployed from a different checkout. 

1375 That stays invisible until a manifest that worked yesterday is refused. 

1376 

1377 Omit MANIFEST_PATH to compare policies without judging anything. 

1378 

1379 --offline answers the same question from cdk.json with no AWS calls, for 

1380 pre-commit hooks and air-gapped checkouts. It is strictly weaker: it reports 

1381 what the file configures, and a deployed region trusts ECR registries the 

1382 file never mentions, so an image rejection may be a false positive. 

1383 

1384 Advisory only: the cluster is the real gate and this reads a snapshot of 

1385 its policy, so it exits 0 unless you pass --fail-on-reject. 

1386 

1387 Examples: 

1388 gco jobs check-policy examples/gpu-job.yaml 

1389 gco jobs check-policy examples/gpu-job.yaml -r us-east-1 -r us-east-2 

1390 gco jobs check-policy # policy comparison only 

1391 gco -o json jobs check-policy examples/gpu-job.yaml 

1392 gco jobs check-policy examples/gpu-job.yaml --offline 

1393 """ 

1394 import dataclasses 

1395 

1396 from ..job_policy import ( 

1397 VERDICT_REJECT, 

1398 detect_policy_drift, 

1399 ecr_augmentation, 

1400 fetch_region_policies, 

1401 region_verdicts, 

1402 registry_drift, 

1403 ) 

1404 

1405 formatter = get_output_formatter(config) 

1406 job_manager = get_job_manager(config) 

1407 

1408 if offline: 

1409 _check_policy_offline( 

1410 config, 

1411 job_manager, 

1412 formatter, 

1413 manifest_path=manifest_path, 

1414 namespace=namespace, 

1415 fail_on_reject=fail_on_reject, 

1416 ) 

1417 return 

1418 

1419 try: 

1420 target_regions = _policy_regions(config, regions) 

1421 policies = fetch_region_policies(job_manager._aws_client, target_regions) 

1422 

1423 manifests: list[dict[str, Any]] = [] 

1424 if manifest_path: 

1425 manifests = job_manager.load_manifests(manifest_path) 

1426 effective_namespace = namespace or config.default_namespace 

1427 for manifest in manifests: 

1428 if isinstance(manifest, dict): 

1429 manifest.setdefault("metadata", {}).setdefault("namespace", effective_namespace) 

1430 

1431 verdicts = region_verdicts(manifests, policies) if manifests else [] 

1432 drift = detect_policy_drift(policies) 

1433 registries = registry_drift(policies) 

1434 if registries is not None: 

1435 drift = [*drift, registries] 

1436 augmentation = ecr_augmentation(policies) 

1437 except Exception as e: 

1438 formatter.print_error(f"Failed to check policy: {e}") 

1439 sys.exit(1) 

1440 

1441 if config.output_format != "table": 

1442 formatter.print( 

1443 { 

1444 "regions": target_regions, 

1445 "unreadable": {entry.region: entry.reason for entry in policies if not entry.ok}, 

1446 "verdicts": [dataclasses.asdict(verdict) for verdict in verdicts], 

1447 "policy_drift": [dataclasses.asdict(item) for item in drift], 

1448 "ecr_augmentation": augmentation, 

1449 } 

1450 ) 

1451 if fail_on_reject and any(v.verdict == VERDICT_REJECT for v in verdicts): 

1452 sys.exit(1) 

1453 return 

1454 

1455 if verdicts: 

1456 print(f"\n Admissibility — {len(verdicts)} region(s)") 

1457 print(" " + "-" * 60) 

1458 _render_verdicts(verdicts) 

1459 

1460 print(f"\n Cross-region policy agreement — {len(policies)} region(s) read") 

1461 print(" " + "-" * 60) 

1462 readable = [entry for entry in policies if entry.ok] 

1463 if len(readable) < 2: 

1464 print(" only one region readable; nothing to compare") 

1465 elif not drift: 

1466 print(f" identical across {', '.join(entry.region for entry in readable)}") 

1467 else: 

1468 print(" these fields differ, which means a region is running a") 

1469 print(" different deployment of cdk.json than the others:") 

1470 for item in drift: 

1471 print(f" {item.field}:") 

1472 for region, value in sorted(item.values.items()): 

1473 print(f" {region}: {value}") 

1474 

1475 added = {region: hosts for region, hosts in augmentation.items() if hosts} 

1476 if added: 

1477 print("\n ECR hostnames CDK added at synth time (absent from cdk.json)") 

1478 print(" " + "-" * 60) 

1479 for region, hosts in sorted(added.items()): 

1480 for host in hosts: 

1481 print(f" {region}: {host}") 

1482 print() 

1483 

1484 if fail_on_reject and any(v.verdict == VERDICT_REJECT for v in verdicts): 

1485 sys.exit(1) 

1486 

1487 

1488@jobs.command("submit-queue") 

1489@click.argument("manifest_path", type=click.Path(exists=True)) 

1490@click.option("--region", "-r", required=True, help="Target region for job execution") 

1491@click.option("--namespace", "-n", default="gco-jobs", help="Kubernetes namespace") 

1492@click.option( 

1493 "--priority", 

1494 "-p", 

1495 type=click.IntRange(0, 100), 

1496 default=0, 

1497 help="Job priority (0-100, higher = more important)", 

1498) 

1499@click.option("--label", "-l", multiple=True, help="Add labels (key=value)") 

1500@pass_config 

1501def submit_job_queue( 

1502 config: Any, manifest_path: Any, region: Any, namespace: Any, priority: Any, label: Any 

1503) -> None: 

1504 """Submit a job to the global DynamoDB queue for regional pickup. 

1505 

1506 Jobs are stored in DynamoDB and picked up by the target region's 

1507 manifest processor. This enables global job submission with 

1508 centralized tracking and status history. 

1509 

1510 This is different from submit-sqs which uses regional SQS queues. 

1511 The DynamoDB queue provides: 

1512 - Global visibility of all queued jobs 

1513 - Status tracking and history 

1514 - Priority-based scheduling 

1515 - Cross-region job management 

1516 

1517 Use 'gco queue list' to view queued jobs and their status. 

1518 

1519 Examples: 

1520 gco jobs submit-queue job.yaml --region us-east-1 

1521 gco jobs submit-queue job.yaml -r us-west-2 --priority 50 

1522 gco jobs submit-queue job.yaml -r us-east-1 -l team=ml -l project=training 

1523 """ 

1524 

1525 from gco.services.manifest_processor import safe_load_yaml 

1526 

1527 formatter = get_output_formatter(config) 

1528 

1529 # Parse and validate labels at the CLI boundary. Silently dropping a 

1530 # malformed value would submit a differently labelled workload than the 

1531 # operator requested; values may still contain additional ``=`` bytes. 

1532 labels = {} 

1533 for lbl in label: 

1534 key, separator, value = lbl.partition("=") 

1535 if not separator or not key: 

1536 raise click.BadParameter( 

1537 "labels must use key=value with a non-empty key", 

1538 param_hint="--label", 

1539 ) 

1540 labels[key] = value 

1541 

1542 try: 

1543 # Load manifest 

1544 with open(manifest_path, encoding="utf-8") as f: 

1545 manifest = safe_load_yaml(f, allow_aliases=False) 

1546 

1547 # Submit via API 

1548 from ..aws_client import get_aws_client 

1549 

1550 aws_client = get_aws_client(config) 

1551 

1552 result = aws_client.call_api( 

1553 method="POST", 

1554 path="/api/v1/queue/jobs", 

1555 region=region, 

1556 body={ 

1557 "manifest": manifest, 

1558 "target_region": region, 

1559 "namespace": namespace, 

1560 "priority": priority, 

1561 "labels": labels if labels else None, 

1562 }, 

1563 ) 

1564 

1565 formatter.print_success(f"Job queued for {region}") 

1566 formatter.print_info("Use 'gco queue list' or 'gco queue get <job_id>' to track status") 

1567 formatter.print(result) 

1568 

1569 except Exception as e: 

1570 formatter.print_error(f"Failed to queue job: {e}") 

1571 sys.exit(1)