awslabs.elasticache-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 +16 -0
- awslabs/elasticache_mcp_server/__init__.py +17 -0
- awslabs/elasticache_mcp_server/common/__init__.py +15 -0
- awslabs/elasticache_mcp_server/common/connection.py +117 -0
- awslabs/elasticache_mcp_server/common/decorators.py +41 -0
- awslabs/elasticache_mcp_server/common/server.py +30 -0
- awslabs/elasticache_mcp_server/context.py +39 -0
- awslabs/elasticache_mcp_server/main.py +52 -0
- awslabs/elasticache_mcp_server/tools/__init__.py +15 -0
- awslabs/elasticache_mcp_server/tools/cc/__init__.py +31 -0
- awslabs/elasticache_mcp_server/tools/cc/connect.py +444 -0
- awslabs/elasticache_mcp_server/tools/cc/create.py +212 -0
- awslabs/elasticache_mcp_server/tools/cc/delete.py +65 -0
- awslabs/elasticache_mcp_server/tools/cc/describe.py +80 -0
- awslabs/elasticache_mcp_server/tools/cc/modify.py +159 -0
- awslabs/elasticache_mcp_server/tools/cc/parsers.py +78 -0
- awslabs/elasticache_mcp_server/tools/cc/processors.py +74 -0
- awslabs/elasticache_mcp_server/tools/ce/__init__.py +19 -0
- awslabs/elasticache_mcp_server/tools/ce/get_cost_and_usage.py +76 -0
- awslabs/elasticache_mcp_server/tools/cw/__init__.py +19 -0
- awslabs/elasticache_mcp_server/tools/cw/get_metric_statistics.py +85 -0
- awslabs/elasticache_mcp_server/tools/cwlogs/__init__.py +29 -0
- awslabs/elasticache_mcp_server/tools/cwlogs/create_log_group.py +68 -0
- awslabs/elasticache_mcp_server/tools/cwlogs/describe_log_groups.py +123 -0
- awslabs/elasticache_mcp_server/tools/cwlogs/describe_log_streams.py +120 -0
- awslabs/elasticache_mcp_server/tools/cwlogs/filter_log_events.py +122 -0
- awslabs/elasticache_mcp_server/tools/cwlogs/get_log_events.py +99 -0
- awslabs/elasticache_mcp_server/tools/firehose/__init__.py +19 -0
- awslabs/elasticache_mcp_server/tools/firehose/list_delivery_streams.py +63 -0
- awslabs/elasticache_mcp_server/tools/misc/__init__.py +31 -0
- awslabs/elasticache_mcp_server/tools/misc/batch_apply_update_action.py +62 -0
- awslabs/elasticache_mcp_server/tools/misc/batch_stop_update_action.py +62 -0
- awslabs/elasticache_mcp_server/tools/misc/describe_cache_engine_versions.py +79 -0
- awslabs/elasticache_mcp_server/tools/misc/describe_engine_default_parameters.py +64 -0
- awslabs/elasticache_mcp_server/tools/misc/describe_events.py +86 -0
- awslabs/elasticache_mcp_server/tools/misc/describe_service_updates.py +71 -0
- awslabs/elasticache_mcp_server/tools/rg/__init__.py +54 -0
- awslabs/elasticache_mcp_server/tools/rg/complete_migration.py +94 -0
- awslabs/elasticache_mcp_server/tools/rg/connect.py +537 -0
- awslabs/elasticache_mcp_server/tools/rg/create.py +318 -0
- awslabs/elasticache_mcp_server/tools/rg/delete.py +68 -0
- awslabs/elasticache_mcp_server/tools/rg/describe.py +68 -0
- awslabs/elasticache_mcp_server/tools/rg/modify.py +236 -0
- awslabs/elasticache_mcp_server/tools/rg/parsers.py +268 -0
- awslabs/elasticache_mcp_server/tools/rg/processors.py +227 -0
- awslabs/elasticache_mcp_server/tools/rg/start_migration.py +151 -0
- awslabs/elasticache_mcp_server/tools/rg/test_migration.py +139 -0
- awslabs/elasticache_mcp_server/tools/serverless/__init__.py +37 -0
- awslabs/elasticache_mcp_server/tools/serverless/connect.py +451 -0
- awslabs/elasticache_mcp_server/tools/serverless/create.py +174 -0
- awslabs/elasticache_mcp_server/tools/serverless/delete.py +49 -0
- awslabs/elasticache_mcp_server/tools/serverless/describe.py +69 -0
- awslabs/elasticache_mcp_server/tools/serverless/models.py +160 -0
- awslabs/elasticache_mcp_server/tools/serverless/modify.py +95 -0
- awslabs_elasticache_mcp_server-0.1.1.dist-info/METADATA +257 -0
- awslabs_elasticache_mcp_server-0.1.1.dist-info/RECORD +60 -0
- awslabs_elasticache_mcp_server-0.1.1.dist-info/WHEEL +4 -0
- awslabs_elasticache_mcp_server-0.1.1.dist-info/entry_points.txt +2 -0
- awslabs_elasticache_mcp_server-0.1.1.dist-info/licenses/LICENSE +175 -0
- awslabs_elasticache_mcp_server-0.1.1.dist-info/licenses/NOTICE +2 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Connect module for creating and configuring jump host EC2 instances to access ElastiCache replication groups."""
|
|
16
|
+
|
|
17
|
+
from ...common.connection import EC2ConnectionManager, ElastiCacheConnectionManager
|
|
18
|
+
from ...common.decorators import handle_exceptions
|
|
19
|
+
from ...common.server import mcp
|
|
20
|
+
from ...context import Context
|
|
21
|
+
from botocore.exceptions import ClientError
|
|
22
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _configure_security_groups(
|
|
26
|
+
replication_group_id: str,
|
|
27
|
+
instance_id: str,
|
|
28
|
+
ec2_client: Any = None,
|
|
29
|
+
elasticache_client: Any = None,
|
|
30
|
+
) -> Tuple[bool, str, int]:
|
|
31
|
+
"""Configure security group rules to allow access from EC2 instance to ElastiCache replication group.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
replication_group_id (str): ID of the ElastiCache replication group
|
|
35
|
+
instance_id (str): ID of the EC2 instance
|
|
36
|
+
ec2_client (Any, optional): EC2 client. If not provided, will get from connection manager
|
|
37
|
+
elasticache_client (Any, optional): ElastiCache client. If not provided, will get from connection manager
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Tuple[bool, str, int]: Tuple containing (success status, vpc id, cache port)
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
ValueError: If VPC compatibility check fails or required resources not found
|
|
44
|
+
"""
|
|
45
|
+
if not ec2_client:
|
|
46
|
+
ec2_client = EC2ConnectionManager.get_connection()
|
|
47
|
+
if not elasticache_client:
|
|
48
|
+
elasticache_client = ElastiCacheConnectionManager.get_connection()
|
|
49
|
+
|
|
50
|
+
# Get replication group details
|
|
51
|
+
replication_group = elasticache_client.describe_replication_groups(
|
|
52
|
+
ReplicationGroupId=replication_group_id
|
|
53
|
+
)['ReplicationGroups'][0]
|
|
54
|
+
|
|
55
|
+
# Get primary cluster details
|
|
56
|
+
primary_cluster_id = None
|
|
57
|
+
for member in replication_group['MemberClusters']:
|
|
58
|
+
cluster = elasticache_client.describe_cache_clusters(
|
|
59
|
+
CacheClusterId=member, ShowCacheNodeInfo=True
|
|
60
|
+
)['CacheClusters'][0]
|
|
61
|
+
if cluster['CacheClusterRole'].lower() == 'primary':
|
|
62
|
+
primary_cluster_id = member
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
if not primary_cluster_id:
|
|
66
|
+
raise ValueError(f'No primary cluster found in replication group {replication_group_id}')
|
|
67
|
+
|
|
68
|
+
# Get cache cluster VPC ID from primary cluster
|
|
69
|
+
primary_cluster = elasticache_client.describe_cache_clusters(
|
|
70
|
+
CacheClusterId=primary_cluster_id, ShowCacheNodeInfo=True
|
|
71
|
+
)['CacheClusters'][0]
|
|
72
|
+
|
|
73
|
+
# Get subnet group name from cluster
|
|
74
|
+
subnet_group_name = primary_cluster.get('CacheSubnetGroupName')
|
|
75
|
+
if not subnet_group_name:
|
|
76
|
+
raise ValueError(f'No cache subnet group found for cluster {primary_cluster_id}')
|
|
77
|
+
|
|
78
|
+
# Get VPC ID from subnet group
|
|
79
|
+
try:
|
|
80
|
+
cache_subnet_group = elasticache_client.describe_cache_subnet_groups(
|
|
81
|
+
CacheSubnetGroupName=subnet_group_name
|
|
82
|
+
)['CacheSubnetGroups'][0]
|
|
83
|
+
except Exception as e:
|
|
84
|
+
raise ValueError(f'Failed to get cache subnet group {subnet_group_name}: {str(e)}')
|
|
85
|
+
cache_vpc_id = cache_subnet_group['VpcId']
|
|
86
|
+
|
|
87
|
+
# Get EC2 instance details
|
|
88
|
+
instance_info = ec2_client.describe_instances(InstanceIds=[instance_id])
|
|
89
|
+
if not instance_info['Reservations']:
|
|
90
|
+
raise ValueError(f'EC2 instance {instance_id} not found')
|
|
91
|
+
|
|
92
|
+
instance = instance_info['Reservations'][0]['Instances'][0]
|
|
93
|
+
instance_vpc_id = instance['VpcId']
|
|
94
|
+
|
|
95
|
+
# Check VPC compatibility
|
|
96
|
+
if instance_vpc_id != cache_vpc_id:
|
|
97
|
+
raise ValueError(
|
|
98
|
+
f'EC2 instance VPC ({instance_vpc_id}) does not match replication group VPC ({cache_vpc_id})'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Get cache cluster port from primary node
|
|
102
|
+
cache_port = primary_cluster['CacheNodes'][0]['Endpoint']['Port']
|
|
103
|
+
|
|
104
|
+
# Get cache cluster security groups from all member clusters
|
|
105
|
+
cache_security_groups = set()
|
|
106
|
+
for member in replication_group['MemberClusters']:
|
|
107
|
+
cluster = elasticache_client.describe_cache_clusters(
|
|
108
|
+
CacheClusterId=member, ShowCacheNodeInfo=True
|
|
109
|
+
)['CacheClusters'][0]
|
|
110
|
+
for sg in cluster.get('SecurityGroups', []):
|
|
111
|
+
cache_security_groups.add(sg['SecurityGroupId'])
|
|
112
|
+
|
|
113
|
+
if not cache_security_groups:
|
|
114
|
+
raise ValueError(f'No security groups found for replication group {replication_group_id}')
|
|
115
|
+
|
|
116
|
+
# Get EC2 instance security groups
|
|
117
|
+
instance_security_groups = [sg['GroupId'] for sg in instance['SecurityGroups']]
|
|
118
|
+
if not instance_security_groups:
|
|
119
|
+
raise ValueError(f'No security groups found for EC2 instance {instance_id}')
|
|
120
|
+
|
|
121
|
+
# For each cache security group, ensure it allows inbound access from EC2 security groups
|
|
122
|
+
for cache_sg_id in cache_security_groups:
|
|
123
|
+
cache_sg_info = ec2_client.describe_security_groups(GroupIds=[cache_sg_id])[
|
|
124
|
+
'SecurityGroups'
|
|
125
|
+
][0]
|
|
126
|
+
|
|
127
|
+
# Check existing rules
|
|
128
|
+
existing_rules = cache_sg_info.get('IpPermissions', [])
|
|
129
|
+
needs_rule = True
|
|
130
|
+
|
|
131
|
+
for rule in existing_rules:
|
|
132
|
+
if (
|
|
133
|
+
rule.get('IpProtocol') == 'tcp'
|
|
134
|
+
and rule.get('FromPort') == cache_port
|
|
135
|
+
and rule.get('ToPort') == cache_port
|
|
136
|
+
):
|
|
137
|
+
# Check if any EC2 security group is already allowed
|
|
138
|
+
for group_pair in rule.get('UserIdGroupPairs', []):
|
|
139
|
+
if group_pair.get('GroupId') in instance_security_groups:
|
|
140
|
+
needs_rule = False
|
|
141
|
+
break
|
|
142
|
+
if not needs_rule:
|
|
143
|
+
break
|
|
144
|
+
|
|
145
|
+
# Add rule if needed
|
|
146
|
+
if needs_rule:
|
|
147
|
+
ec2_client.authorize_security_group_ingress(
|
|
148
|
+
GroupId=cache_sg_id,
|
|
149
|
+
IpPermissions=[
|
|
150
|
+
{
|
|
151
|
+
'IpProtocol': 'tcp',
|
|
152
|
+
'FromPort': cache_port,
|
|
153
|
+
'ToPort': cache_port,
|
|
154
|
+
'UserIdGroupPairs': [
|
|
155
|
+
{
|
|
156
|
+
'GroupId': instance_security_groups[0],
|
|
157
|
+
'Description': f'Allow access from jump host {instance_id}',
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return True, cache_vpc_id, cache_port
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@mcp.tool(name='connect-jump-host-replication-group')
|
|
168
|
+
@handle_exceptions
|
|
169
|
+
async def connect_jump_host_rg(replication_group_id: str, instance_id: str) -> Dict[str, Any]:
|
|
170
|
+
"""Configures an existing EC2 instance as a jump host to access an ElastiCache replication group.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
replication_group_id (str): ID of the ElastiCache replication group to connect to
|
|
174
|
+
instance_id (str): ID of the EC2 instance to use as jump host
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
Dict[str, Any]: Dictionary containing connection details and configuration status
|
|
178
|
+
|
|
179
|
+
Raises:
|
|
180
|
+
ValueError: If VPC compatibility check fails or required resources not found
|
|
181
|
+
"""
|
|
182
|
+
# Check if readonly mode is enabled
|
|
183
|
+
if Context.readonly_mode():
|
|
184
|
+
raise ValueError(
|
|
185
|
+
'You have configured this tool in readonly mode. To make this change you will have to update your configuration.'
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
# Configure security groups using common function
|
|
190
|
+
configured, vpc_id, cache_port = await _configure_security_groups(
|
|
191
|
+
replication_group_id, instance_id
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
'Status': 'Success',
|
|
196
|
+
'InstanceId': instance_id,
|
|
197
|
+
'ReplicationGroupId': replication_group_id,
|
|
198
|
+
'CachePort': cache_port,
|
|
199
|
+
'VpcId': vpc_id,
|
|
200
|
+
'SecurityGroupsConfigured': configured,
|
|
201
|
+
'Message': 'Jump host connection configured successfully',
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
except Exception as e:
|
|
205
|
+
raise ValueError(str(e))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@mcp.tool(name='get-ssh-tunnel-command-replication-group')
|
|
209
|
+
@handle_exceptions
|
|
210
|
+
async def get_ssh_tunnel_command_rg(
|
|
211
|
+
replication_group_id: str, instance_id: str
|
|
212
|
+
) -> Dict[str, Union[str, int, List[Dict[str, Any]], None]]:
|
|
213
|
+
"""Generates SSH tunnel commands to connect to an ElastiCache replication group through an EC2 jump host.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
replication_group_id (str): ID of the ElastiCache replication group to connect to
|
|
217
|
+
instance_id (str): ID of the EC2 instance to use as jump host
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
Dict[str, Union[str, int, List[Dict[str, Any]]]]: Dictionary containing SSH tunnel commands and related details
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
ValueError: If required resources not found or information cannot be retrieved
|
|
224
|
+
"""
|
|
225
|
+
# Get AWS clients
|
|
226
|
+
ec2_client = EC2ConnectionManager.get_connection()
|
|
227
|
+
elasticache_client = ElastiCacheConnectionManager.get_connection()
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
# Get EC2 instance details
|
|
231
|
+
instance_info = ec2_client.describe_instances(InstanceIds=[instance_id])
|
|
232
|
+
if not instance_info['Reservations']:
|
|
233
|
+
raise ValueError(f'EC2 instance {instance_id} not found')
|
|
234
|
+
|
|
235
|
+
instance = instance_info['Reservations'][0]['Instances'][0]
|
|
236
|
+
|
|
237
|
+
# Get instance key name and public DNS
|
|
238
|
+
key_name = instance.get('KeyName')
|
|
239
|
+
if not key_name:
|
|
240
|
+
raise ValueError(f'No key pair associated with EC2 instance {instance_id}')
|
|
241
|
+
|
|
242
|
+
public_dns = instance.get('PublicDnsName')
|
|
243
|
+
if not public_dns:
|
|
244
|
+
raise ValueError(f'No public DNS name found for EC2 instance {instance_id}')
|
|
245
|
+
|
|
246
|
+
# Get instance platform details to determine user
|
|
247
|
+
platform = instance.get('Platform', '')
|
|
248
|
+
user = 'ec2-user' # Default for Amazon Linux
|
|
249
|
+
if platform.lower() == 'windows':
|
|
250
|
+
raise ValueError('Windows instances are not supported for SSH tunneling')
|
|
251
|
+
elif 'ubuntu' in instance.get('ImageId', '').lower():
|
|
252
|
+
user = 'ubuntu'
|
|
253
|
+
|
|
254
|
+
# Get replication group details
|
|
255
|
+
replication_group = elasticache_client.describe_replication_groups(
|
|
256
|
+
ReplicationGroupId=replication_group_id
|
|
257
|
+
)['ReplicationGroups'][0]
|
|
258
|
+
|
|
259
|
+
# Get node details for all clusters
|
|
260
|
+
node_commands = []
|
|
261
|
+
base_port = None
|
|
262
|
+
|
|
263
|
+
for member in replication_group['MemberClusters']:
|
|
264
|
+
cluster = elasticache_client.describe_cache_clusters(
|
|
265
|
+
CacheClusterId=member, ShowCacheNodeInfo=True
|
|
266
|
+
)['CacheClusters'][0]
|
|
267
|
+
|
|
268
|
+
if not cluster.get('CacheNodes'):
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
node = cluster['CacheNodes'][0]
|
|
272
|
+
endpoint = node['Endpoint']['Address']
|
|
273
|
+
port = node['Endpoint']['Port']
|
|
274
|
+
|
|
275
|
+
if base_port is None:
|
|
276
|
+
base_port = port
|
|
277
|
+
|
|
278
|
+
# For replicas, use different local ports to avoid conflicts
|
|
279
|
+
local_port = port
|
|
280
|
+
if cluster['CacheClusterRole'].lower() != 'primary':
|
|
281
|
+
local_port = port + 1000 # Use a different port range for replicas
|
|
282
|
+
|
|
283
|
+
ssh_command = (
|
|
284
|
+
f'ssh -i "{key_name}.pem" -fN -l {user} '
|
|
285
|
+
f'-L {local_port}:{endpoint}:{port} {public_dns} -v'
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
node_commands.append(
|
|
289
|
+
{
|
|
290
|
+
'role': cluster['CacheClusterRole'],
|
|
291
|
+
'clusterId': member,
|
|
292
|
+
'command': ssh_command,
|
|
293
|
+
'localPort': local_port,
|
|
294
|
+
'remoteEndpoint': endpoint,
|
|
295
|
+
'remotePort': port,
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return {
|
|
300
|
+
'keyName': key_name,
|
|
301
|
+
'user': user,
|
|
302
|
+
'jumpHostDns': public_dns,
|
|
303
|
+
'nodes': node_commands,
|
|
304
|
+
'basePort': base_port,
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
except Exception as e:
|
|
308
|
+
raise ValueError(str(e))
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@mcp.tool(name='create-jump-host-replication-group')
|
|
312
|
+
@handle_exceptions
|
|
313
|
+
async def create_jump_host_rg(
|
|
314
|
+
replication_group_id: str,
|
|
315
|
+
subnet_id: str,
|
|
316
|
+
security_group_id: str,
|
|
317
|
+
key_name: str,
|
|
318
|
+
instance_type: str = 't3.small',
|
|
319
|
+
) -> Dict[str, Any]:
|
|
320
|
+
"""Creates an EC2 jump host instance to access an ElastiCache replication group via SSH tunnel.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
replication_group_id (str): ID of the ElastiCache replication group to connect to
|
|
324
|
+
subnet_id (str): ID of the subnet to launch the EC2 instance in (must be public)
|
|
325
|
+
security_group_id (str): ID of the security group to assign to the EC2 instance
|
|
326
|
+
key_name (str): Name of the EC2 key pair to use for SSH access
|
|
327
|
+
instance_type (str, optional): EC2 instance type. Defaults to "t3.small"
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
Dict[str, Any]: Dictionary containing the created EC2 instance details
|
|
331
|
+
|
|
332
|
+
Raises:
|
|
333
|
+
ValueError: If subnet is not public or VPC compatibility check fails
|
|
334
|
+
"""
|
|
335
|
+
# Check if readonly mode is enabled
|
|
336
|
+
if Context.readonly_mode():
|
|
337
|
+
raise ValueError(
|
|
338
|
+
'You have configured this tool in readonly mode. To make this change you will have to update your configuration.'
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Get AWS clients from connection managers
|
|
342
|
+
ec2_client = EC2ConnectionManager.get_connection()
|
|
343
|
+
elasticache_client = ElastiCacheConnectionManager.get_connection()
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
# Validate key_name
|
|
347
|
+
if not key_name:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
'key_name is required. Use CreateKeyPair or ImportKeyPair EC2 APIs to create/import an SSH key pair.'
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Verify key pair exists
|
|
353
|
+
key_pairs = ec2_client.describe_key_pairs(KeyNames=[key_name])
|
|
354
|
+
if not key_pairs.get('KeyPairs'):
|
|
355
|
+
return {
|
|
356
|
+
'error': f"Key pair '{key_name}' not found. Use CreateKeyPair or ImportKeyPair EC2 APIs to create/import an SSH key pair."
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Get replication group details
|
|
360
|
+
replication_group = elasticache_client.describe_replication_groups(
|
|
361
|
+
ReplicationGroupId=replication_group_id
|
|
362
|
+
)['ReplicationGroups'][0]
|
|
363
|
+
|
|
364
|
+
# Get primary cluster details
|
|
365
|
+
primary_cluster_id = None
|
|
366
|
+
for member in replication_group['MemberClusters']:
|
|
367
|
+
cluster = elasticache_client.describe_cache_clusters(
|
|
368
|
+
CacheClusterId=member, ShowCacheNodeInfo=True
|
|
369
|
+
)['CacheClusters'][0]
|
|
370
|
+
if cluster['CacheClusterRole'].lower() == 'primary':
|
|
371
|
+
primary_cluster_id = member
|
|
372
|
+
break
|
|
373
|
+
|
|
374
|
+
if not primary_cluster_id:
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f'No primary cluster found in replication group {replication_group_id}'
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Get VPC details from primary cluster
|
|
380
|
+
primary_cluster = elasticache_client.describe_cache_clusters(
|
|
381
|
+
CacheClusterId=primary_cluster_id, ShowCacheNodeInfo=True
|
|
382
|
+
)['CacheClusters'][0]
|
|
383
|
+
|
|
384
|
+
cache_subnet_group = elasticache_client.describe_cache_subnet_groups(
|
|
385
|
+
CacheSubnetGroupName=primary_cluster['CacheSubnetGroupName']
|
|
386
|
+
)['CacheSubnetGroups'][0]
|
|
387
|
+
cache_vpc_id = cache_subnet_group['VpcId']
|
|
388
|
+
|
|
389
|
+
# Get subnet details and verify it's public
|
|
390
|
+
subnet_response = ec2_client.describe_subnets(SubnetIds=[subnet_id])
|
|
391
|
+
subnet = subnet_response['Subnets'][0]
|
|
392
|
+
subnet_vpc_id = subnet['VpcId']
|
|
393
|
+
|
|
394
|
+
# Check VPC compatibility
|
|
395
|
+
if subnet_vpc_id != cache_vpc_id:
|
|
396
|
+
raise ValueError(
|
|
397
|
+
f'Subnet VPC ({subnet_vpc_id}) does not match replication group VPC ({cache_vpc_id})'
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Check if subnet is public by looking for route to internet gateway
|
|
401
|
+
route_tables = ec2_client.describe_route_tables(
|
|
402
|
+
Filters=[{'Name': 'association.subnet-id', 'Values': [subnet_id]}]
|
|
403
|
+
)['RouteTables']
|
|
404
|
+
|
|
405
|
+
# If no explicit route table association, check main route table
|
|
406
|
+
if not route_tables:
|
|
407
|
+
route_tables = ec2_client.describe_route_tables(
|
|
408
|
+
Filters=[
|
|
409
|
+
{'Name': 'vpc-id', 'Values': [subnet_vpc_id]},
|
|
410
|
+
{'Name': 'association.main', 'Values': ['true']},
|
|
411
|
+
]
|
|
412
|
+
)['RouteTables']
|
|
413
|
+
|
|
414
|
+
# Check for route to internet gateway
|
|
415
|
+
is_public = False
|
|
416
|
+
for rt in route_tables:
|
|
417
|
+
for route in rt.get('Routes', []):
|
|
418
|
+
if route.get('GatewayId', '').startswith('igw-'):
|
|
419
|
+
is_public = True
|
|
420
|
+
break
|
|
421
|
+
if is_public:
|
|
422
|
+
break
|
|
423
|
+
|
|
424
|
+
# Raise error if no route to internet gateway found
|
|
425
|
+
if not is_public:
|
|
426
|
+
raise ValueError(
|
|
427
|
+
f'Subnet {subnet_id} is not public (no route to internet gateway found). '
|
|
428
|
+
'The subnet must be public to allow SSH access to the jump host.'
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# Use Amazon Linux 2023 AMI
|
|
432
|
+
images = ec2_client.describe_images(
|
|
433
|
+
Filters=[
|
|
434
|
+
{'Name': 'name', 'Values': ['al2023-ami-2023.*-x86_64']},
|
|
435
|
+
{'Name': 'owner-alias', 'Values': ['amazon']},
|
|
436
|
+
]
|
|
437
|
+
)
|
|
438
|
+
ami_id = sorted(images['Images'], key=lambda x: x['CreationDate'], reverse=True)[0][
|
|
439
|
+
'ImageId'
|
|
440
|
+
]
|
|
441
|
+
|
|
442
|
+
# Verify and update security group rules for SSH access
|
|
443
|
+
security_group = ec2_client.describe_security_groups(GroupIds=[security_group_id])[
|
|
444
|
+
'SecurityGroups'
|
|
445
|
+
][0]
|
|
446
|
+
|
|
447
|
+
# Check if port 22 is already open
|
|
448
|
+
has_ssh_rule = False
|
|
449
|
+
for rule in security_group.get('IpPermissions', []):
|
|
450
|
+
if (
|
|
451
|
+
rule.get('IpProtocol') == 'tcp'
|
|
452
|
+
and rule.get('FromPort') == 22
|
|
453
|
+
and rule.get('ToPort') == 22
|
|
454
|
+
and any(
|
|
455
|
+
ip_range.get('CidrIp') == '0.0.0.0/0' for ip_range in rule.get('IpRanges', [])
|
|
456
|
+
)
|
|
457
|
+
):
|
|
458
|
+
has_ssh_rule = True
|
|
459
|
+
break
|
|
460
|
+
|
|
461
|
+
# Add SSH rule if it doesn't exist
|
|
462
|
+
if not has_ssh_rule:
|
|
463
|
+
ec2_client.authorize_security_group_ingress(
|
|
464
|
+
GroupId=security_group_id,
|
|
465
|
+
IpPermissions=[
|
|
466
|
+
{
|
|
467
|
+
'IpProtocol': 'tcp',
|
|
468
|
+
'FromPort': 22,
|
|
469
|
+
'ToPort': 22,
|
|
470
|
+
'IpRanges': [
|
|
471
|
+
{'CidrIp': '0.0.0.0/0', 'Description': 'SSH access from anywhere'}
|
|
472
|
+
],
|
|
473
|
+
}
|
|
474
|
+
],
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Launch EC2 instance
|
|
478
|
+
instance = ec2_client.run_instances(
|
|
479
|
+
ImageId=ami_id,
|
|
480
|
+
InstanceType=instance_type,
|
|
481
|
+
KeyName=key_name,
|
|
482
|
+
MaxCount=1,
|
|
483
|
+
MinCount=1,
|
|
484
|
+
NetworkInterfaces=[
|
|
485
|
+
{
|
|
486
|
+
'SubnetId': subnet_id,
|
|
487
|
+
'DeviceIndex': 0,
|
|
488
|
+
'AssociatePublicIpAddress': True,
|
|
489
|
+
'Groups': [security_group_id],
|
|
490
|
+
}
|
|
491
|
+
],
|
|
492
|
+
TagSpecifications=[
|
|
493
|
+
{
|
|
494
|
+
'ResourceType': 'instance',
|
|
495
|
+
'Tags': [
|
|
496
|
+
{'Key': 'Name', 'Value': f'ElastiCache-JumpHost-{replication_group_id}'}
|
|
497
|
+
],
|
|
498
|
+
}
|
|
499
|
+
],
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
# Wait for instance to be running and get its public IP
|
|
503
|
+
waiter = ec2_client.get_waiter('instance_running')
|
|
504
|
+
instance_id = instance['Instances'][0]['InstanceId']
|
|
505
|
+
waiter.wait(InstanceIds=[instance_id])
|
|
506
|
+
|
|
507
|
+
instance_info = ec2_client.describe_instances(InstanceIds=[instance_id])
|
|
508
|
+
public_ip = instance_info['Reservations'][0]['Instances'][0]['PublicIpAddress']
|
|
509
|
+
|
|
510
|
+
# Configure security groups using common function
|
|
511
|
+
configured, vpc_id, cache_port = await _configure_security_groups(
|
|
512
|
+
replication_group_id,
|
|
513
|
+
instance_id,
|
|
514
|
+
ec2_client=ec2_client,
|
|
515
|
+
elasticache_client=elasticache_client,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
'InstanceId': instance_id,
|
|
520
|
+
'PublicIpAddress': public_ip,
|
|
521
|
+
'InstanceType': instance_type,
|
|
522
|
+
'SubnetId': subnet_id,
|
|
523
|
+
'SecurityGroupId': security_group_id,
|
|
524
|
+
'ReplicationGroupId': replication_group_id,
|
|
525
|
+
'SecurityGroupsConfigured': configured,
|
|
526
|
+
'CachePort': cache_port,
|
|
527
|
+
'VpcId': vpc_id,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
except ClientError as e:
|
|
531
|
+
if e.response['Error']['Code'] == 'InvalidKeyPair.NotFound':
|
|
532
|
+
return {
|
|
533
|
+
'error': f"Key pair '{key_name}' not found. Use CreateKeyPair or ImportKeyPair EC2 APIs to create/import an SSH key pair."
|
|
534
|
+
}
|
|
535
|
+
return {'error': str(e)}
|
|
536
|
+
except Exception as e:
|
|
537
|
+
return {'error': str(e)}
|