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,661 @@
|
|
|
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
|
+
"""EKS stack handler for the EKS MCP Server."""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import yaml
|
|
16
|
+
from awslabs.eks_mcp_server.aws_helper import AwsHelper
|
|
17
|
+
from awslabs.eks_mcp_server.consts import (
|
|
18
|
+
CFN_CAPABILITY_IAM,
|
|
19
|
+
CFN_ON_FAILURE_DELETE,
|
|
20
|
+
CFN_STACK_NAME_TEMPLATE,
|
|
21
|
+
CFN_STACK_TAG_KEY,
|
|
22
|
+
CFN_STACK_TAG_VALUE,
|
|
23
|
+
DELETE_OPERATION,
|
|
24
|
+
DEPLOY_OPERATION,
|
|
25
|
+
DESCRIBE_OPERATION,
|
|
26
|
+
GENERATE_OPERATION,
|
|
27
|
+
STACK_NOT_OWNED_ERROR_TEMPLATE,
|
|
28
|
+
)
|
|
29
|
+
from awslabs.eks_mcp_server.logging_helper import LogLevel, log_with_request_id
|
|
30
|
+
from awslabs.eks_mcp_server.models import (
|
|
31
|
+
DeleteStackResponse,
|
|
32
|
+
DeployStackResponse,
|
|
33
|
+
DescribeStackResponse,
|
|
34
|
+
GenerateTemplateResponse,
|
|
35
|
+
)
|
|
36
|
+
from mcp.server.fastmcp import Context
|
|
37
|
+
from mcp.types import EmbeddedResource, ImageContent, TextContent
|
|
38
|
+
from pydantic import Field
|
|
39
|
+
from typing import Dict, List, Optional, Tuple, Union, cast
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class EksStackHandler:
|
|
43
|
+
"""Handler for Amazon EKS CloudFormation stack operations.
|
|
44
|
+
|
|
45
|
+
This class provides tools for creating, managing, and deleting CloudFormation
|
|
46
|
+
stacks for EKS clusters.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, mcp, allow_write: bool = False):
|
|
50
|
+
"""Initialize the EKS stack handler.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
mcp: The MCP server instance
|
|
54
|
+
allow_write: Whether to enable write access (default: False)
|
|
55
|
+
"""
|
|
56
|
+
self.mcp = mcp
|
|
57
|
+
self.allow_write = allow_write
|
|
58
|
+
|
|
59
|
+
# Register tools
|
|
60
|
+
self.mcp.tool(name='manage_eks_stacks')(self.manage_eks_stacks)
|
|
61
|
+
|
|
62
|
+
def _ensure_stack_ownership(
|
|
63
|
+
self, ctx: Context, stack_name: str, operation: str
|
|
64
|
+
) -> Tuple[bool, Optional[Dict], Optional[str]]:
|
|
65
|
+
"""Ensure that a stack exists and was created by this tool.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
ctx: The MCP context
|
|
69
|
+
stack_name: Name of the stack to verify
|
|
70
|
+
operation: Operation being performed (for error messages)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tuple of (success, stack_details, error_message)
|
|
74
|
+
- success: True if the stack exists and was created by this tool
|
|
75
|
+
- stack_details: Stack details if the stack exists, None otherwise
|
|
76
|
+
- error_message: Error message if the stack doesn't exist or wasn't created by this tool, None if successful
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
# Create CloudFormation client
|
|
80
|
+
cfn_client = AwsHelper.create_boto3_client('cloudformation')
|
|
81
|
+
|
|
82
|
+
# Get stack details
|
|
83
|
+
stack_details = cfn_client.describe_stacks(StackName=stack_name)
|
|
84
|
+
stack = stack_details['Stacks'][0]
|
|
85
|
+
|
|
86
|
+
# Verify the stack was created by our tool
|
|
87
|
+
tags = stack.get('Tags', [])
|
|
88
|
+
is_our_stack = False
|
|
89
|
+
for tag in tags:
|
|
90
|
+
if tag.get('Key') == CFN_STACK_TAG_KEY and tag.get('Value') == CFN_STACK_TAG_VALUE:
|
|
91
|
+
is_our_stack = True
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
if not is_our_stack:
|
|
95
|
+
error_message = STACK_NOT_OWNED_ERROR_TEMPLATE.format(
|
|
96
|
+
stack_name=stack_name, tool_name=CFN_STACK_TAG_VALUE, operation=operation
|
|
97
|
+
)
|
|
98
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
99
|
+
return False, stack, error_message
|
|
100
|
+
|
|
101
|
+
return True, stack, None
|
|
102
|
+
except Exception as e:
|
|
103
|
+
if 'does not exist' in str(e):
|
|
104
|
+
error_message = f'Stack {stack_name} not found or cannot be accessed: {str(e)}'
|
|
105
|
+
else:
|
|
106
|
+
error_message = f'Error verifying stack ownership: {str(e)}'
|
|
107
|
+
|
|
108
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
109
|
+
return False, None, error_message
|
|
110
|
+
|
|
111
|
+
async def manage_eks_stacks(
|
|
112
|
+
self,
|
|
113
|
+
ctx: Context,
|
|
114
|
+
operation: str = Field(
|
|
115
|
+
...,
|
|
116
|
+
description='Operation to perform: generate, deploy, describe, or delete. Choose "describe" for read-only operations when write access is disabled.',
|
|
117
|
+
),
|
|
118
|
+
template_file: Optional[str] = Field(
|
|
119
|
+
None,
|
|
120
|
+
description="""Absolute path for the CloudFormation template (for generate and deploy operations).
|
|
121
|
+
IMPORTANT: Assistant must provide the full absolute path to the template file, as the MCP client and server might not run from the same location.""",
|
|
122
|
+
),
|
|
123
|
+
cluster_name: Optional[str] = Field(
|
|
124
|
+
None,
|
|
125
|
+
description="""Name of the EKS cluster (for generate, deploy, describe and delete operations).
|
|
126
|
+
This name will be used to derive the CloudFormation stack name and will be embedded in the cluster resources.""",
|
|
127
|
+
),
|
|
128
|
+
) -> Union[
|
|
129
|
+
GenerateTemplateResponse, DeployStackResponse, DescribeStackResponse, DeleteStackResponse
|
|
130
|
+
]:
|
|
131
|
+
"""Manage EKS CloudFormation stacks with both read and write operations.
|
|
132
|
+
|
|
133
|
+
This tool provides operations for managing EKS CloudFormation stacks, including creating templates,
|
|
134
|
+
deploying stacks, retrieving stack information, and deleting stacks. It serves as the primary
|
|
135
|
+
mechanism for creating and managing EKS clusters through CloudFormation, enabling standardized
|
|
136
|
+
cluster creation, configuration updates, and resource cleanup.
|
|
137
|
+
|
|
138
|
+
## Requirements
|
|
139
|
+
- The server must be run with the `--allow-write` flag for generate, deploy, and delete operations
|
|
140
|
+
- For deploy and delete operations, the stack must have been created by this tool
|
|
141
|
+
- For template_file parameter, the path must be absolute and accessible to the server
|
|
142
|
+
|
|
143
|
+
## Operations
|
|
144
|
+
- **generate**: Create a CloudFormation template at the specified absolute path with the cluster name embedded
|
|
145
|
+
- **deploy**: Deploy a CloudFormation template from the specified absolute path (creates a new stack or updates an existing one)
|
|
146
|
+
- **describe**: Get detailed information about a CloudFormation stack for a specific cluster
|
|
147
|
+
- **delete**: Delete a CloudFormation stack for the specified cluster
|
|
148
|
+
|
|
149
|
+
## Response Information
|
|
150
|
+
The response type varies based on the operation:
|
|
151
|
+
- generate: Returns GenerateTemplateResponse with the template path
|
|
152
|
+
- deploy: Returns DeployStackResponse with stack name, ARN, and cluster name
|
|
153
|
+
- describe: Returns DescribeStackResponse with stack details, outputs, and status
|
|
154
|
+
- delete: Returns DeleteStackResponse with stack name, ID, and cluster name
|
|
155
|
+
|
|
156
|
+
## Usage Tips
|
|
157
|
+
- Use the describe operation first to check if a cluster already exists
|
|
158
|
+
- For safety, this tool will only modify or delete stacks that it created
|
|
159
|
+
- Stack creation typically takes 15-20 minutes to complete
|
|
160
|
+
- Use absolute paths for template files (e.g., '/home/user/templates/eks-template.yaml')
|
|
161
|
+
- The cluster name is used to derive the CloudFormation stack name
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
ctx: MCP context
|
|
165
|
+
operation: Operation to perform (generate, deploy, describe, or delete)
|
|
166
|
+
template_file: Absolute path for the CloudFormation template (for generate and deploy operations)
|
|
167
|
+
cluster_name: Name of the EKS cluster (for all operations)
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Union[GenerateTemplateResponse, DeployStackResponse, DescribeStackResponse, DeleteStackResponse]:
|
|
171
|
+
Response specific to the operation performed
|
|
172
|
+
"""
|
|
173
|
+
try:
|
|
174
|
+
# Check if write access is disabled and trying to perform a mutating operation
|
|
175
|
+
if not self.allow_write and operation not in [
|
|
176
|
+
DESCRIBE_OPERATION,
|
|
177
|
+
]:
|
|
178
|
+
error_message = f'Operation {operation} is not allowed without write access'
|
|
179
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
180
|
+
|
|
181
|
+
# Return appropriate response type based on operation
|
|
182
|
+
if operation == GENERATE_OPERATION:
|
|
183
|
+
return GenerateTemplateResponse(
|
|
184
|
+
isError=True,
|
|
185
|
+
content=cast(
|
|
186
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
187
|
+
[TextContent(type='text', text=error_message)],
|
|
188
|
+
),
|
|
189
|
+
template_path='',
|
|
190
|
+
)
|
|
191
|
+
elif operation == DEPLOY_OPERATION:
|
|
192
|
+
return DeployStackResponse(
|
|
193
|
+
isError=True,
|
|
194
|
+
content=cast(
|
|
195
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
196
|
+
[TextContent(type='text', text=error_message)],
|
|
197
|
+
),
|
|
198
|
+
stack_name='',
|
|
199
|
+
stack_arn='',
|
|
200
|
+
cluster_name=cluster_name or '',
|
|
201
|
+
)
|
|
202
|
+
elif operation == DELETE_OPERATION:
|
|
203
|
+
return DeleteStackResponse(
|
|
204
|
+
isError=True,
|
|
205
|
+
content=cast(
|
|
206
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
207
|
+
[TextContent(type='text', text=error_message)],
|
|
208
|
+
),
|
|
209
|
+
stack_name='',
|
|
210
|
+
stack_id='',
|
|
211
|
+
cluster_name=cluster_name or '',
|
|
212
|
+
)
|
|
213
|
+
else: # Default to describe operation
|
|
214
|
+
return DescribeStackResponse(
|
|
215
|
+
isError=True,
|
|
216
|
+
content=cast(
|
|
217
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
218
|
+
[TextContent(type='text', text=error_message)],
|
|
219
|
+
),
|
|
220
|
+
stack_name='',
|
|
221
|
+
stack_id='',
|
|
222
|
+
cluster_name=cluster_name or '',
|
|
223
|
+
creation_time='',
|
|
224
|
+
stack_status='',
|
|
225
|
+
outputs={},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if operation == GENERATE_OPERATION:
|
|
229
|
+
if template_file is None:
|
|
230
|
+
raise ValueError('template_file is required for generate operation')
|
|
231
|
+
if cluster_name is None:
|
|
232
|
+
raise ValueError('cluster_name is required for generate operation')
|
|
233
|
+
return await self._generate_template(
|
|
234
|
+
ctx=ctx, template_path=template_file, cluster_name=cluster_name
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
elif operation == DEPLOY_OPERATION:
|
|
238
|
+
if template_file is None:
|
|
239
|
+
raise ValueError('template_file is required for deploy operation')
|
|
240
|
+
if cluster_name is None:
|
|
241
|
+
raise ValueError('cluster_name is required for deploy operation')
|
|
242
|
+
|
|
243
|
+
# Derive stack name from cluster name
|
|
244
|
+
stack_name = CFN_STACK_NAME_TEMPLATE.format(cluster_name=cluster_name)
|
|
245
|
+
return await self._deploy_stack(
|
|
246
|
+
ctx=ctx,
|
|
247
|
+
template_file=template_file,
|
|
248
|
+
stack_name=stack_name,
|
|
249
|
+
cluster_name=cluster_name,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
elif operation == DESCRIBE_OPERATION:
|
|
253
|
+
if cluster_name is None:
|
|
254
|
+
raise ValueError('cluster_name is required for describe operation')
|
|
255
|
+
|
|
256
|
+
# Derive stack name from cluster name
|
|
257
|
+
stack_name = CFN_STACK_NAME_TEMPLATE.format(cluster_name=cluster_name)
|
|
258
|
+
return await self._describe_stack(
|
|
259
|
+
ctx=ctx, stack_name=stack_name, cluster_name=cluster_name
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
elif operation == DELETE_OPERATION:
|
|
263
|
+
if cluster_name is None:
|
|
264
|
+
raise ValueError('cluster_name is required for delete operation')
|
|
265
|
+
|
|
266
|
+
# Derive stack name from cluster name
|
|
267
|
+
stack_name = CFN_STACK_NAME_TEMPLATE.format(cluster_name=cluster_name)
|
|
268
|
+
return await self._delete_stack(
|
|
269
|
+
ctx=ctx, stack_name=stack_name, cluster_name=cluster_name
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
else:
|
|
273
|
+
error_message = f'Invalid operation: {operation}. Must be one of: generate, deploy, describe, delete'
|
|
274
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
275
|
+
# Default to DescribeStackResponse for invalid operations
|
|
276
|
+
return DescribeStackResponse(
|
|
277
|
+
isError=True,
|
|
278
|
+
content=cast(
|
|
279
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
280
|
+
[TextContent(type='text', text=error_message)],
|
|
281
|
+
),
|
|
282
|
+
stack_name='',
|
|
283
|
+
stack_id='',
|
|
284
|
+
cluster_name=cluster_name or '',
|
|
285
|
+
creation_time='',
|
|
286
|
+
stack_status='',
|
|
287
|
+
outputs={},
|
|
288
|
+
)
|
|
289
|
+
except ValueError as e:
|
|
290
|
+
# Re-raise ValueError for parameter validation errors
|
|
291
|
+
log_with_request_id(ctx, LogLevel.ERROR, f'Parameter validation error: {str(e)}')
|
|
292
|
+
raise
|
|
293
|
+
except Exception as e:
|
|
294
|
+
error_message = f'Error in manage_eks_stacks: {str(e)}'
|
|
295
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
296
|
+
# Default to DescribeStackResponse for general exceptions
|
|
297
|
+
return DescribeStackResponse(
|
|
298
|
+
isError=True,
|
|
299
|
+
content=cast(
|
|
300
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
301
|
+
[TextContent(type='text', text=error_message)],
|
|
302
|
+
),
|
|
303
|
+
stack_name='',
|
|
304
|
+
stack_id='',
|
|
305
|
+
cluster_name=cluster_name or '',
|
|
306
|
+
creation_time='',
|
|
307
|
+
stack_status='',
|
|
308
|
+
outputs={},
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
async def _generate_template(
|
|
312
|
+
self, ctx: Context, template_path: str, cluster_name: str
|
|
313
|
+
) -> GenerateTemplateResponse:
|
|
314
|
+
"""Generate a CloudFormation template at the specified path with the cluster name embedded.
|
|
315
|
+
|
|
316
|
+
The template creates a complete EKS environment including:
|
|
317
|
+
- A dedicated VPC with public and private subnets across two availability zones
|
|
318
|
+
- Internet Gateway and NAT Gateways for outbound connectivity
|
|
319
|
+
- Security groups for cluster communication
|
|
320
|
+
- IAM roles for the EKS cluster and worker nodes
|
|
321
|
+
- An EKS cluster in Auto Mode with:
|
|
322
|
+
- Compute configuration with general-purpose and system node pools
|
|
323
|
+
- Kubernetes network configuration with elastic load balancing
|
|
324
|
+
- Block storage configuration
|
|
325
|
+
- API authentication mode
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
# Get the source template path
|
|
329
|
+
source_template_path = os.path.join(
|
|
330
|
+
os.path.dirname(__file__), 'templates', 'eks-templates', 'eks-with-vpc.yaml'
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Create directory if it doesn't exist
|
|
334
|
+
os.makedirs(os.path.dirname(template_path), exist_ok=True)
|
|
335
|
+
|
|
336
|
+
# Read the template
|
|
337
|
+
with open(source_template_path, 'r') as source_file:
|
|
338
|
+
template_content = source_file.read()
|
|
339
|
+
|
|
340
|
+
# Parse the template as YAML
|
|
341
|
+
template_yaml = yaml.safe_load(template_content)
|
|
342
|
+
|
|
343
|
+
# Modify the template to set the cluster name directly
|
|
344
|
+
# Find the ClusterName parameter and set its default value
|
|
345
|
+
if 'Parameters' in template_yaml and 'ClusterName' in template_yaml['Parameters']:
|
|
346
|
+
template_yaml['Parameters']['ClusterName']['Default'] = cluster_name
|
|
347
|
+
|
|
348
|
+
# Convert back to YAML
|
|
349
|
+
modified_template = yaml.dump(template_yaml, default_flow_style=False)
|
|
350
|
+
|
|
351
|
+
# Write the modified template to the destination
|
|
352
|
+
with open(template_path, 'w') as dest_file:
|
|
353
|
+
dest_file.write(modified_template)
|
|
354
|
+
|
|
355
|
+
log_with_request_id(
|
|
356
|
+
ctx,
|
|
357
|
+
LogLevel.INFO,
|
|
358
|
+
f'Generated CloudFormation template at {template_path} with cluster name {cluster_name}',
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
return GenerateTemplateResponse(
|
|
362
|
+
isError=False,
|
|
363
|
+
content=cast(
|
|
364
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
365
|
+
[
|
|
366
|
+
TextContent(
|
|
367
|
+
type='text',
|
|
368
|
+
text=f'CloudFormation template generated at {template_path} with cluster name {cluster_name}',
|
|
369
|
+
)
|
|
370
|
+
],
|
|
371
|
+
),
|
|
372
|
+
template_path=template_path,
|
|
373
|
+
)
|
|
374
|
+
except Exception as e:
|
|
375
|
+
error_message = f'Failed to generate template: {str(e)}'
|
|
376
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
377
|
+
|
|
378
|
+
return GenerateTemplateResponse(
|
|
379
|
+
isError=True,
|
|
380
|
+
content=cast(
|
|
381
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
382
|
+
[TextContent(type='text', text=error_message or 'Unknown error')],
|
|
383
|
+
),
|
|
384
|
+
template_path='',
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
async def _deploy_stack(
|
|
388
|
+
self, ctx: Context, template_file: str, stack_name: str, cluster_name: str
|
|
389
|
+
) -> DeployStackResponse:
|
|
390
|
+
"""Deploy a CloudFormation stack from the specified template file."""
|
|
391
|
+
try:
|
|
392
|
+
# Create CloudFormation client
|
|
393
|
+
cfn_client = AwsHelper.create_boto3_client('cloudformation')
|
|
394
|
+
|
|
395
|
+
# Read the template
|
|
396
|
+
with open(template_file, 'r') as template_file_obj:
|
|
397
|
+
template_body = template_file_obj.read()
|
|
398
|
+
|
|
399
|
+
# Check if the stack already exists and verify ownership
|
|
400
|
+
stack_exists = False
|
|
401
|
+
try:
|
|
402
|
+
success, stack, error_message = self._ensure_stack_ownership(
|
|
403
|
+
ctx, stack_name, 'update'
|
|
404
|
+
)
|
|
405
|
+
if stack:
|
|
406
|
+
stack_exists = True
|
|
407
|
+
if not success:
|
|
408
|
+
return DeployStackResponse(
|
|
409
|
+
isError=True,
|
|
410
|
+
content=cast(
|
|
411
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
412
|
+
[TextContent(type='text', text=error_message or 'Unknown error')],
|
|
413
|
+
),
|
|
414
|
+
stack_name=stack_name,
|
|
415
|
+
stack_arn='',
|
|
416
|
+
cluster_name=cluster_name,
|
|
417
|
+
)
|
|
418
|
+
except Exception:
|
|
419
|
+
# Stack doesn't exist, we'll create it
|
|
420
|
+
stack_exists = False
|
|
421
|
+
|
|
422
|
+
# Create or update the stack
|
|
423
|
+
if stack_exists:
|
|
424
|
+
log_with_request_id(
|
|
425
|
+
ctx,
|
|
426
|
+
LogLevel.INFO,
|
|
427
|
+
f'Updating CloudFormation stack {stack_name} for EKS cluster {cluster_name}',
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
response = cfn_client.update_stack(
|
|
431
|
+
StackName=stack_name,
|
|
432
|
+
TemplateBody=template_body,
|
|
433
|
+
Capabilities=[CFN_CAPABILITY_IAM],
|
|
434
|
+
Tags=[{'Key': CFN_STACK_TAG_KEY, 'Value': CFN_STACK_TAG_VALUE}],
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
operation_text = 'update'
|
|
438
|
+
else:
|
|
439
|
+
log_with_request_id(
|
|
440
|
+
ctx,
|
|
441
|
+
LogLevel.INFO,
|
|
442
|
+
f'Creating CloudFormation stack {stack_name} for EKS cluster {cluster_name}',
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
response = cfn_client.create_stack(
|
|
446
|
+
StackName=stack_name,
|
|
447
|
+
TemplateBody=template_body,
|
|
448
|
+
Capabilities=[CFN_CAPABILITY_IAM],
|
|
449
|
+
OnFailure=CFN_ON_FAILURE_DELETE,
|
|
450
|
+
Tags=[{'Key': CFN_STACK_TAG_KEY, 'Value': CFN_STACK_TAG_VALUE}],
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
operation_text = 'creation'
|
|
454
|
+
|
|
455
|
+
log_with_request_id(
|
|
456
|
+
ctx,
|
|
457
|
+
LogLevel.INFO,
|
|
458
|
+
f'CloudFormation stack {operation_text} initiated. Stack ARN: {response["StackId"]}',
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return DeployStackResponse(
|
|
462
|
+
isError=False,
|
|
463
|
+
content=cast(
|
|
464
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
465
|
+
[
|
|
466
|
+
TextContent(
|
|
467
|
+
type='text',
|
|
468
|
+
text=f'CloudFormation stack {operation_text} initiated. Stack {operation_text} is in progress and typically takes 15-20 minutes to complete.',
|
|
469
|
+
)
|
|
470
|
+
],
|
|
471
|
+
),
|
|
472
|
+
stack_name=stack_name,
|
|
473
|
+
stack_arn=response['StackId'],
|
|
474
|
+
cluster_name=cluster_name,
|
|
475
|
+
)
|
|
476
|
+
except Exception as e:
|
|
477
|
+
error_message = f'Failed to deploy stack: {str(e)}'
|
|
478
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
479
|
+
|
|
480
|
+
return DeployStackResponse(
|
|
481
|
+
isError=True,
|
|
482
|
+
content=cast(
|
|
483
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
484
|
+
[TextContent(type='text', text=error_message or 'Unknown error')],
|
|
485
|
+
),
|
|
486
|
+
stack_name=stack_name,
|
|
487
|
+
stack_arn='',
|
|
488
|
+
cluster_name=cluster_name,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
async def _describe_stack(
|
|
492
|
+
self, ctx: Context, stack_name: str, cluster_name: str
|
|
493
|
+
) -> DescribeStackResponse:
|
|
494
|
+
"""Describe a CloudFormation stack."""
|
|
495
|
+
try:
|
|
496
|
+
# Verify stack ownership
|
|
497
|
+
success, stack, error_message = self._ensure_stack_ownership(
|
|
498
|
+
ctx, stack_name, 'describe'
|
|
499
|
+
)
|
|
500
|
+
if not success:
|
|
501
|
+
# Prepare error response with available stack details
|
|
502
|
+
stack_id = ''
|
|
503
|
+
creation_time = ''
|
|
504
|
+
stack_status = ''
|
|
505
|
+
|
|
506
|
+
if stack:
|
|
507
|
+
stack_id = stack['StackId']
|
|
508
|
+
creation_time = stack['CreationTime'].isoformat()
|
|
509
|
+
stack_status = stack['StackStatus']
|
|
510
|
+
|
|
511
|
+
return DescribeStackResponse(
|
|
512
|
+
isError=True,
|
|
513
|
+
content=cast(
|
|
514
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
515
|
+
[TextContent(type='text', text=error_message or 'Unknown error')],
|
|
516
|
+
),
|
|
517
|
+
stack_name=stack_name,
|
|
518
|
+
stack_id=stack_id,
|
|
519
|
+
cluster_name=cluster_name,
|
|
520
|
+
creation_time=creation_time,
|
|
521
|
+
stack_status=stack_status,
|
|
522
|
+
outputs={},
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# Extract outputs
|
|
526
|
+
outputs = {}
|
|
527
|
+
if stack and 'Outputs' in stack:
|
|
528
|
+
for output in stack['Outputs']:
|
|
529
|
+
if 'OutputKey' in output and 'OutputValue' in output:
|
|
530
|
+
outputs[output['OutputKey']] = output['OutputValue']
|
|
531
|
+
|
|
532
|
+
log_with_request_id(
|
|
533
|
+
ctx,
|
|
534
|
+
LogLevel.INFO,
|
|
535
|
+
f'Described CloudFormation stack {stack_name} for EKS cluster {cluster_name}',
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Safely extract stack details
|
|
539
|
+
stack_id = ''
|
|
540
|
+
creation_time = ''
|
|
541
|
+
stack_status = ''
|
|
542
|
+
|
|
543
|
+
if stack:
|
|
544
|
+
stack_id = stack.get('StackId', '')
|
|
545
|
+
|
|
546
|
+
# Safely handle creation time
|
|
547
|
+
if 'CreationTime' in stack:
|
|
548
|
+
creation_time_obj = stack['CreationTime']
|
|
549
|
+
if hasattr(creation_time_obj, 'isoformat'):
|
|
550
|
+
creation_time = creation_time_obj.isoformat()
|
|
551
|
+
else:
|
|
552
|
+
creation_time = str(creation_time_obj)
|
|
553
|
+
|
|
554
|
+
stack_status = stack.get('StackStatus', '')
|
|
555
|
+
|
|
556
|
+
return DescribeStackResponse(
|
|
557
|
+
isError=False,
|
|
558
|
+
content=cast(
|
|
559
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
560
|
+
[
|
|
561
|
+
TextContent(
|
|
562
|
+
type='text',
|
|
563
|
+
text=f'Successfully described CloudFormation stack {stack_name} for EKS cluster {cluster_name}',
|
|
564
|
+
)
|
|
565
|
+
],
|
|
566
|
+
),
|
|
567
|
+
stack_name=stack_name,
|
|
568
|
+
stack_id=stack_id,
|
|
569
|
+
cluster_name=cluster_name,
|
|
570
|
+
creation_time=creation_time,
|
|
571
|
+
stack_status=stack_status,
|
|
572
|
+
outputs=outputs,
|
|
573
|
+
)
|
|
574
|
+
except Exception as e:
|
|
575
|
+
error_message = f'Failed to describe stack: {str(e)}'
|
|
576
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
577
|
+
|
|
578
|
+
return DescribeStackResponse(
|
|
579
|
+
isError=True,
|
|
580
|
+
content=cast(
|
|
581
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
582
|
+
[TextContent(type='text', text=error_message or 'Unknown error')],
|
|
583
|
+
),
|
|
584
|
+
stack_name=stack_name,
|
|
585
|
+
stack_id='',
|
|
586
|
+
cluster_name=cluster_name,
|
|
587
|
+
creation_time='',
|
|
588
|
+
stack_status='',
|
|
589
|
+
outputs={},
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
async def _delete_stack(
|
|
593
|
+
self, ctx: Context, stack_name: str, cluster_name: str
|
|
594
|
+
) -> DeleteStackResponse:
|
|
595
|
+
"""Delete a CloudFormation stack."""
|
|
596
|
+
try:
|
|
597
|
+
# Create CloudFormation client
|
|
598
|
+
cfn_client = AwsHelper.create_boto3_client('cloudformation')
|
|
599
|
+
|
|
600
|
+
# Verify stack ownership
|
|
601
|
+
success, stack, error_message = self._ensure_stack_ownership(ctx, stack_name, 'delete')
|
|
602
|
+
if not success:
|
|
603
|
+
# Prepare error response with available stack details
|
|
604
|
+
stack_id = ''
|
|
605
|
+
if stack:
|
|
606
|
+
stack_id = stack['StackId']
|
|
607
|
+
|
|
608
|
+
return DeleteStackResponse(
|
|
609
|
+
isError=True,
|
|
610
|
+
content=cast(
|
|
611
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
612
|
+
[TextContent(type='text', text=error_message or 'Unknown error')],
|
|
613
|
+
),
|
|
614
|
+
stack_name=stack_name,
|
|
615
|
+
stack_id=stack_id,
|
|
616
|
+
cluster_name=cluster_name,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# Safely extract stack ID
|
|
620
|
+
stack_id = ''
|
|
621
|
+
if stack and 'StackId' in stack:
|
|
622
|
+
stack_id = stack['StackId']
|
|
623
|
+
|
|
624
|
+
# Delete the stack
|
|
625
|
+
cfn_client.delete_stack(StackName=stack_name)
|
|
626
|
+
|
|
627
|
+
log_with_request_id(
|
|
628
|
+
ctx,
|
|
629
|
+
LogLevel.INFO,
|
|
630
|
+
f'Initiated deletion of CloudFormation stack {stack_name} for EKS cluster {cluster_name}',
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
return DeleteStackResponse(
|
|
634
|
+
isError=False,
|
|
635
|
+
content=cast(
|
|
636
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
637
|
+
[
|
|
638
|
+
TextContent(
|
|
639
|
+
type='text',
|
|
640
|
+
text=f'Initiated deletion of CloudFormation stack {stack_name} for EKS cluster {cluster_name}. Deletion is in progress.',
|
|
641
|
+
)
|
|
642
|
+
],
|
|
643
|
+
),
|
|
644
|
+
stack_name=stack_name,
|
|
645
|
+
stack_id=stack_id,
|
|
646
|
+
cluster_name=cluster_name,
|
|
647
|
+
)
|
|
648
|
+
except Exception as e:
|
|
649
|
+
error_message = f'Failed to delete stack: {str(e)}'
|
|
650
|
+
log_with_request_id(ctx, LogLevel.ERROR, error_message)
|
|
651
|
+
|
|
652
|
+
return DeleteStackResponse(
|
|
653
|
+
isError=True,
|
|
654
|
+
content=cast(
|
|
655
|
+
List[Union[TextContent, ImageContent, EmbeddedResource]],
|
|
656
|
+
[TextContent(type='text', text=error_message or 'Unknown error')],
|
|
657
|
+
),
|
|
658
|
+
stack_name=stack_name,
|
|
659
|
+
stack_id='',
|
|
660
|
+
cluster_name=cluster_name,
|
|
661
|
+
)
|