awslabs.eks-mcp-server 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,506 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
4
+ # with the License. A copy of the License is located at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
9
+ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
10
+ # and limitations under the License.
11
+
12
+ """Kubernetes API client for the EKS MCP Server."""
13
+
14
+ import base64
15
+ import os
16
+ import tempfile
17
+ from awslabs.eks_mcp_server.models import Operation
18
+ from loguru import logger
19
+ from typing import Any, Dict, List, Optional
20
+
21
+
22
+ class K8sApis:
23
+ """Class for managing Kubernetes API client.
24
+
25
+ This class provides a simplified interface for interacting with the Kubernetes API
26
+ using the official Kubernetes Python client.
27
+ """
28
+
29
+ def __init__(self, endpoint, token, ca_data):
30
+ """Initialize Kubernetes API client.
31
+
32
+ Args:
33
+ endpoint: Kubernetes API endpoint
34
+ token: Authentication token
35
+ ca_data: CA certificate data (base64 encoded) - required for SSL verification
36
+ """
37
+ try:
38
+ from kubernetes import client, dynamic
39
+
40
+ configuration = client.Configuration()
41
+ configuration.host = endpoint
42
+ configuration.api_key = {'authorization': f'Bearer {token}'}
43
+
44
+ # Store the CA cert file path for cleanup
45
+ self._ca_cert_file_path = None
46
+
47
+ # Always enable SSL verification with CA data
48
+ configuration.verify_ssl = True
49
+
50
+ # Create a temporary file for the CA certificate using a context manager
51
+ try:
52
+ with tempfile.NamedTemporaryFile(delete=False) as ca_cert_file:
53
+ ca_cert_data = base64.b64decode(ca_data)
54
+ ca_cert_file.write(ca_cert_data)
55
+ # File is automatically closed when exiting the with block
56
+
57
+ # Store the path for cleanup and set the SSL CA cert
58
+ self._ca_cert_file_path = ca_cert_file.name
59
+ # Set the SSL CA cert to the temporary file path
60
+ # Use setattr to avoid potential attribute access issues
61
+ setattr(configuration, 'ssl_ca_cert', ca_cert_file.name)
62
+ except Exception as e:
63
+ # If we have a path and the file exists, clean it up
64
+ if (
65
+ hasattr(self, '_ca_cert_file_path')
66
+ and self._ca_cert_file_path
67
+ and os.path.exists(self._ca_cert_file_path)
68
+ ):
69
+ os.unlink(self._ca_cert_file_path)
70
+ raise e
71
+
72
+ # Create base API client
73
+ self.api_client = client.ApiClient(configuration)
74
+
75
+ # Create dynamic client
76
+ self.dynamic_client = dynamic.DynamicClient(self.api_client)
77
+
78
+ except ImportError:
79
+ logger.error('kubernetes package not installed')
80
+ raise
81
+
82
+ def _patch_resource(
83
+ self,
84
+ resource,
85
+ body: Optional[Dict[str, Any]],
86
+ name: Optional[str],
87
+ namespace: Optional[str] = None,
88
+ **kwargs,
89
+ ) -> Any:
90
+ """Patch a resource with strategic merge patch, falling back to merge patch if needed.
91
+
92
+ Args:
93
+ resource: The dynamic resource object
94
+ body: The resource body to patch with
95
+ name: Name of the resource
96
+ namespace: Namespace of the resource (if namespaced)
97
+ **kwargs: Additional arguments for the API call
98
+
99
+ Returns:
100
+ The API response
101
+ """
102
+ try:
103
+ # First try with strategic merge patch (default)
104
+ return resource.patch(
105
+ body=body,
106
+ name=name,
107
+ namespace=namespace,
108
+ content_type='application/strategic-merge-patch+json',
109
+ **kwargs,
110
+ )
111
+ except Exception as e:
112
+ # If we get a 415 error, try with merge patch
113
+ if '415' in str(e) or 'Unsupported Media Type' in str(e):
114
+ logger.warning(
115
+ f'Strategic merge patch not supported for {resource.kind}, falling back to merge patch'
116
+ )
117
+ return resource.patch(
118
+ body=body,
119
+ name=name,
120
+ namespace=namespace,
121
+ content_type='application/merge-patch+json',
122
+ **kwargs,
123
+ )
124
+ # Re-raise other errors
125
+ raise
126
+
127
+ def manage_resource(
128
+ self,
129
+ operation: Operation,
130
+ kind: str,
131
+ api_version: str,
132
+ name: Optional[str] = None,
133
+ namespace: Optional[str] = None,
134
+ body: Optional[dict] = None,
135
+ **kwargs,
136
+ ) -> Any:
137
+ """Manage a single Kubernetes resource with the specified operation using dynamic client.
138
+
139
+ Args:
140
+ operation: Operation to perform (Operation.CREATE, Operation.REPLACE, etc.)
141
+ kind: Resource kind (e.g., 'Pod', 'Service')
142
+ api_version: API version (e.g., 'v1', 'apps/v1')
143
+ name: Resource name (required for replace, patch, delete, read)
144
+ namespace: Namespace of the resource (optional)
145
+ body: Resource body (required for create, replace, patch)
146
+ **kwargs: Additional arguments for the API call
147
+
148
+ Returns:
149
+ The API response
150
+ """
151
+ # Validate parameters based on operation
152
+ if (
153
+ operation in [Operation.REPLACE, Operation.PATCH, Operation.DELETE, Operation.READ]
154
+ and not name
155
+ ):
156
+ raise ValueError(f'Resource name is required for {operation.value} operation')
157
+
158
+ if operation in [Operation.CREATE, Operation.REPLACE, Operation.PATCH] and not body:
159
+ raise ValueError(f'Resource body is required for {operation.value} operation')
160
+
161
+ try:
162
+ # Get the API resource
163
+ resource = self.dynamic_client.resources.get(api_version=api_version, kind=kind)
164
+
165
+ # Set kind and apiVersion in the body if provided
166
+ if body:
167
+ body['kind'] = kind
168
+ body['apiVersion'] = api_version
169
+
170
+ # Set name and namespace in metadata if provided
171
+ if name:
172
+ if 'metadata' not in body:
173
+ body['metadata'] = {}
174
+ body['metadata']['name'] = name
175
+ if namespace:
176
+ if 'metadata' not in body:
177
+ body['metadata'] = {}
178
+ body['metadata']['namespace'] = namespace
179
+
180
+ # Perform the operation based on the operation type
181
+ if operation == Operation.CREATE:
182
+ return resource.create(body=body, namespace=namespace, **kwargs)
183
+ elif operation == Operation.REPLACE:
184
+ return resource.replace(body=body, name=name, namespace=namespace, **kwargs)
185
+ elif operation == Operation.PATCH:
186
+ return self._patch_resource(resource, body, name, namespace, **kwargs)
187
+ elif operation == Operation.DELETE:
188
+ return resource.delete(name=name, namespace=namespace, **kwargs)
189
+ elif operation == Operation.READ:
190
+ return resource.get(name=name, namespace=namespace, **kwargs)
191
+ else:
192
+ raise ValueError(f'Unsupported operation: {operation.value}')
193
+
194
+ except Exception as e:
195
+ # Re-raise with more context
196
+ raise ValueError(f'Error managing {kind} resource: {str(e)}')
197
+
198
+ def list_resources(
199
+ self,
200
+ kind: str,
201
+ api_version: str,
202
+ namespace: Optional[str] = None,
203
+ label_selector: Optional[str] = None,
204
+ field_selector: Optional[str] = None,
205
+ **kwargs,
206
+ ) -> Any:
207
+ """List Kubernetes resources of a specific kind using dynamic client.
208
+
209
+ Args:
210
+ kind: Resource kind (e.g., 'Pod', 'Service')
211
+ api_version: API version (e.g., 'v1', 'apps/v1')
212
+ namespace: Namespace to list resources from (optional)
213
+ label_selector: Label selector to filter resources (optional)
214
+ field_selector: Field selector to filter resources (optional)
215
+ **kwargs: Additional arguments for the API call
216
+
217
+ Returns:
218
+ The API response containing the list of resources
219
+ """
220
+ try:
221
+ # Get the API resource
222
+ resource = self.dynamic_client.resources.get(api_version=api_version, kind=kind)
223
+
224
+ # Prepare kwargs for the list operation
225
+ list_kwargs = {}
226
+ if label_selector:
227
+ list_kwargs['label_selector'] = label_selector
228
+ if field_selector:
229
+ list_kwargs['field_selector'] = field_selector
230
+
231
+ # Add any additional kwargs
232
+ list_kwargs.update(kwargs)
233
+
234
+ # List resources
235
+ if namespace:
236
+ return resource.get(namespace=namespace, **list_kwargs)
237
+ else:
238
+ return resource.get(**list_kwargs)
239
+
240
+ except Exception as e:
241
+ # Re-raise with more context
242
+ raise ValueError(f'Error listing {kind} resources: {str(e)}')
243
+
244
+ def apply_from_yaml(
245
+ self, yaml_objects: list, namespace: str = 'default', force: bool = True, **kwargs
246
+ ) -> tuple:
247
+ """Apply YAML objects to the cluster with support for custom resources and updates.
248
+
249
+ This method improves upon the standard create_from_yaml by:
250
+ 1. Supporting custom resources through the dynamic client
251
+ 2. Supporting updates to existing resources when force=True
252
+
253
+ Args:
254
+ yaml_objects: List of YAML objects to apply
255
+ namespace: Default namespace to use for namespaced resources
256
+ force: Whether to update resources if they already exist (like kubectl apply)
257
+ **kwargs: Additional arguments for the API calls
258
+
259
+ Returns:
260
+ Tuple of (results, created_count, updated_count)
261
+ """
262
+ results = []
263
+ created_count = 0
264
+ updated_count = 0
265
+
266
+ for obj in yaml_objects:
267
+ if not obj:
268
+ continue
269
+
270
+ # Extract key information from the object
271
+ kind = obj.get('kind')
272
+ api_version = obj.get('apiVersion')
273
+ metadata = obj.get('metadata', {})
274
+ name = metadata.get('name')
275
+ obj_namespace = metadata.get('namespace', namespace)
276
+
277
+ if not kind or not api_version or not name:
278
+ raise ValueError('Invalid resource: missing kind, apiVersion, or name')
279
+
280
+ try:
281
+ # Get the API resource
282
+ resource = self.dynamic_client.resources.get(api_version=api_version, kind=kind)
283
+
284
+ # Check if resource exists
285
+ exists = False
286
+ if force:
287
+ try:
288
+ resource.get(
289
+ name=name, namespace=obj_namespace if resource.namespaced else None
290
+ )
291
+ exists = True
292
+ except Exception:
293
+ # Resource doesn't exist, will be created
294
+ exists = False
295
+
296
+ # Apply the resource
297
+ if exists and force:
298
+ # Update existing resource - use patch only
299
+ result = self._patch_resource(
300
+ resource,
301
+ obj,
302
+ name,
303
+ obj_namespace if resource.namespaced else None,
304
+ **kwargs,
305
+ )
306
+ updated_count += 1
307
+ else:
308
+ # Create new resource
309
+ result = resource.create(
310
+ body=obj,
311
+ namespace=obj_namespace if resource.namespaced else None,
312
+ **kwargs,
313
+ )
314
+ created_count += 1
315
+
316
+ results.append(result)
317
+
318
+ except Exception as e:
319
+ # Add context to the error
320
+ resource_name = f'{obj_namespace}/{name}' if obj_namespace else name
321
+ raise ValueError(f'Error applying {kind} {resource_name}: {str(e)}')
322
+
323
+ return results, created_count, updated_count
324
+
325
+ def get_events(
326
+ self,
327
+ kind: str,
328
+ name: str,
329
+ namespace: Optional[str] = None,
330
+ ) -> List[Dict[str, Any]]:
331
+ """Get events related to a specific Kubernetes resource.
332
+
333
+ Args:
334
+ kind: Kind of the involved object (e.g., 'Pod', 'Deployment')
335
+ name: Name of the involved object
336
+ namespace: Namespace of the involved object (optional for non-namespaced resources)
337
+
338
+ Returns:
339
+ List of events related to the specified object
340
+ """
341
+ try:
342
+ # Get the Event resource using the dynamic client
343
+ event_resource = self.dynamic_client.resources.get(api_version='v1', kind='Event')
344
+
345
+ # Prepare field selector to filter events
346
+ field_selector = f'involvedObject.kind={kind},involvedObject.name={name}'
347
+
348
+ # If namespace is provided, get events from that namespace
349
+ # Otherwise, search across all namespaces
350
+ if namespace:
351
+ events_response = event_resource.get(
352
+ namespace=namespace, field_selector=field_selector
353
+ )
354
+ else:
355
+ events_response = event_resource.get(field_selector=field_selector)
356
+
357
+ # Process events
358
+ result = []
359
+ for event in events_response.items:
360
+ # Dynamic client resources always have to_dict()
361
+ event_dict = event.to_dict()
362
+
363
+ # Extract relevant fields and handle camelCase field names
364
+ first_timestamp = event_dict.get('firstTimestamp')
365
+ last_timestamp = event_dict.get('lastTimestamp')
366
+ source = event_dict.get('source', {})
367
+
368
+ result.append(
369
+ {
370
+ 'first_timestamp': str(first_timestamp) if first_timestamp else None,
371
+ 'last_timestamp': str(last_timestamp) if last_timestamp else None,
372
+ 'count': event_dict.get('count'),
373
+ 'message': event_dict.get('message', ''),
374
+ 'reason': event_dict.get('reason'),
375
+ 'reporting_component': source.get('component'),
376
+ 'type': event_dict.get('type'),
377
+ }
378
+ )
379
+
380
+ return result
381
+
382
+ except Exception as e:
383
+ # Re-raise with more context
384
+ resource_name = f'{namespace + "/" if namespace else ""}{name}'
385
+ raise ValueError(f'Error getting events for {kind} {resource_name}: {str(e)}')
386
+
387
+ def get_pod_logs(
388
+ self,
389
+ pod_name: str,
390
+ namespace: str,
391
+ container_name: Optional[str] = None,
392
+ since_seconds: Optional[int] = None,
393
+ tail_lines: Optional[int] = None,
394
+ limit_bytes: Optional[int] = None,
395
+ ) -> str:
396
+ """Get logs from a pod.
397
+
398
+ Args:
399
+ pod_name: Name of the pod
400
+ namespace: Namespace of the pod
401
+ container_name: Container name (optional, if pod contains more than one container)
402
+ since_seconds: Only return logs newer than this many seconds (optional)
403
+ tail_lines: Number of lines to return from the end of the logs (optional)
404
+ limit_bytes: Maximum number of bytes to return (optional)
405
+
406
+ Returns:
407
+ Pod logs as a string
408
+ """
409
+ try:
410
+ # Get the Pod resource using the dynamic client
411
+ pod_resource = self.dynamic_client.resources.get(api_version='v1', kind='Pod')
412
+
413
+ # Prepare parameters for the log subresource
414
+ params = {}
415
+ if container_name:
416
+ params['container'] = container_name
417
+ if since_seconds:
418
+ params['sinceSeconds'] = since_seconds
419
+ if tail_lines:
420
+ params['tailLines'] = tail_lines
421
+ if limit_bytes:
422
+ params['limitBytes'] = limit_bytes
423
+
424
+ # Call the log subresource (note: singular 'log', not 'logs')
425
+ logs_response = pod_resource.log.get(name=pod_name, namespace=namespace, **params)
426
+
427
+ return logs_response
428
+
429
+ except Exception as e:
430
+ # Re-raise with more context
431
+ raise ValueError(f'Error getting logs from pod {namespace}/{pod_name}: {str(e)}')
432
+
433
+ def get_api_versions(self) -> List[str]:
434
+ """Get preferred API versions from the Kubernetes cluster.
435
+
436
+ Returns only the preferred (stable) API version for each group, avoiding alpha/beta versions
437
+ when stable versions are available.
438
+
439
+ Returns:
440
+ List of preferred API versions (e.g., ['v1', 'apps/v1', 'networking.k8s.io/v1'])
441
+ """
442
+ try:
443
+ from kubernetes import client
444
+
445
+ api_versions: set[str] = set()
446
+
447
+ # Get core API version (v1)
448
+ try:
449
+ core_api = client.CoreApi(self.api_client)
450
+ core_version_obj = core_api.get_api_versions()
451
+
452
+ # Extract versions safely
453
+ if core_version_obj is not None:
454
+ # Try to get versions as a list of strings
455
+ versions = getattr(core_version_obj, 'versions', None)
456
+ if versions is not None and isinstance(versions, list):
457
+ for version in versions:
458
+ if isinstance(version, str):
459
+ api_versions.add(version)
460
+ except Exception as e:
461
+ logger.warning(f'Error getting core API versions: {str(e)}')
462
+ raise ValueError(f'Error getting API versions: {str(e)}')
463
+
464
+ # Get API groups and their preferred versions
465
+ try:
466
+ apis_api = client.ApisApi(self.api_client)
467
+ api_groups_obj = apis_api.get_api_versions()
468
+
469
+ # Extract groups safely
470
+ if api_groups_obj is not None:
471
+ groups = getattr(api_groups_obj, 'groups', None)
472
+ if groups is not None and isinstance(groups, list):
473
+ for group in groups:
474
+ if group is not None:
475
+ # Try to get preferred version
476
+ preferred_version = getattr(group, 'preferred_version', None)
477
+ if preferred_version is not None:
478
+ group_version = getattr(
479
+ preferred_version, 'group_version', None
480
+ )
481
+ if group_version is not None and isinstance(
482
+ group_version, str
483
+ ):
484
+ api_versions.add(group_version)
485
+ except Exception as e:
486
+ logger.warning(f'Error getting API groups: {str(e)}')
487
+
488
+ # Convert to sorted list
489
+ return sorted(api_versions)
490
+
491
+ except Exception as e:
492
+ # Re-raise with more context
493
+ raise ValueError(f'Error getting API versions: {str(e)}')
494
+
495
+ def __del__(self):
496
+ """Clean up temporary files when the object is garbage collected."""
497
+ if (
498
+ hasattr(self, '_ca_cert_file_path')
499
+ and self._ca_cert_file_path
500
+ and os.path.exists(self._ca_cert_file_path)
501
+ ):
502
+ try:
503
+ os.unlink(self._ca_cert_file_path)
504
+ except Exception:
505
+ # Ignore errors during cleanup
506
+ pass
@@ -0,0 +1,164 @@
1
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file except in compliance
4
+ # with the License. A copy of the License is located at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # or in the 'license' file accompanying this file. This file is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES
9
+ # OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific language governing permissions
10
+ # and limitations under the License.
11
+
12
+ """Kubernetes client cache for the EKS MCP Server."""
13
+
14
+ import base64
15
+ from awslabs.eks_mcp_server.aws_helper import AwsHelper
16
+ from awslabs.eks_mcp_server.k8s_apis import K8sApis
17
+ from cachetools import TTLCache
18
+
19
+
20
+ # Presigned url timeout in seconds
21
+ URL_TIMEOUT = 60
22
+ TOKEN_PREFIX = 'k8s-aws-v1.'
23
+ K8S_AWS_ID_HEADER = 'x-k8s-aws-id'
24
+
25
+ # 14 minutes in seconds (buffer before the 15-minute token expiration)
26
+ TOKEN_TTL = 14 * 60
27
+
28
+
29
+ class K8sClientCache:
30
+ """Singleton class for managing Kubernetes API client cache.
31
+
32
+ This class provides a centralized cache for Kubernetes API clients
33
+ to avoid creating multiple clients for the same cluster.
34
+ """
35
+
36
+ # Singleton instance
37
+ _instance = None
38
+
39
+ def __new__(cls):
40
+ """Ensure only one instance of K8sClientCache exists."""
41
+ if cls._instance is None:
42
+ cls._instance = super(K8sClientCache, cls).__new__(cls)
43
+ cls._instance._initialized = False
44
+ return cls._instance
45
+
46
+ def __init__(self):
47
+ """Initialize the K8s client cache."""
48
+ # Only initialize once
49
+ if hasattr(self, '_initialized') and self._initialized:
50
+ return
51
+
52
+ # Client cache with TTL to handle token expiration
53
+ self._client_cache = TTLCache(maxsize=100, ttl=TOKEN_TTL)
54
+
55
+ # Clients for credential retrieval
56
+ self._eks_client = None
57
+ self._sts_client = None
58
+
59
+ self._initialized = True
60
+
61
+ def _get_eks_client(self):
62
+ """Get or create the EKS client."""
63
+ if self._eks_client is None:
64
+ self._eks_client = AwsHelper.create_boto3_client('eks')
65
+ return self._eks_client
66
+
67
+ def _get_sts_client(self):
68
+ """Get or create the STS client with event handlers registered."""
69
+ if self._sts_client is None:
70
+ sts_client = AwsHelper.create_boto3_client('sts')
71
+
72
+ # Register STS event handlers
73
+ sts_client.meta.events.register(
74
+ 'provide-client-params.sts.GetCallerIdentity',
75
+ self._retrieve_k8s_aws_id,
76
+ )
77
+ sts_client.meta.events.register(
78
+ 'before-sign.sts.GetCallerIdentity',
79
+ self._inject_k8s_aws_id_header,
80
+ )
81
+
82
+ self._sts_client = sts_client
83
+
84
+ return self._sts_client
85
+
86
+ def _retrieve_k8s_aws_id(self, params, context, **kwargs):
87
+ """Retrieve the Kubernetes AWS ID from parameters."""
88
+ if K8S_AWS_ID_HEADER in params:
89
+ context[K8S_AWS_ID_HEADER] = params.pop(K8S_AWS_ID_HEADER)
90
+
91
+ def _inject_k8s_aws_id_header(self, request, **kwargs):
92
+ """Inject the Kubernetes AWS ID header into the request."""
93
+ if K8S_AWS_ID_HEADER in request.context:
94
+ request.headers[K8S_AWS_ID_HEADER] = request.context[K8S_AWS_ID_HEADER]
95
+
96
+ def _get_cluster_credentials(self, cluster_name: str):
97
+ """Get credentials for an EKS cluster (private method).
98
+
99
+ Args:
100
+ cluster_name: Name of the EKS cluster
101
+
102
+ Returns:
103
+ Tuple of (endpoint, token, ca_data)
104
+
105
+ Raises:
106
+ ValueError: If the cluster credentials are invalid
107
+ Exception: If there's an error getting the cluster credentials
108
+ """
109
+ eks_client = self._get_eks_client()
110
+ sts_client = self._get_sts_client()
111
+
112
+ # Get cluster details
113
+ response = eks_client.describe_cluster(name=cluster_name)
114
+ endpoint = response['cluster']['endpoint']
115
+ ca_data = response['cluster']['certificateAuthority']['data']
116
+
117
+ # Generate a presigned URL for authentication
118
+ url = sts_client.generate_presigned_url(
119
+ 'get_caller_identity',
120
+ Params={K8S_AWS_ID_HEADER: cluster_name},
121
+ ExpiresIn=URL_TIMEOUT,
122
+ HttpMethod='GET',
123
+ )
124
+
125
+ # Create the token from the presigned URL
126
+ token = TOKEN_PREFIX + base64.urlsafe_b64encode(url.encode('utf-8')).decode(
127
+ 'utf-8'
128
+ ).rstrip('=')
129
+
130
+ return endpoint, token, ca_data
131
+
132
+ def get_client(self, cluster_name: str) -> K8sApis:
133
+ """Get a Kubernetes client for the specified cluster.
134
+
135
+ This is the only public method to access K8s API clients.
136
+
137
+ Args:
138
+ cluster_name: Name of the EKS cluster
139
+
140
+ Returns:
141
+ K8sApis instance
142
+
143
+ Raises:
144
+ ValueError: If the cluster credentials are invalid
145
+ Exception: If there's an error getting the cluster credentials
146
+ """
147
+ if cluster_name not in self._client_cache:
148
+ try:
149
+ # Create a new client
150
+ endpoint, token, ca_data = self._get_cluster_credentials(cluster_name)
151
+
152
+ # Validate credentials
153
+ if not endpoint or not token or endpoint is None or token is None:
154
+ raise ValueError('Invalid cluster credentials')
155
+
156
+ self._client_cache[cluster_name] = K8sApis(endpoint, token, ca_data)
157
+ except ValueError:
158
+ # Re-raise ValueError for invalid credentials
159
+ raise
160
+ except Exception as e:
161
+ # Re-raise any other exceptions
162
+ raise Exception(f'Failed to get cluster credentials: {str(e)}')
163
+
164
+ return self._client_cache[cluster_name]