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.
- awslabs/__init__.py +13 -0
- awslabs/eks_mcp_server/__init__.py +14 -0
- awslabs/eks_mcp_server/aws_helper.py +71 -0
- awslabs/eks_mcp_server/cloudwatch_handler.py +670 -0
- awslabs/eks_mcp_server/consts.py +33 -0
- awslabs/eks_mcp_server/eks_kb_handler.py +86 -0
- awslabs/eks_mcp_server/eks_stack_handler.py +661 -0
- awslabs/eks_mcp_server/iam_handler.py +359 -0
- awslabs/eks_mcp_server/k8s_apis.py +506 -0
- awslabs/eks_mcp_server/k8s_client_cache.py +164 -0
- awslabs/eks_mcp_server/k8s_handler.py +1151 -0
- awslabs/eks_mcp_server/logging_helper.py +52 -0
- awslabs/eks_mcp_server/models.py +271 -0
- awslabs/eks_mcp_server/server.py +151 -0
- awslabs/eks_mcp_server/templates/eks-templates/eks-with-vpc.yaml +454 -0
- awslabs/eks_mcp_server/templates/k8s-templates/deployment.yaml +49 -0
- awslabs/eks_mcp_server/templates/k8s-templates/service.yaml +18 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/METADATA +596 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/RECORD +23 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/WHEEL +4 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/entry_points.txt +2 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/licenses/LICENSE +175 -0
- awslabs_eks_mcp_server-0.1.1.dist-info/licenses/NOTICE +2 -0
|
@@ -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]
|