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.
Files changed (60) hide show
  1. awslabs/__init__.py +16 -0
  2. awslabs/elasticache_mcp_server/__init__.py +17 -0
  3. awslabs/elasticache_mcp_server/common/__init__.py +15 -0
  4. awslabs/elasticache_mcp_server/common/connection.py +117 -0
  5. awslabs/elasticache_mcp_server/common/decorators.py +41 -0
  6. awslabs/elasticache_mcp_server/common/server.py +30 -0
  7. awslabs/elasticache_mcp_server/context.py +39 -0
  8. awslabs/elasticache_mcp_server/main.py +52 -0
  9. awslabs/elasticache_mcp_server/tools/__init__.py +15 -0
  10. awslabs/elasticache_mcp_server/tools/cc/__init__.py +31 -0
  11. awslabs/elasticache_mcp_server/tools/cc/connect.py +444 -0
  12. awslabs/elasticache_mcp_server/tools/cc/create.py +212 -0
  13. awslabs/elasticache_mcp_server/tools/cc/delete.py +65 -0
  14. awslabs/elasticache_mcp_server/tools/cc/describe.py +80 -0
  15. awslabs/elasticache_mcp_server/tools/cc/modify.py +159 -0
  16. awslabs/elasticache_mcp_server/tools/cc/parsers.py +78 -0
  17. awslabs/elasticache_mcp_server/tools/cc/processors.py +74 -0
  18. awslabs/elasticache_mcp_server/tools/ce/__init__.py +19 -0
  19. awslabs/elasticache_mcp_server/tools/ce/get_cost_and_usage.py +76 -0
  20. awslabs/elasticache_mcp_server/tools/cw/__init__.py +19 -0
  21. awslabs/elasticache_mcp_server/tools/cw/get_metric_statistics.py +85 -0
  22. awslabs/elasticache_mcp_server/tools/cwlogs/__init__.py +29 -0
  23. awslabs/elasticache_mcp_server/tools/cwlogs/create_log_group.py +68 -0
  24. awslabs/elasticache_mcp_server/tools/cwlogs/describe_log_groups.py +123 -0
  25. awslabs/elasticache_mcp_server/tools/cwlogs/describe_log_streams.py +120 -0
  26. awslabs/elasticache_mcp_server/tools/cwlogs/filter_log_events.py +122 -0
  27. awslabs/elasticache_mcp_server/tools/cwlogs/get_log_events.py +99 -0
  28. awslabs/elasticache_mcp_server/tools/firehose/__init__.py +19 -0
  29. awslabs/elasticache_mcp_server/tools/firehose/list_delivery_streams.py +63 -0
  30. awslabs/elasticache_mcp_server/tools/misc/__init__.py +31 -0
  31. awslabs/elasticache_mcp_server/tools/misc/batch_apply_update_action.py +62 -0
  32. awslabs/elasticache_mcp_server/tools/misc/batch_stop_update_action.py +62 -0
  33. awslabs/elasticache_mcp_server/tools/misc/describe_cache_engine_versions.py +79 -0
  34. awslabs/elasticache_mcp_server/tools/misc/describe_engine_default_parameters.py +64 -0
  35. awslabs/elasticache_mcp_server/tools/misc/describe_events.py +86 -0
  36. awslabs/elasticache_mcp_server/tools/misc/describe_service_updates.py +71 -0
  37. awslabs/elasticache_mcp_server/tools/rg/__init__.py +54 -0
  38. awslabs/elasticache_mcp_server/tools/rg/complete_migration.py +94 -0
  39. awslabs/elasticache_mcp_server/tools/rg/connect.py +537 -0
  40. awslabs/elasticache_mcp_server/tools/rg/create.py +318 -0
  41. awslabs/elasticache_mcp_server/tools/rg/delete.py +68 -0
  42. awslabs/elasticache_mcp_server/tools/rg/describe.py +68 -0
  43. awslabs/elasticache_mcp_server/tools/rg/modify.py +236 -0
  44. awslabs/elasticache_mcp_server/tools/rg/parsers.py +268 -0
  45. awslabs/elasticache_mcp_server/tools/rg/processors.py +227 -0
  46. awslabs/elasticache_mcp_server/tools/rg/start_migration.py +151 -0
  47. awslabs/elasticache_mcp_server/tools/rg/test_migration.py +139 -0
  48. awslabs/elasticache_mcp_server/tools/serverless/__init__.py +37 -0
  49. awslabs/elasticache_mcp_server/tools/serverless/connect.py +451 -0
  50. awslabs/elasticache_mcp_server/tools/serverless/create.py +174 -0
  51. awslabs/elasticache_mcp_server/tools/serverless/delete.py +49 -0
  52. awslabs/elasticache_mcp_server/tools/serverless/describe.py +69 -0
  53. awslabs/elasticache_mcp_server/tools/serverless/models.py +160 -0
  54. awslabs/elasticache_mcp_server/tools/serverless/modify.py +95 -0
  55. awslabs_elasticache_mcp_server-0.1.1.dist-info/METADATA +257 -0
  56. awslabs_elasticache_mcp_server-0.1.1.dist-info/RECORD +60 -0
  57. awslabs_elasticache_mcp_server-0.1.1.dist-info/WHEEL +4 -0
  58. awslabs_elasticache_mcp_server-0.1.1.dist-info/entry_points.txt +2 -0
  59. awslabs_elasticache_mcp_server-0.1.1.dist-info/licenses/LICENSE +175 -0
  60. 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)}