Coverage for cli/aws_client.py: 94.88%
372 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-30 21:22 +0000
1"""
2AWS Client utilities for GCO CLI.
4Provides authenticated access to AWS services with SigV4 signing,
5stack discovery, and region management.
6"""
8import json
9import logging
10import time
11from dataclasses import dataclass
12from datetime import datetime
13from typing import Any
14from urllib.parse import quote
16import boto3
17import requests
18from botocore.auth import SigV4Auth
19from botocore.awsrequest import AWSRequest
21from .config import GCOConfig, get_config
23logger = logging.getLogger(__name__)
25# HTTP status codes that are safe to retry (transient failures)
26_RETRYABLE_STATUS_CODES = {429, 502, 503, 504}
27_MAX_RETRIES = 3
28_RETRY_BACKOFF_BASE = 1.0 # seconds
31def _validate_max_attempts(max_attempts: int | None) -> None:
32 """Validate an explicitly supplied request-attempt limit."""
33 if max_attempts is not None and (
34 isinstance(max_attempts, bool) or not isinstance(max_attempts, int) or max_attempts <= 0
35 ):
36 raise ValueError("max_attempts must be a positive integer")
39@dataclass
40class RegionalStack:
41 """Information about a regional GCO stack."""
43 region: str
44 stack_name: str
45 cluster_name: str
46 status: str
47 api_endpoint: str | None = None
48 efs_file_system_id: str | None = None
49 fsx_file_system_id: str | None = None
50 created_time: datetime | None = None
53@dataclass
54class ApiEndpoint:
55 """API Gateway endpoint information."""
57 url: str
58 region: str
59 api_id: str
60 is_regional: bool = False # True if this is a regional API (for private access)
63class GCOAWSClient:
64 """
65 AWS client for GCO operations.
67 Handles:
68 - Stack discovery across regions
69 - Authenticated API requests with SigV4
70 - CloudFormation stack queries
71 - EKS cluster information
72 """
74 def __init__(self, config: GCOConfig | None = None):
75 self.config = config or get_config()
76 self._session = boto3.Session()
77 self._api_endpoint_cache: ApiEndpoint | None = None
78 self._regional_api_cache: dict[str, ApiEndpoint] = {}
79 self._regional_stacks_cache: dict[str, RegionalStack] | None = None
80 self._cache_timestamp: float | None = None
81 self._use_regional_api = getattr(self.config, "use_regional_api", False) is True
83 def _is_cache_valid(self) -> bool:
84 """Check if cache is still valid."""
85 if self._cache_timestamp is None:
86 return False
87 return (time.time() - self._cache_timestamp) < self.config.cache_ttl_seconds
89 def _invalidate_cache(self) -> None:
90 """Invalidate all caches."""
91 self._api_endpoint_cache = None
92 self._regional_api_cache = {}
93 self._regional_stacks_cache = None
94 self._cache_timestamp = None
96 def set_use_regional_api(self, use_regional: bool) -> None:
97 """Set whether to use regional APIs instead of global API.
99 When enabled, API calls will be routed through regional API Gateways
100 that use VPC Lambdas to access internal ALBs. This is required when
101 public access is disabled.
103 Args:
104 use_regional: True to use regional APIs, False for global API
105 """
106 self._use_regional_api = use_regional
108 def get_regional_api_endpoint(
109 self, region: str, force_refresh: bool = False
110 ) -> ApiEndpoint | None:
111 """
112 Get the regional API Gateway endpoint for a specific region.
114 Regional APIs are used when public access is disabled and the ALB
115 is internal-only.
117 Args:
118 region: AWS region
119 force_refresh: Force refresh from CloudFormation
121 Returns:
122 ApiEndpoint with URL and metadata, or None if not found
123 """
124 if not force_refresh and region in self._regional_api_cache and self._is_cache_valid():
125 return self._regional_api_cache[region]
127 cfn = self._session.client("cloudformation", region_name=region)
128 stack_name = f"{self.config.project_name}-regional-api-{region}"
130 try:
131 response = cfn.describe_stacks(StackName=stack_name)
132 stack = response["Stacks"][0]
134 api_url = None
135 for output in stack.get("Outputs", []):
136 if output["OutputKey"] == "RegionalApiEndpoint":
137 api_url = output["OutputValue"].rstrip("/")
138 break
140 if not api_url:
141 return None
143 # Extract API ID from URL
144 api_id = api_url.split(".")[0].replace("https://", "")
146 endpoint = ApiEndpoint(url=api_url, region=region, api_id=api_id, is_regional=True)
147 self._regional_api_cache[region] = endpoint
148 return endpoint
150 except cfn.exceptions.ClientError:
151 # Stack doesn't exist
152 return None
153 except Exception as e:
154 logger.debug("Failed to get regional API endpoint for %s: %s", region, e)
155 return None
157 def get_api_endpoint(self, force_refresh: bool = False) -> ApiEndpoint:
158 """
159 Get the global API Gateway endpoint.
161 Args:
162 force_refresh: Force refresh from CloudFormation
164 Returns:
165 ApiEndpoint with URL and metadata
166 """
167 if not force_refresh and self._api_endpoint_cache and self._is_cache_valid():
168 return self._api_endpoint_cache
170 cfn = self._session.client("cloudformation", region_name=self.config.api_gateway_region)
172 try:
173 response = cfn.describe_stacks(StackName=self.config.api_gateway_stack_name)
174 stack = response["Stacks"][0]
176 api_url = None
177 for output in stack.get("Outputs", []):
178 if output["OutputKey"] == "ApiEndpoint": 178 ↛ 177line 178 didn't jump to line 177 because the condition on line 178 was always true
179 api_url = output["OutputValue"].rstrip("/")
180 break
182 if not api_url:
183 raise ValueError(
184 f"ApiEndpoint not found in stack {self.config.api_gateway_stack_name}"
185 )
187 # Extract API ID from URL
188 # Format: https://{api-id}.execute-api.{region}.amazonaws.com/prod
189 api_id = api_url.split(".")[0].replace("https://", "")
191 self._api_endpoint_cache = ApiEndpoint(
192 url=api_url, region=self.config.api_gateway_region, api_id=api_id
193 )
194 self._cache_timestamp = time.time()
196 return self._api_endpoint_cache
198 except Exception as e:
199 raise RuntimeError(f"Failed to get API endpoint: {e}") from e
201 def discover_regional_stacks(self, force_refresh: bool = False) -> dict[str, RegionalStack]:
202 """
203 Discover all regional GCO stacks.
205 Checks configured regions from cdk.json first for fast discovery,
206 then falls back to scanning all AWS regions if no stacks are found.
208 Args:
209 force_refresh: Force refresh from CloudFormation
211 Returns:
212 Dictionary mapping region to RegionalStack
213 """
214 if not force_refresh and self._regional_stacks_cache and self._is_cache_valid():
215 return self._regional_stacks_cache
217 regional_stacks: dict[str, RegionalStack] = {}
219 # Try configured regions first (fast path)
220 configured_regions = self._get_configured_regions()
221 if configured_regions: 221 ↛ 228line 221 didn't jump to line 228 because the condition on line 221 was always true
222 for region in configured_regions:
223 stack = self._probe_regional_stack(region)
224 if stack:
225 regional_stacks[region] = stack
227 # If we found stacks in configured regions, skip the full scan
228 if not regional_stacks:
229 # Fall back to scanning all regions
230 logger.debug("No stacks found in configured regions, scanning all AWS regions")
231 ec2 = self._session.client("ec2", region_name="us-east-1")
232 regions_response = ec2.describe_regions()
233 all_regions = [r["RegionName"] for r in regions_response["Regions"]]
235 for region in all_regions:
236 if region in configured_regions:
237 continue # Already checked
238 stack = self._probe_regional_stack(region)
239 if stack: 239 ↛ 235line 239 didn't jump to line 235 because the condition on line 239 was always true
240 regional_stacks[region] = stack
242 self._regional_stacks_cache = regional_stacks
243 self._cache_timestamp = time.time()
245 return regional_stacks
247 def _get_configured_regions(self) -> list[str]:
248 """Get the list of configured deployment regions from cdk.json."""
249 from .config import _load_cdk_json
251 cdk_regions = _load_cdk_json()
252 regions: list[str] = cdk_regions.get("regional", [])
253 return regions
255 def _probe_regional_stack(self, region: str) -> RegionalStack | None:
256 """Probe a single region for a GCO regional stack.
258 Args:
259 region: AWS region to check
261 Returns:
262 RegionalStack if found, None otherwise
263 """
264 try:
265 cfn = self._session.client("cloudformation", region_name=region)
266 stack_name = f"{self.config.regional_stack_prefix}-{region}"
268 try:
269 response = cfn.describe_stacks(StackName=stack_name)
270 stack = response["Stacks"][0]
272 outputs = {o["OutputKey"]: o["OutputValue"] for o in stack.get("Outputs", [])}
274 return RegionalStack(
275 region=region,
276 stack_name=stack_name,
277 cluster_name=outputs.get("ClusterName", f"{self.config.project_name}-{region}"),
278 status=stack["StackStatus"],
279 efs_file_system_id=outputs.get("EfsFileSystemId"),
280 fsx_file_system_id=outputs.get("FsxFileSystemId"),
281 created_time=stack.get("CreationTime"),
282 )
283 except cfn.exceptions.ClientError:
284 return None
286 except Exception as e:
287 logger.debug("Failed to get regional stack info for %s: %s", region, e)
288 return None
290 def get_regional_stack(self, region: str) -> RegionalStack | None:
291 """Get information about a specific regional stack."""
292 stacks = self.discover_regional_stacks()
293 return stacks.get(region)
295 def call_api(
296 self,
297 method: str,
298 path: str,
299 region: str | None = None,
300 body: dict[str, Any] | None = None,
301 params: dict[str, str] | None = None,
302 *,
303 max_attempts: int | None = None,
304 ) -> dict[str, Any]:
305 """
306 Make an API call and return the JSON response.
308 This is a convenience wrapper around make_authenticated_request.
310 Args:
311 method: HTTP method (GET, POST, DELETE, etc.)
312 path: API path (e.g., /api/v1/templates)
313 region: Target region for the request
314 body: Request body (will be JSON encoded)
315 params: Query parameters
316 max_attempts: Maximum attempts for read-only requests. Mutating
317 requests always make exactly one attempt. Defaults to the
318 existing retry limit.
320 Returns:
321 JSON response as dictionary
323 Raises:
324 RuntimeError: If the request fails with a descriptive error message
325 ValueError: If max_attempts is not a positive integer
326 """
327 _validate_max_attempts(max_attempts)
329 # Add URL-encoded query parameters to path
330 if params:
331 encoded_pairs = [
332 f"{quote(str(k), safe='')}={quote(str(v), safe='')}"
333 for k, v in params.items()
334 if v is not None
335 ]
336 if encoded_pairs: 336 ↛ 339line 336 didn't jump to line 339 because the condition on line 336 was always true
337 path = f"{path}?{'&'.join(encoded_pairs)}"
339 response = self.make_authenticated_request(
340 method=method,
341 path=path,
342 body=body,
343 target_region=region,
344 max_attempts=max_attempts,
345 )
347 if not response.ok:
348 error_msg = f"{response.status_code} {response.reason}"
349 try:
350 error_data = response.json()
351 if "error" in error_data:
352 error_msg = error_data["error"]
353 elif "message" in error_data:
354 error_msg = error_data["message"]
355 elif "detail" in error_data:
356 error_msg = error_data["detail"]
357 except json.JSONDecodeError, KeyError:
358 error_msg = response.text or error_msg
359 raise RuntimeError(f"API request failed: {error_msg}")
361 result: dict[str, Any] = response.json()
362 return result
364 def make_authenticated_request(
365 self,
366 method: str,
367 path: str,
368 body: dict[str, Any] | None = None,
369 headers: dict[str, str] | None = None,
370 target_region: str | None = None,
371 stream: bool = False,
372 *,
373 max_attempts: int | None = None,
374 ) -> requests.Response:
375 """
376 Make an authenticated request to the GCO API.
378 Requests with ``target_region`` always use that region's API Gateway so
379 exact region pinning is enforced without sending routing headers through
380 the global endpoint. Unpinned requests use the global API unless regional
381 mode is enabled, in which case they use ``config.default_region``. Global
382 aggregation paths are unavailable in regional mode. Missing regional
383 endpoints fail closed instead of silently using the global API.
385 Args:
386 method: HTTP method (GET, POST, etc.)
387 path: API path (e.g., /api/v1/manifests)
388 body: Request body (will be JSON encoded)
389 headers: Additional headers
390 target_region: Exact region for the request. When set, the request
391 uses that region's API Gateway directly.
392 stream: Leave the response body unbuffered for incremental consumption.
393 max_attempts: Maximum attempts for read-only requests. Mutating
394 requests always make exactly one attempt. Defaults to the
395 existing retry limit.
397 Returns:
398 requests.Response object
400 Raises:
401 ValueError: If max_attempts is not a positive integer
402 """
403 _validate_max_attempts(max_attempts)
405 # Global aggregation endpoints exist only on the global API. Regional
406 # mode must reject them clearly rather than send a global path to a
407 # regional bridge and surface an opaque 404.
408 if self._use_regional_api and (
409 path == "/api/v1/global" or path.startswith("/api/v1/global/")
410 ):
411 raise ValueError("Global API operations are unavailable in regional API mode")
413 # Strict regional mode has no global fallback. Resolve an omitted
414 # optional ``--region`` to the configured default, then require a real
415 # non-blank Region before attempting endpoint discovery. Keep this as a
416 # separate branch so ``get_api_endpoint`` is unreachable in strict mode.
417 if self._use_regional_api:
418 effective_region = (
419 target_region if target_region is not None else self.config.default_region
420 )
421 if not isinstance(effective_region, str) or not effective_region.strip():
422 raise ValueError(
423 "Regional API mode requires a non-empty target or default AWS region"
424 )
425 target_region = effective_region.strip()
426 endpoint = self.get_regional_api_endpoint(target_region)
427 elif target_region:
428 # Exact region pinning always uses the regional API. The global
429 # proxy is intentionally not VPC-attached and rejects
430 # X-GCO-Target-Region, so it cannot honor a pin without pretending
431 # success or weakening isolation.
432 endpoint = self.get_regional_api_endpoint(target_region)
433 else:
434 endpoint = self.get_api_endpoint()
436 if endpoint is None:
437 # Only regional discovery returns None; the global endpoint helper
438 # either returns an endpoint or raises its own actionable error.
439 assert target_region is not None
440 raise RuntimeError(
441 f"Regional API endpoint is not deployed in {target_region}; "
442 "exact region routing requires the regional API bridge"
443 )
445 url = f"{endpoint.url}{path}"
447 # Normalize the method once. Only read-only operations are eligible
448 # for automatic replay; retrying POST/PUT/PATCH/DELETE can duplicate a
449 # model invocation or state transition after an ambiguous response.
450 method = method.upper()
451 retryable_method = method in {"GET", "HEAD", "OPTIONS"}
453 # Prepare headers without mutating the caller's mapping.
454 request_headers = dict(headers or {})
455 request_headers["Content-Type"] = "application/json"
457 # Prepare body
458 body_str = json.dumps(body) if body is not None else ""
460 # Create AWS request for signing
461 aws_request = AWSRequest(method=method, url=url, headers=request_headers, data=body_str)
463 # Sign the request with the endpoint's region
464 credentials = self._session.get_credentials()
465 if credentials is None:
466 raise RuntimeError(
467 "No AWS credentials found. Configure credentials via environment variables, "
468 "~/.aws/credentials, IAM role, or SSO (aws sso login)."
469 )
470 SigV4Auth(credentials, "execute-api", endpoint.region).add_auth(aws_request)
472 # Read-only requests retry transient failures and may refresh expired
473 # SigV4 credentials once. Mutating requests receive exactly one network
474 # attempt and return its response unchanged.
475 last_response = None
476 retried_auth = False
477 attempt_limit = (
478 (max_attempts if max_attempts is not None else _MAX_RETRIES) if retryable_method else 1
479 )
480 for attempt in range(attempt_limit):
481 response = requests.request(
482 method=method,
483 url=url,
484 headers=dict(aws_request.headers),
485 data=body_str,
486 timeout=(10, 310) if stream else 30,
487 stream=stream,
488 )
489 last_response = response
491 # A read-only 403 may mean an expired SigV4 signature. Refresh and
492 # retry once; mutating requests are never replayed automatically.
493 if (
494 response.status_code == 403
495 and retryable_method
496 and not retried_auth
497 and attempt < attempt_limit - 1
498 ):
499 retried_auth = True
500 logger.warning(
501 "Request to %s returned 403, refreshing credentials and retrying",
502 path,
503 )
504 # Force a new session to pick up refreshed credentials
505 self._session = boto3.Session()
506 aws_request = AWSRequest(
507 method=method, url=url, headers=request_headers, data=body_str
508 )
509 credentials = self._session.get_credentials()
510 if credentials is None: 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true
511 return response # No credentials available, return the 403
512 SigV4Auth(credentials, "execute-api", endpoint.region).add_auth(aws_request)
513 response.close()
514 continue
516 if response.status_code not in _RETRYABLE_STATUS_CODES or not retryable_method:
517 return response
519 # Retryable read-only error — back off and retry.
520 if attempt < attempt_limit - 1:
521 wait_time = _RETRY_BACKOFF_BASE * (2**attempt)
522 logger.warning(
523 "Request to %s returned %d, retrying in %.1fs (attempt %d/%d)",
524 path,
525 response.status_code,
526 wait_time,
527 attempt + 1,
528 attempt_limit,
529 )
530 response.close()
531 time.sleep(wait_time)
533 # Re-sign the request for the retry (credentials/time may have changed)
534 aws_request = AWSRequest(
535 method=method, url=url, headers=request_headers, data=body_str
536 )
537 credentials = self._session.get_credentials()
538 if credentials is None: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 return last_response
540 SigV4Auth(credentials, "execute-api", endpoint.region).add_auth(aws_request)
542 # All retries exhausted — return the last response
543 return last_response # type: ignore[return-value]
545 def submit_manifests(
546 self,
547 manifests: list[dict[str, Any]],
548 namespace: str | None = None,
549 target_region: str | None = None,
550 dry_run: bool = False,
551 ) -> dict[str, Any]:
552 """
553 Submit manifests to the GCO API.
555 Args:
556 manifests: List of Kubernetes manifest dictionaries
557 namespace: Default namespace for manifests
558 target_region: Target region for job execution
559 dry_run: If True, validate without applying
561 Returns:
562 API response dictionary
564 Raises:
565 RuntimeError: If submission fails with descriptive error message
566 """
567 body = {"manifests": manifests, "dry_run": dry_run}
569 if namespace: 569 ↛ 572line 569 didn't jump to line 572 because the condition on line 569 was always true
570 body["namespace"] = namespace
572 response = self.make_authenticated_request(
573 method="POST", path="/api/v1/manifests", body=body, target_region=target_region
574 )
576 # Parse response and provide descriptive error messages
577 if not response.ok:
578 error_msg = f"{response.status_code} {response.reason}"
579 try:
580 error_data = response.json()
581 # Extract meaningful error details from the response
582 if "resources" in error_data:
583 failed = [r for r in error_data["resources"] if r.get("status") == "failed"]
584 if failed: 584 ↛ 595line 584 didn't jump to line 595 because the condition on line 584 was always true
585 messages = [
586 f"{r.get('name')}: {r.get('message', 'Unknown error')}" for r in failed
587 ]
588 error_msg = "; ".join(messages)
589 elif "error" in error_data: 589 ↛ 590line 589 didn't jump to line 590 because the condition on line 589 was never true
590 error_msg = error_data["error"]
591 elif "message" in error_data: 591 ↛ 595line 591 didn't jump to line 595 because the condition on line 591 was always true
592 error_msg = error_data["message"]
593 except json.JSONDecodeError, KeyError:
594 error_msg = response.text or error_msg
595 raise RuntimeError(error_msg)
597 result: dict[str, Any] = response.json()
598 return result
600 def get_jobs(
601 self,
602 region: str | None = None,
603 namespace: str | None = None,
604 status: str | None = None,
605 ) -> list[dict[str, Any]]:
606 """
607 Get jobs from GCO clusters.
609 Args:
610 region: Specific region to query (None for all regions)
611 namespace: Filter by namespace
612 status: Filter by status (running, completed, failed)
614 Returns:
615 List of job information dictionaries
616 """
617 params = []
618 if namespace: 618 ↛ 620line 618 didn't jump to line 620 because the condition on line 618 was always true
619 params.append(f"namespace={namespace}")
620 if status:
621 params.append(f"status={status}")
623 query_string = f"?{'&'.join(params)}" if params else ""
625 response = self.make_authenticated_request(
626 method="GET", path=f"/api/v1/jobs{query_string}", target_region=region
627 )
629 response.raise_for_status()
630 result: list[dict[str, Any]] = response.json()
631 return result
633 def get_job_details(
634 self, job_name: str, namespace: str, region: str | None = None
635 ) -> dict[str, Any]:
636 """
637 Get detailed information about a specific job.
639 Args:
640 job_name: Name of the job
641 namespace: Namespace of the job
642 region: Region where the job is running
644 Returns:
645 Job details dictionary
646 """
647 response = self.make_authenticated_request(
648 method="GET", path=f"/api/v1/jobs/{namespace}/{job_name}", target_region=region
649 )
651 response.raise_for_status()
652 result: dict[str, Any] = response.json()
653 return result
655 def get_job_logs(
656 self, job_name: str, namespace: str, region: str | None = None, tail_lines: int = 100
657 ) -> str:
658 """
659 Get logs from a job.
661 Args:
662 job_name: Name of the job
663 namespace: Namespace of the job
664 region: Region where the job is running
665 tail_lines: Number of lines to return from the end
667 Returns:
668 Log content as string
669 """
670 response = self.make_authenticated_request(
671 method="GET",
672 path=f"/api/v1/jobs/{namespace}/{job_name}/logs?tail={tail_lines}",
673 target_region=region,
674 )
676 if not response.ok:
677 # Try to extract a useful error message from the response body
678 try:
679 error_data = response.json()
680 detail = error_data.get("detail", response.reason)
681 except Exception:
682 detail = response.text or response.reason
683 raise RuntimeError(detail)
685 return str(response.json().get("logs", ""))
687 def delete_job(
688 self,
689 job_name: str,
690 namespace: str,
691 region: str | None = None,
692 expected_uid: str | None = None,
693 ) -> dict[str, Any]:
694 """
695 Delete a job.
697 Args:
698 job_name: Name of the job
699 namespace: Namespace of the job
700 region: Region where the job is running
702 Returns:
703 Deletion result dictionary
704 """
705 path = f"/api/v1/jobs/{quote(namespace, safe='')}/{quote(job_name, safe='')}"
706 if expected_uid is not None: 706 ↛ 708line 706 didn't jump to line 708 because the condition on line 706 was always true
707 path += f"?expected_uid={quote(expected_uid, safe='')}"
708 response = self.make_authenticated_request(
709 method="DELETE",
710 path=path,
711 target_region=region,
712 )
714 response.raise_for_status()
715 result: dict[str, Any] = response.json()
716 return result
718 def get_regional_alb_endpoint(self, region: str) -> str | None:
719 """
720 Get the ALB endpoint for a specific region.
722 Args:
723 region: AWS region
725 Returns:
726 ALB DNS name or None if not found
727 """
728 stack = self.get_regional_stack(region)
729 if not stack: 729 ↛ 730line 729 didn't jump to line 730 because the condition on line 729 was never true
730 return None
732 cfn = self._session.client("cloudformation", region_name=region)
733 try:
734 response = cfn.describe_stacks(StackName=stack.stack_name)
735 stack_data = response["Stacks"][0]
736 outputs = {o["OutputKey"]: o["OutputValue"] for o in stack_data.get("Outputs", [])}
737 return outputs.get("AlbDnsName") or outputs.get("LoadBalancerDnsName")
738 except Exception as e:
739 logger.debug("Failed to get ALB DNS for %s: %s", region, e)
740 return None
742 # =========================================================================
743 # Global Aggregation Methods (Cross-Region)
744 # =========================================================================
746 def get_global_jobs(
747 self,
748 namespace: str | None = None,
749 status: str | None = None,
750 limit: int = 50,
751 ) -> dict[str, Any]:
752 """
753 Get jobs across all regions via the global aggregation API.
755 Args:
756 namespace: Filter by namespace
757 status: Filter by status
758 limit: Maximum jobs to return
760 Returns:
761 Aggregated job list with region information
762 """
763 params = [f"limit={limit}"]
764 if namespace: 764 ↛ 766line 764 didn't jump to line 766 because the condition on line 764 was always true
765 params.append(f"namespace={namespace}")
766 if status: 766 ↛ 769line 766 didn't jump to line 769 because the condition on line 766 was always true
767 params.append(f"status={status}")
769 query_string = f"?{'&'.join(params)}"
771 response = self.make_authenticated_request(
772 method="GET", path=f"/api/v1/global/jobs{query_string}"
773 )
775 response.raise_for_status()
776 result: dict[str, Any] = response.json()
777 return result
779 def get_global_health(self) -> dict[str, Any]:
780 """
781 Get health status across all regions.
783 Returns:
784 Aggregated health status from all regional clusters
785 """
786 response = self.make_authenticated_request(method="GET", path="/api/v1/global/health")
788 response.raise_for_status()
789 result: dict[str, Any] = response.json()
790 return result
792 def get_global_status(self) -> dict[str, Any]:
793 """
794 Get cluster status across all regions.
796 Returns:
797 Aggregated status from all regional clusters
798 """
799 response = self.make_authenticated_request(method="GET", path="/api/v1/global/status")
801 response.raise_for_status()
802 result: dict[str, Any] = response.json()
803 return result
805 def bulk_delete_global(
806 self,
807 namespace: str | None = None,
808 status: str | None = None,
809 older_than_days: int | None = None,
810 label_selector: str | None = None,
811 dry_run: bool = True,
812 ) -> dict[str, Any]:
813 """
814 Bulk delete jobs across all regions.
816 Args:
817 namespace: Filter by namespace
818 status: Filter by status
819 older_than_days: Delete jobs older than N days
820 label_selector: Kubernetes label selector
821 dry_run: If True, only return what would be deleted
823 Returns:
824 Deletion results from all regions
825 """
826 body: dict[str, Any] = {"dry_run": dry_run}
827 if namespace:
828 body["namespace"] = namespace
829 if status: 829 ↛ 831line 829 didn't jump to line 831 because the condition on line 829 was always true
830 body["status"] = status
831 if older_than_days:
832 body["older_than_days"] = older_than_days
833 if label_selector:
834 body["label_selector"] = label_selector
836 response = self.make_authenticated_request(
837 method="DELETE", path="/api/v1/global/jobs", body=body
838 )
840 response.raise_for_status()
841 result: dict[str, Any] = response.json()
842 return result
844 # =========================================================================
845 # Regional Job Operations (New API Endpoints)
846 # =========================================================================
848 def get_job_events(self, job_name: str, namespace: str, region: str) -> dict[str, Any]:
849 """
850 Get Kubernetes events for a job.
852 Args:
853 job_name: Name of the job
854 namespace: Namespace of the job
855 region: Region where the job is running
857 Returns:
858 Events related to the job
859 """
860 response = self.make_authenticated_request(
861 method="GET",
862 path=f"/api/v1/jobs/{namespace}/{job_name}/events",
863 target_region=region,
864 )
866 response.raise_for_status()
867 result: dict[str, Any] = response.json()
868 return result
870 def get_job_pods(self, job_name: str, namespace: str, region: str) -> dict[str, Any]:
871 """
872 Get pods for a job.
874 Args:
875 job_name: Name of the job
876 namespace: Namespace of the job
877 region: Region where the job is running
879 Returns:
880 Pod details for the job
881 """
882 response = self.make_authenticated_request(
883 method="GET",
884 path=f"/api/v1/jobs/{namespace}/{job_name}/pods",
885 target_region=region,
886 )
888 response.raise_for_status()
889 result: dict[str, Any] = response.json()
890 return result
892 def get_pod_logs(
893 self,
894 job_name: str,
895 pod_name: str,
896 namespace: str,
897 region: str,
898 tail_lines: int = 100,
899 container: str | None = None,
900 ) -> dict[str, Any]:
901 """
902 Get logs from a specific pod of a job.
904 Args:
905 job_name: Name of the job
906 pod_name: Name of the pod
907 namespace: Namespace of the job
908 region: Region where the job is running
909 tail_lines: Number of lines to return from the end
910 container: Container name (for multi-container pods)
912 Returns:
913 Pod logs response
914 """
915 params = [f"tail={tail_lines}"]
916 if container:
917 params.append(f"container={container}")
919 query_string = f"?{'&'.join(params)}"
921 response = self.make_authenticated_request(
922 method="GET",
923 path=f"/api/v1/jobs/{namespace}/{job_name}/pods/{pod_name}/logs{query_string}",
924 target_region=region,
925 )
927 response.raise_for_status()
928 result: dict[str, Any] = response.json()
929 return result
931 def get_job_metrics(self, job_name: str, namespace: str, region: str) -> dict[str, Any]:
932 """
933 Get resource metrics for a job.
935 Args:
936 job_name: Name of the job
937 namespace: Namespace of the job
938 region: Region where the job is running
940 Returns:
941 Resource usage metrics for the job's pods
942 """
943 response = self.make_authenticated_request(
944 method="GET",
945 path=f"/api/v1/jobs/{namespace}/{job_name}/metrics",
946 target_region=region,
947 )
949 response.raise_for_status()
950 result: dict[str, Any] = response.json()
951 return result
953 def retry_job(self, job_name: str, namespace: str, region: str) -> dict[str, Any]:
954 """
955 Retry a failed job.
957 Creates a new job from the failed job's spec with a new name.
959 Args:
960 job_name: Name of the failed job
961 namespace: Namespace of the job
962 region: Region where the job is running
964 Returns:
965 Result with new job name
966 """
967 response = self.make_authenticated_request(
968 method="POST",
969 path=f"/api/v1/jobs/{namespace}/{job_name}/retry",
970 target_region=region,
971 )
973 response.raise_for_status()
974 result: dict[str, Any] = response.json()
975 return result
977 def bulk_delete_jobs(
978 self,
979 namespace: str | None = None,
980 status: str | None = None,
981 older_than_days: int | None = None,
982 label_selector: str | None = None,
983 region: str | None = None,
984 dry_run: bool = True,
985 ) -> dict[str, Any]:
986 """
987 Bulk delete jobs in a region.
989 Args:
990 namespace: Filter by namespace
991 status: Filter by status
992 older_than_days: Delete jobs older than N days
993 label_selector: Kubernetes label selector
994 region: Target region
995 dry_run: If True, only return what would be deleted
997 Returns:
998 Deletion results
999 """
1000 body: dict[str, Any] = {"dry_run": dry_run}
1001 if namespace:
1002 body["namespace"] = namespace
1003 if status:
1004 body["status"] = status
1005 if older_than_days:
1006 body["older_than_days"] = older_than_days
1007 if label_selector:
1008 body["label_selector"] = label_selector
1010 response = self.make_authenticated_request(
1011 method="DELETE", path="/api/v1/jobs", body=body, target_region=region
1012 )
1014 response.raise_for_status()
1015 result: dict[str, Any] = response.json()
1016 return result
1018 def get_health(self, region: str) -> dict[str, Any]:
1019 """
1020 Get health status for a specific region.
1022 Args:
1023 region: Target region
1025 Returns:
1026 Health status for the regional cluster
1027 """
1028 response = self.make_authenticated_request(
1029 method="GET", path="/api/v1/health", target_region=region
1030 )
1032 response.raise_for_status()
1033 result: dict[str, Any] = response.json()
1034 return result
1037def get_aws_client(config: GCOConfig | None = None) -> GCOAWSClient:
1038 """Get a configured AWS client instance."""
1039 return GCOAWSClient(config)