runbooks 0.7.0__py3-none-any.whl → 0.7.6__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 (132) hide show
  1. runbooks/__init__.py +87 -37
  2. runbooks/cfat/README.md +300 -49
  3. runbooks/cfat/__init__.py +2 -2
  4. runbooks/finops/__init__.py +1 -1
  5. runbooks/finops/cli.py +1 -1
  6. runbooks/inventory/collectors/__init__.py +8 -0
  7. runbooks/inventory/collectors/aws_management.py +791 -0
  8. runbooks/inventory/collectors/aws_networking.py +3 -3
  9. runbooks/main.py +3389 -782
  10. runbooks/operate/__init__.py +207 -0
  11. runbooks/operate/base.py +311 -0
  12. runbooks/operate/cloudformation_operations.py +619 -0
  13. runbooks/operate/cloudwatch_operations.py +496 -0
  14. runbooks/operate/dynamodb_operations.py +812 -0
  15. runbooks/operate/ec2_operations.py +926 -0
  16. runbooks/operate/iam_operations.py +569 -0
  17. runbooks/operate/s3_operations.py +1211 -0
  18. runbooks/operate/tagging_operations.py +655 -0
  19. runbooks/remediation/CLAUDE.md +100 -0
  20. runbooks/remediation/DOME9.md +218 -0
  21. runbooks/remediation/README.md +26 -0
  22. runbooks/remediation/Tests/__init__.py +0 -0
  23. runbooks/remediation/Tests/update_policy.py +74 -0
  24. runbooks/remediation/__init__.py +95 -0
  25. runbooks/remediation/acm_cert_expired_unused.py +98 -0
  26. runbooks/remediation/acm_remediation.py +875 -0
  27. runbooks/remediation/api_gateway_list.py +167 -0
  28. runbooks/remediation/base.py +643 -0
  29. runbooks/remediation/cloudtrail_remediation.py +908 -0
  30. runbooks/remediation/cloudtrail_s3_modifications.py +296 -0
  31. runbooks/remediation/cognito_active_users.py +78 -0
  32. runbooks/remediation/cognito_remediation.py +856 -0
  33. runbooks/remediation/cognito_user_password_reset.py +163 -0
  34. runbooks/remediation/commons.py +455 -0
  35. runbooks/remediation/dynamodb_optimize.py +155 -0
  36. runbooks/remediation/dynamodb_remediation.py +744 -0
  37. runbooks/remediation/dynamodb_server_side_encryption.py +108 -0
  38. runbooks/remediation/ec2_public_ips.py +134 -0
  39. runbooks/remediation/ec2_remediation.py +892 -0
  40. runbooks/remediation/ec2_subnet_disable_auto_ip_assignment.py +72 -0
  41. runbooks/remediation/ec2_unattached_ebs_volumes.py +448 -0
  42. runbooks/remediation/ec2_unused_security_groups.py +202 -0
  43. runbooks/remediation/kms_enable_key_rotation.py +651 -0
  44. runbooks/remediation/kms_remediation.py +717 -0
  45. runbooks/remediation/lambda_list.py +243 -0
  46. runbooks/remediation/lambda_remediation.py +971 -0
  47. runbooks/remediation/multi_account.py +569 -0
  48. runbooks/remediation/rds_instance_list.py +199 -0
  49. runbooks/remediation/rds_remediation.py +873 -0
  50. runbooks/remediation/rds_snapshot_list.py +192 -0
  51. runbooks/remediation/requirements.txt +118 -0
  52. runbooks/remediation/s3_block_public_access.py +159 -0
  53. runbooks/remediation/s3_bucket_public_access.py +143 -0
  54. runbooks/remediation/s3_disable_static_website_hosting.py +74 -0
  55. runbooks/remediation/s3_downloader.py +215 -0
  56. runbooks/remediation/s3_enable_access_logging.py +562 -0
  57. runbooks/remediation/s3_encryption.py +526 -0
  58. runbooks/remediation/s3_force_ssl_secure_policy.py +143 -0
  59. runbooks/remediation/s3_list.py +141 -0
  60. runbooks/remediation/s3_object_search.py +201 -0
  61. runbooks/remediation/s3_remediation.py +816 -0
  62. runbooks/remediation/scan_for_phrase.py +425 -0
  63. runbooks/remediation/workspaces_list.py +220 -0
  64. runbooks/security/__init__.py +9 -10
  65. runbooks/security/security_baseline_tester.py +4 -2
  66. runbooks-0.7.6.dist-info/METADATA +608 -0
  67. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/RECORD +84 -76
  68. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/entry_points.txt +0 -1
  69. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/top_level.txt +0 -1
  70. jupyter-agent/.env +0 -2
  71. jupyter-agent/.env.template +0 -2
  72. jupyter-agent/.gitattributes +0 -35
  73. jupyter-agent/.gradio/certificate.pem +0 -31
  74. jupyter-agent/README.md +0 -16
  75. jupyter-agent/__main__.log +0 -8
  76. jupyter-agent/app.py +0 -256
  77. jupyter-agent/cloudops-agent.png +0 -0
  78. jupyter-agent/ds-system-prompt.txt +0 -154
  79. jupyter-agent/jupyter-agent.png +0 -0
  80. jupyter-agent/llama3_template.jinja +0 -123
  81. jupyter-agent/requirements.txt +0 -9
  82. jupyter-agent/tmp/4ojbs8a02ir/jupyter-agent.ipynb +0 -68
  83. jupyter-agent/tmp/cm5iasgpm3p/jupyter-agent.ipynb +0 -91
  84. jupyter-agent/tmp/crqbsseag5/jupyter-agent.ipynb +0 -91
  85. jupyter-agent/tmp/hohanq1u097/jupyter-agent.ipynb +0 -57
  86. jupyter-agent/tmp/jns1sam29wm/jupyter-agent.ipynb +0 -53
  87. jupyter-agent/tmp/jupyter-agent.ipynb +0 -27
  88. jupyter-agent/utils.py +0 -409
  89. runbooks/aws/__init__.py +0 -58
  90. runbooks/aws/dynamodb_operations.py +0 -231
  91. runbooks/aws/ec2_copy_image_cross-region.py +0 -195
  92. runbooks/aws/ec2_describe_instances.py +0 -202
  93. runbooks/aws/ec2_ebs_snapshots_delete.py +0 -186
  94. runbooks/aws/ec2_run_instances.py +0 -213
  95. runbooks/aws/ec2_start_stop_instances.py +0 -212
  96. runbooks/aws/ec2_terminate_instances.py +0 -143
  97. runbooks/aws/ec2_unused_eips.py +0 -196
  98. runbooks/aws/ec2_unused_volumes.py +0 -188
  99. runbooks/aws/s3_create_bucket.py +0 -142
  100. runbooks/aws/s3_list_buckets.py +0 -152
  101. runbooks/aws/s3_list_objects.py +0 -156
  102. runbooks/aws/s3_object_operations.py +0 -183
  103. runbooks/aws/tagging_lambda_handler.py +0 -183
  104. runbooks/inventory/FAILED_SCRIPTS_TROUBLESHOOTING.md +0 -619
  105. runbooks/inventory/PASSED_SCRIPTS_GUIDE.md +0 -738
  106. runbooks/inventory/aws_organization.png +0 -0
  107. runbooks/inventory/cfn_move_stack_instances.py +0 -1526
  108. runbooks/inventory/delete_s3_buckets_objects.py +0 -169
  109. runbooks/inventory/lockdown_cfn_stackset_role.py +0 -224
  110. runbooks/inventory/update_aws_actions.py +0 -173
  111. runbooks/inventory/update_cfn_stacksets.py +0 -1215
  112. runbooks/inventory/update_cloudwatch_logs_retention_policy.py +0 -294
  113. runbooks/inventory/update_iam_roles_cross_accounts.py +0 -478
  114. runbooks/inventory/update_s3_public_access_block.py +0 -539
  115. runbooks/organizations/__init__.py +0 -12
  116. runbooks/organizations/manager.py +0 -374
  117. runbooks-0.7.0.dist-info/METADATA +0 -375
  118. /runbooks/inventory/{tests → Tests}/common_test_data.py +0 -0
  119. /runbooks/inventory/{tests → Tests}/common_test_functions.py +0 -0
  120. /runbooks/inventory/{tests → Tests}/script_test_data.py +0 -0
  121. /runbooks/inventory/{tests → Tests}/setup.py +0 -0
  122. /runbooks/inventory/{tests → Tests}/src.py +0 -0
  123. /runbooks/inventory/{tests/test_inventory_modules.py → Tests/test_Inventory_Modules.py} +0 -0
  124. /runbooks/inventory/{tests → Tests}/test_cfn_describe_stacks.py +0 -0
  125. /runbooks/inventory/{tests → Tests}/test_ec2_describe_instances.py +0 -0
  126. /runbooks/inventory/{tests → Tests}/test_lambda_list_functions.py +0 -0
  127. /runbooks/inventory/{tests → Tests}/test_moto_integration_example.py +0 -0
  128. /runbooks/inventory/{tests → Tests}/test_org_list_accounts.py +0 -0
  129. /runbooks/inventory/{Inventory_Modules.py → inventory_modules.py} +0 -0
  130. /runbooks/{aws → operate}/tags.json +0 -0
  131. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/WHEEL +0 -0
  132. {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,926 @@
1
+ """
2
+ Enterprise-Grade EC2 Operations Module.
3
+
4
+ Comprehensive EC2 resource management with Lambda support, environment configuration,
5
+ SNS notifications, and full compatibility with original AWS Cloud Foundations scripts.
6
+
7
+ Migrated and enhanced from:
8
+ - aws/ec2_terminate_instances.py (with Lambda handler)
9
+ - aws/ec2_start_stop_instances.py (with state management)
10
+ - aws/ec2_run_instances.py (with block device mappings)
11
+ - aws/ec2_copy_image_cross-region.py (with image creation)
12
+ - aws/ec2_ebs_snapshots_delete.py (with safety checks)
13
+ - aws/ec2_unused_volumes.py (with SNS notifications)
14
+ - aws/ec2_unused_eips.py (with comprehensive scanning)
15
+
16
+ Author: CloudOps DevOps Engineer
17
+ Date: 2025-01-21
18
+ Version: 2.0.0 - Enterprise Enhancement
19
+ """
20
+
21
+ import base64
22
+ import json
23
+ import os
24
+ from datetime import datetime
25
+ from typing import Any, Dict, List, Optional, Union
26
+
27
+ import boto3
28
+ from botocore.exceptions import BotoCoreError, ClientError
29
+ from loguru import logger
30
+
31
+ from runbooks.operate.base import BaseOperation, OperationContext, OperationResult, OperationStatus
32
+
33
+
34
+ class EC2Operations(BaseOperation):
35
+ """
36
+ Enterprise-grade EC2 resource operations and lifecycle management.
37
+
38
+ Handles all EC2-related operational tasks including instance management,
39
+ volume operations, AMI operations, resource cleanup, and notifications.
40
+ Supports environment variable configuration and AWS Lambda execution.
41
+ """
42
+
43
+ service_name = "ec2"
44
+ supported_operations = {
45
+ "start_instances",
46
+ "stop_instances",
47
+ "terminate_instances",
48
+ "run_instances",
49
+ "copy_image",
50
+ "create_image",
51
+ "delete_snapshots",
52
+ "cleanup_unused_volumes",
53
+ "cleanup_unused_eips",
54
+ "reboot_instances",
55
+ }
56
+ requires_confirmation = True
57
+
58
+ def __init__(
59
+ self,
60
+ profile: Optional[str] = None,
61
+ region: Optional[str] = None,
62
+ dry_run: bool = False,
63
+ sns_topic_arn: Optional[str] = None,
64
+ ):
65
+ """
66
+ Initialize EC2 operations with enhanced configuration support.
67
+
68
+ Args:
69
+ profile: AWS profile name (can be overridden by AWS_PROFILE env var)
70
+ region: AWS region (can be overridden by AWS_REGION env var)
71
+ dry_run: Dry run mode (can be overridden by DRY_RUN env var)
72
+ sns_topic_arn: SNS topic for notifications (can be overridden by SNS_TOPIC_ARN env var)
73
+ """
74
+ # Environment variable support for Lambda/Container deployment
75
+ self.profile = profile or os.getenv("AWS_PROFILE")
76
+ self.region = region or os.getenv("AWS_REGION", "us-east-1")
77
+ self.dry_run = dry_run or os.getenv("DRY_RUN", "false").lower() == "true"
78
+ self.sns_topic_arn = sns_topic_arn or os.getenv("SNS_TOPIC_ARN")
79
+
80
+ super().__init__(self.profile, self.region, self.dry_run)
81
+
82
+ # Initialize SNS client for notifications
83
+ self.sns_client = None
84
+ if self.sns_topic_arn:
85
+ self.sns_client = self.get_client("sns", self.region)
86
+
87
+ def validate_sns_arn(self, arn: str) -> None:
88
+ """
89
+ Validates the format of the SNS Topic ARN.
90
+
91
+ Args:
92
+ arn: SNS Topic ARN
93
+
94
+ Raises:
95
+ ValueError: If the ARN format is invalid
96
+ """
97
+ if not arn.startswith("arn:aws:sns:"):
98
+ raise ValueError(f"Invalid SNS Topic ARN: {arn}")
99
+ logger.info(f"✅ Valid SNS ARN: {arn}")
100
+
101
+ def validate_regions(self, source_region: str, dest_region: str) -> None:
102
+ """
103
+ Validates AWS regions for cross-region operations.
104
+
105
+ Args:
106
+ source_region: Source AWS region
107
+ dest_region: Destination AWS region
108
+
109
+ Raises:
110
+ ValueError: If regions are invalid
111
+ """
112
+ session = boto3.session.Session()
113
+ valid_regions = session.get_available_regions("ec2")
114
+
115
+ if source_region not in valid_regions:
116
+ raise ValueError(f"Invalid source region: {source_region}")
117
+ if dest_region not in valid_regions:
118
+ raise ValueError(f"Invalid destination region: {dest_region}")
119
+ logger.info(f"Validated AWS regions: {source_region} -> {dest_region}")
120
+
121
+ def send_sns_notification(self, subject: str, message: str) -> None:
122
+ """
123
+ Send SNS notification if configured.
124
+
125
+ Args:
126
+ subject: Notification subject
127
+ message: Notification message
128
+ """
129
+ if self.sns_client and self.sns_topic_arn:
130
+ try:
131
+ self.sns_client.publish(TopicArn=self.sns_topic_arn, Subject=subject, Message=message)
132
+ logger.info(f"SNS notification sent: {subject}")
133
+ except ClientError as e:
134
+ logger.warning(f"Failed to send SNS notification: {e}")
135
+
136
+ def get_default_block_device_mappings(self, volume_size: int = 20, encrypted: bool = True) -> List[Dict]:
137
+ """
138
+ Get default block device mappings with modern EBS configuration.
139
+
140
+ Args:
141
+ volume_size: EBS volume size in GB
142
+ encrypted: Whether to encrypt the EBS volume
143
+
144
+ Returns:
145
+ Block device mappings configuration
146
+ """
147
+ return [
148
+ {
149
+ "DeviceName": "/dev/xvda", # Root volume device
150
+ "Ebs": {
151
+ "DeleteOnTermination": True, # Clean up after instance termination
152
+ "VolumeSize": volume_size, # Set volume size in GB
153
+ "VolumeType": "gp3", # Modern, faster storage
154
+ "Encrypted": encrypted, # Encrypt the EBS volume
155
+ },
156
+ },
157
+ ]
158
+
159
+ def execute_operation(self, context: OperationContext, operation_type: str, **kwargs) -> List[OperationResult]:
160
+ """Execute EC2 operation."""
161
+ self.validate_context(context)
162
+
163
+ if operation_type == "start_instances":
164
+ return self.start_instances(context, kwargs.get("instance_ids", []))
165
+ elif operation_type == "stop_instances":
166
+ return self.stop_instances(context, kwargs.get("instance_ids", []))
167
+ elif operation_type == "terminate_instances":
168
+ return self.terminate_instances(context, kwargs.get("instance_ids", []))
169
+ elif operation_type == "run_instances":
170
+ return self.run_instances(context, **kwargs)
171
+ elif operation_type == "copy_image":
172
+ return self.copy_image(context, **kwargs)
173
+ elif operation_type == "create_image":
174
+ return self.create_image(context, **kwargs)
175
+ elif operation_type == "delete_snapshots":
176
+ return self.delete_snapshots(context, kwargs.get("snapshot_ids", []))
177
+ elif operation_type == "cleanup_unused_volumes":
178
+ return self.cleanup_unused_volumes(context)
179
+ elif operation_type == "cleanup_unused_eips":
180
+ return self.cleanup_unused_eips(context)
181
+ elif operation_type == "reboot_instances":
182
+ return self.reboot_instances(context, kwargs.get("instance_ids", []))
183
+ else:
184
+ raise ValueError(f"Unsupported operation: {operation_type}")
185
+
186
+ def start_instances(self, context: OperationContext, instance_ids: List[str]) -> List[OperationResult]:
187
+ """Start EC2 instances."""
188
+ ec2_client = self.get_client("ec2", context.region)
189
+ results = []
190
+
191
+ for instance_id in instance_ids:
192
+ result = self.create_operation_result(context, "start_instances", "ec2:instance", instance_id)
193
+
194
+ try:
195
+ if context.dry_run:
196
+ logger.info(f"[DRY-RUN] Would start instance {instance_id}")
197
+ result.mark_completed(OperationStatus.DRY_RUN)
198
+ else:
199
+ response = self.execute_aws_call(ec2_client, "start_instances", InstanceIds=[instance_id])
200
+ result.response_data = response
201
+ result.mark_completed(OperationStatus.SUCCESS)
202
+ logger.info(f"Successfully started instance {instance_id}")
203
+
204
+ except ClientError as e:
205
+ error_msg = f"Failed to start instance {instance_id}: {e}"
206
+ logger.error(error_msg)
207
+ result.mark_completed(OperationStatus.FAILED, error_msg)
208
+
209
+ results.append(result)
210
+
211
+ return results
212
+
213
+ def stop_instances(self, context: OperationContext, instance_ids: List[str]) -> List[OperationResult]:
214
+ """Stop EC2 instances."""
215
+ ec2_client = self.get_client("ec2", context.region)
216
+ results = []
217
+
218
+ for instance_id in instance_ids:
219
+ result = self.create_operation_result(context, "stop_instances", "ec2:instance", instance_id)
220
+
221
+ try:
222
+ if context.dry_run:
223
+ logger.info(f"[DRY-RUN] Would stop instance {instance_id}")
224
+ result.mark_completed(OperationStatus.DRY_RUN)
225
+ else:
226
+ response = self.execute_aws_call(ec2_client, "stop_instances", InstanceIds=[instance_id])
227
+ result.response_data = response
228
+ result.mark_completed(OperationStatus.SUCCESS)
229
+ logger.info(f"Successfully stopped instance {instance_id}")
230
+
231
+ except ClientError as e:
232
+ error_msg = f"Failed to stop instance {instance_id}: {e}"
233
+ logger.error(error_msg)
234
+ result.mark_completed(OperationStatus.FAILED, error_msg)
235
+
236
+ results.append(result)
237
+
238
+ return results
239
+
240
+ def terminate_instances(self, context: OperationContext, instance_ids: List[str]) -> List[OperationResult]:
241
+ """
242
+ Terminate EC2 instances (DESTRUCTIVE) with enhanced validation and notifications.
243
+
244
+ Based on original aws/ec2_terminate_instances.py with enterprise enhancements.
245
+ """
246
+ # Enhanced validation from original file
247
+ if not instance_ids or instance_ids == [""]:
248
+ logger.error("No instance IDs provided for termination.")
249
+ raise ValueError("Instance IDs cannot be empty.")
250
+
251
+ ec2_client = self.get_client("ec2", context.region)
252
+ results = []
253
+ terminated_instances = []
254
+
255
+ logger.info(f"Terminating instances: {', '.join(instance_ids)} in region {context.region}...")
256
+
257
+ for instance_id in instance_ids:
258
+ result = self.create_operation_result(context, "terminate_instances", "ec2:instance", instance_id)
259
+
260
+ try:
261
+ if not self.confirm_operation(context, instance_id, "terminate"):
262
+ result.mark_completed(OperationStatus.CANCELLED, "Operation cancelled by user")
263
+ results.append(result)
264
+ continue
265
+
266
+ if context.dry_run:
267
+ logger.info(f"[DRY-RUN] No actual termination performed for {instance_id}")
268
+ result.mark_completed(OperationStatus.DRY_RUN)
269
+ else:
270
+ response = self.execute_aws_call(ec2_client, "terminate_instances", InstanceIds=[instance_id])
271
+
272
+ # Enhanced logging from original file
273
+ for instance in response["TerminatingInstances"]:
274
+ logger.info(
275
+ f"Instance {instance['InstanceId']} state changed to {instance['CurrentState']['Name']}"
276
+ )
277
+
278
+ terminated_instances.append(instance_id)
279
+ result.response_data = response
280
+ result.mark_completed(OperationStatus.SUCCESS)
281
+ logger.info(f"Successfully terminated instance {instance_id}")
282
+
283
+ except ClientError as e:
284
+ error_msg = f"AWS Client Error: {e}"
285
+ logger.error(error_msg)
286
+ result.mark_completed(OperationStatus.FAILED, error_msg)
287
+ except BotoCoreError as e:
288
+ error_msg = f"BotoCore Error: {e}"
289
+ logger.error(error_msg)
290
+ result.mark_completed(OperationStatus.FAILED, error_msg)
291
+ except Exception as e:
292
+ error_msg = f"Unexpected error: {e}"
293
+ logger.error(error_msg)
294
+ result.mark_completed(OperationStatus.FAILED, error_msg)
295
+
296
+ results.append(result)
297
+
298
+ # Send SNS notification if configured
299
+ if terminated_instances:
300
+ message = f"Successfully terminated instances: {', '.join(terminated_instances)}"
301
+ self.send_sns_notification("EC2 Instances Terminated", message)
302
+ logger.info(message)
303
+ elif not context.dry_run:
304
+ logger.info("No instances terminated.")
305
+
306
+ return results
307
+
308
+ def run_instances(
309
+ self,
310
+ context: OperationContext,
311
+ image_id: Optional[str] = None,
312
+ instance_type: Optional[str] = None,
313
+ min_count: Optional[int] = None,
314
+ max_count: Optional[int] = None,
315
+ key_name: Optional[str] = None,
316
+ security_group_ids: Optional[List[str]] = None,
317
+ subnet_id: Optional[str] = None,
318
+ user_data: Optional[str] = None,
319
+ instance_profile_name: Optional[str] = None,
320
+ tags: Optional[Dict[str, str]] = None,
321
+ volume_size: int = 20,
322
+ enable_monitoring: bool = False,
323
+ enable_encryption: bool = True,
324
+ ) -> List[OperationResult]:
325
+ """
326
+ Launch EC2 instances with comprehensive configuration.
327
+
328
+ Enhanced from original aws/ec2_run_instances.py with environment variable support,
329
+ block device mappings, monitoring, and enterprise-grade configuration.
330
+ """
331
+ # Environment variable support from original file
332
+ image_id = image_id or os.getenv("AMI_ID", "ami-03f052ebc3f436d52") # Default RHEL 9
333
+ instance_type = instance_type or os.getenv("INSTANCE_TYPE", "t2.micro")
334
+ min_count = min_count or int(os.getenv("MIN_COUNT", "1"))
335
+ max_count = max_count or int(os.getenv("MAX_COUNT", "1"))
336
+ key_name = key_name or os.getenv("KEY_NAME", "EC2Test")
337
+
338
+ # Parse security groups and subnet from environment
339
+ if not security_group_ids:
340
+ env_sg = os.getenv("SECURITY_GROUP_IDS", "")
341
+ security_group_ids = env_sg.split(",") if env_sg else []
342
+
343
+ subnet_id = subnet_id or os.getenv("SUBNET_ID")
344
+
345
+ # Parse tags from environment variable
346
+ if not tags:
347
+ env_tags = os.getenv("TAGS", '{"Project":"CloudOps", "Environment":"Dev"}')
348
+ try:
349
+ tags = json.loads(env_tags)
350
+ except json.JSONDecodeError:
351
+ tags = {"Project": "CloudOps", "Environment": "Dev"}
352
+
353
+ # Enhanced validation from original file
354
+ if not subnet_id:
355
+ raise ValueError("Missing required SUBNET_ID for VPC deployment")
356
+ if not security_group_ids:
357
+ raise ValueError("Missing required SECURITY_GROUP_IDS for VPC deployment")
358
+
359
+ logger.info("✅ Environment variables validated successfully.")
360
+
361
+ ec2_client = self.get_client("ec2", context.region)
362
+
363
+ result = self.create_operation_result(
364
+ context, "run_instances", "ec2:instance", f"{min_count}-{max_count} instances"
365
+ )
366
+
367
+ try:
368
+ logger.info(f"Launching {min_count}-{max_count} instances of type {instance_type} with AMI {image_id}...")
369
+
370
+ if context.dry_run:
371
+ logger.info(f"[DRY-RUN] Would launch {min_count}-{max_count} instances of {image_id}")
372
+ result.mark_completed(OperationStatus.DRY_RUN)
373
+ return [result]
374
+
375
+ # Enhanced parameters from original file
376
+ launch_params = {
377
+ "BlockDeviceMappings": self.get_default_block_device_mappings(volume_size, enable_encryption),
378
+ "ImageId": image_id,
379
+ "InstanceType": instance_type,
380
+ "MinCount": min_count,
381
+ "MaxCount": max_count,
382
+ "Monitoring": {"Enabled": enable_monitoring},
383
+ "KeyName": key_name,
384
+ "SubnetId": subnet_id,
385
+ "SecurityGroupIds": security_group_ids,
386
+ }
387
+
388
+ # Optional parameters
389
+ if user_data:
390
+ launch_params["UserData"] = base64.b64encode(user_data.encode()).decode()
391
+ if instance_profile_name:
392
+ launch_params["IamInstanceProfile"] = {"Name": instance_profile_name}
393
+
394
+ # Enhanced tagging from original file
395
+ if tags:
396
+ tag_specifications = [
397
+ {"ResourceType": "instance", "Tags": [{"Key": k, "Value": v} for k, v in tags.items()]}
398
+ ]
399
+ launch_params["TagSpecifications"] = tag_specifications
400
+
401
+ response = self.execute_aws_call(ec2_client, "run_instances", **launch_params)
402
+ instance_ids = [inst["InstanceId"] for inst in response["Instances"]]
403
+
404
+ logger.info(f"Launched Instances: {instance_ids}")
405
+
406
+ # Apply additional tags if needed (from original file approach)
407
+ if tags:
408
+ try:
409
+ ec2_client.create_tags(
410
+ Resources=instance_ids,
411
+ Tags=[{"Key": k, "Value": v} for k, v in tags.items()],
412
+ )
413
+ logger.info(f"✅ Applied tags to instances: {instance_ids}")
414
+ except ClientError as e:
415
+ logger.warning(f"Failed to apply additional tags: {e}")
416
+
417
+ result.response_data = response
418
+ result.mark_completed(OperationStatus.SUCCESS)
419
+ logger.info(f"Successfully launched {len(instance_ids)} instances")
420
+
421
+ # SNS notification
422
+ message = f"Successfully launched {len(instance_ids)} instances: {', '.join(instance_ids)}"
423
+ self.send_sns_notification("EC2 Instances Launched", message)
424
+
425
+ except ClientError as e:
426
+ error_msg = f"AWS Client Error: {e}"
427
+ logger.error(error_msg)
428
+ result.mark_completed(OperationStatus.FAILED, error_msg)
429
+ except BotoCoreError as e:
430
+ error_msg = f"BotoCore Error: {e}"
431
+ logger.error(error_msg)
432
+ result.mark_completed(OperationStatus.FAILED, error_msg)
433
+ except Exception as e:
434
+ error_msg = f"Unexpected error: {e}"
435
+ logger.error(error_msg)
436
+ result.mark_completed(OperationStatus.FAILED, error_msg)
437
+
438
+ return [result]
439
+
440
+ def copy_image(
441
+ self,
442
+ context: OperationContext,
443
+ source_image_id: str,
444
+ source_region: str,
445
+ name: str,
446
+ description: Optional[str] = None,
447
+ encrypted: bool = True,
448
+ kms_key_id: Optional[str] = None,
449
+ ) -> List[OperationResult]:
450
+ """
451
+ Copy AMI across regions with encryption and validation.
452
+
453
+ Enhanced from original aws/ec2_copy_image_cross-region.py.
454
+ """
455
+ # Validate regions using original file logic
456
+ self.validate_regions(source_region, context.region)
457
+
458
+ ec2_client = self.get_client("ec2", context.region)
459
+
460
+ result = self.create_operation_result(
461
+ context, "copy_image", "ec2:ami", f"{source_image_id}:{source_region}->{context.region}"
462
+ )
463
+
464
+ try:
465
+ if context.dry_run:
466
+ logger.info(f"[DRY-RUN] Would copy AMI {source_image_id} from {source_region}")
467
+ result.mark_completed(OperationStatus.DRY_RUN)
468
+ return [result]
469
+
470
+ copy_params = {
471
+ "SourceImageId": source_image_id,
472
+ "SourceRegion": source_region,
473
+ "Name": name,
474
+ "Encrypted": encrypted,
475
+ }
476
+
477
+ if description:
478
+ copy_params["Description"] = description
479
+ if kms_key_id and encrypted:
480
+ copy_params["KmsKeyId"] = kms_key_id
481
+
482
+ response = self.execute_aws_call(ec2_client, "copy_image", **copy_params)
483
+ new_image_id = response["ImageId"]
484
+
485
+ result.response_data = response
486
+ result.mark_completed(OperationStatus.SUCCESS)
487
+ logger.info(f"Successfully initiated AMI copy. New AMI ID: {new_image_id}")
488
+
489
+ # SNS notification
490
+ message = f"AMI {source_image_id} copied from {source_region} to {context.region}. New AMI: {new_image_id}"
491
+ self.send_sns_notification("EC2 AMI Copied", message)
492
+
493
+ except ClientError as e:
494
+ error_msg = f"AWS Client Error: {e}"
495
+ logger.error(error_msg)
496
+ result.mark_completed(OperationStatus.FAILED, error_msg)
497
+ except BotoCoreError as e:
498
+ error_msg = f"BotoCore Error: {e}"
499
+ logger.error(error_msg)
500
+ result.mark_completed(OperationStatus.FAILED, error_msg)
501
+ except Exception as e:
502
+ error_msg = f"Unexpected error: {e}"
503
+ logger.error(error_msg)
504
+ result.mark_completed(OperationStatus.FAILED, error_msg)
505
+
506
+ return [result]
507
+
508
+ def create_image(
509
+ self, context: OperationContext, instance_ids: List[str], image_name_prefix: Optional[str] = None
510
+ ) -> List[OperationResult]:
511
+ """
512
+ Create AMI images from EC2 instances.
513
+
514
+ Based on original aws/ec2_copy_image_cross-region.py image creation functionality.
515
+ """
516
+ # Environment variable support
517
+ image_name_prefix = image_name_prefix or os.getenv("IMAGE_NAME_PREFIX", "Demo-Boto")
518
+
519
+ if not instance_ids:
520
+ raise ValueError("No instance IDs provided for image creation.")
521
+
522
+ ec2_resource = boto3.resource("ec2", region_name=context.region)
523
+ results = []
524
+ created_images = []
525
+
526
+ for instance_id in instance_ids:
527
+ result = self.create_operation_result(context, "create_image", "ec2:ami", instance_id)
528
+
529
+ try:
530
+ instance = ec2_resource.Instance(instance_id)
531
+ image_name = f"{image_name_prefix}-{instance_id}"
532
+
533
+ logger.info(f"Creating image for instance {instance_id} with name '{image_name}'...")
534
+
535
+ if context.dry_run:
536
+ logger.info(f"[DRY-RUN] Image creation for {instance_id} skipped.")
537
+ result.mark_completed(OperationStatus.DRY_RUN)
538
+ else:
539
+ image = instance.create_image(Name=image_name, Description=f"Image for {instance_id}")
540
+ created_images.append(image.id)
541
+
542
+ result.response_data = {"ImageId": image.id, "ImageName": image_name}
543
+ result.mark_completed(OperationStatus.SUCCESS)
544
+ logger.info(f"Created image: {image.id}")
545
+
546
+ except ClientError as e:
547
+ error_msg = f"AWS Client Error: {e}"
548
+ logger.error(error_msg)
549
+ result.mark_completed(OperationStatus.FAILED, error_msg)
550
+ except BotoCoreError as e:
551
+ error_msg = f"BotoCore Error: {e}"
552
+ logger.error(error_msg)
553
+ result.mark_completed(OperationStatus.FAILED, error_msg)
554
+ except Exception as e:
555
+ error_msg = f"Unexpected error: {e}"
556
+ logger.error(error_msg)
557
+ result.mark_completed(OperationStatus.FAILED, error_msg)
558
+
559
+ results.append(result)
560
+
561
+ # SNS notification
562
+ if created_images:
563
+ message = f"Successfully created {len(created_images)} AMI images: {', '.join(created_images)}"
564
+ self.send_sns_notification("EC2 AMI Images Created", message)
565
+ logger.info(message)
566
+
567
+ return results
568
+
569
+ def delete_snapshots(
570
+ self, context: OperationContext, snapshot_ids: List[str], delete_owned_only: bool = True
571
+ ) -> List[OperationResult]:
572
+ """Delete EBS snapshots with safety checks."""
573
+ ec2_client = self.get_client("ec2", context.region)
574
+ results = []
575
+
576
+ for snapshot_id in snapshot_ids:
577
+ result = self.create_operation_result(context, "delete_snapshots", "ec2:snapshot", snapshot_id)
578
+
579
+ try:
580
+ if not self.confirm_operation(context, snapshot_id, "delete EBS snapshot"):
581
+ result.mark_completed(OperationStatus.CANCELLED, "Operation cancelled by user")
582
+ results.append(result)
583
+ continue
584
+
585
+ if context.dry_run:
586
+ logger.info(f"[DRY-RUN] Would delete snapshot {snapshot_id}")
587
+ result.mark_completed(OperationStatus.DRY_RUN)
588
+ results.append(result)
589
+ continue
590
+
591
+ self.execute_aws_call(ec2_client, "delete_snapshot", SnapshotId=snapshot_id)
592
+ result.mark_completed(OperationStatus.SUCCESS)
593
+ logger.info(f"Successfully deleted snapshot {snapshot_id}")
594
+
595
+ except ClientError as e:
596
+ error_msg = f"Failed to delete snapshot {snapshot_id}: {e}"
597
+ logger.error(error_msg)
598
+ result.mark_completed(OperationStatus.FAILED, error_msg)
599
+
600
+ results.append(result)
601
+
602
+ return results
603
+
604
+ def cleanup_unused_volumes(self, context: OperationContext) -> List[OperationResult]:
605
+ """
606
+ Identify unused EBS volumes with detailed reporting and SNS notifications.
607
+
608
+ Enhanced from original aws/ec2_unused_volumes.py with comprehensive scanning.
609
+ """
610
+ ec2_client = self.get_client("ec2", context.region)
611
+
612
+ result = self.create_operation_result(context, "cleanup_unused_volumes", "ec2:volume", "scan")
613
+
614
+ try:
615
+ logger.info("🔍 Fetching all EBS volumes...")
616
+
617
+ # Get all volumes (not just available ones for comprehensive reporting)
618
+ volumes_response = self.execute_aws_call(ec2_client, "describe_volumes")
619
+
620
+ unused_volumes = []
621
+
622
+ # Enhanced loop with detailed analysis from original file
623
+ for vol in volumes_response["Volumes"]:
624
+ if len(vol.get("Attachments", [])) == 0: # Unattached volumes
625
+ # Enhanced volume details from original file
626
+ volume_details = {
627
+ "VolumeId": vol["VolumeId"],
628
+ "Size": vol["Size"],
629
+ "State": vol["State"],
630
+ "Encrypted": vol.get("Encrypted", False),
631
+ "VolumeType": vol.get("VolumeType", "unknown"),
632
+ "CreateTime": str(vol["CreateTime"]),
633
+ }
634
+ unused_volumes.append(volume_details)
635
+
636
+ # Debug logging from original file
637
+ logger.debug(f"Unattached Volume: {json.dumps(volume_details, default=str)}")
638
+
639
+ result.response_data = {
640
+ "unused_volumes": unused_volumes,
641
+ "count": len(unused_volumes),
642
+ "total_scanned": len(volumes_response["Volumes"]),
643
+ }
644
+ result.mark_completed(OperationStatus.SUCCESS)
645
+ logger.info(
646
+ f"✅ Found {len(unused_volumes)} unused volumes out of {len(volumes_response['Volumes'])} total volumes"
647
+ )
648
+
649
+ # SNS notification with detailed report from original file
650
+ if unused_volumes:
651
+ message = f"Found {len(unused_volumes)} unused EBS volumes in {context.region}:\n"
652
+ for vol in unused_volumes[:10]: # Limit to first 10 for readability
653
+ message += f"- {vol['VolumeId']} ({vol['Size']}GB, {vol['VolumeType']}, {vol['State']})\n"
654
+ if len(unused_volumes) > 10:
655
+ message += f"... and {len(unused_volumes) - 10} more volumes"
656
+
657
+ self.send_sns_notification("Unused EBS Volumes Found", message)
658
+
659
+ except ClientError as e:
660
+ error_msg = f"❌ AWS Client Error: {e}"
661
+ logger.error(error_msg)
662
+ result.mark_completed(OperationStatus.FAILED, error_msg)
663
+ except BotoCoreError as e:
664
+ error_msg = f"❌ BotoCore Error: {e}"
665
+ logger.error(error_msg)
666
+ result.mark_completed(OperationStatus.FAILED, error_msg)
667
+ except Exception as e:
668
+ error_msg = f"❌ Unexpected error: {e}"
669
+ logger.error(error_msg)
670
+ result.mark_completed(OperationStatus.FAILED, error_msg)
671
+
672
+ return [result]
673
+
674
+ def cleanup_unused_eips(self, context: OperationContext) -> List[OperationResult]:
675
+ """
676
+ Identify unused Elastic IPs with detailed reporting and SNS notifications.
677
+
678
+ Enhanced from original aws/ec2_unused_eips.py with comprehensive scanning.
679
+ """
680
+ ec2_client = self.get_client("ec2", context.region)
681
+
682
+ result = self.create_operation_result(context, "cleanup_unused_eips", "ec2:eip", "scan")
683
+
684
+ try:
685
+ logger.info("🔍 Fetching all Elastic IP addresses...")
686
+
687
+ addresses_response = self.execute_aws_call(ec2_client, "describe_addresses")
688
+ unassociated_eips = []
689
+ eip_details = []
690
+
691
+ for address in addresses_response["Addresses"]:
692
+ # Enhanced analysis from original file
693
+ if "InstanceId" not in address and "NetworkInterfaceId" not in address:
694
+ eip_info = {
695
+ "AllocationId": address.get("AllocationId", "N/A"),
696
+ "PublicIp": address.get("PublicIp", "N/A"),
697
+ "Domain": address.get("Domain", "classic"),
698
+ "AssociationId": address.get("AssociationId"),
699
+ }
700
+ unassociated_eips.append(address.get("AllocationId", address.get("PublicIp")))
701
+ eip_details.append(eip_info)
702
+
703
+ # Debug logging from original file
704
+ logger.debug(f"Unassociated EIP: {json.dumps(eip_info, default=str)}")
705
+
706
+ result.response_data = {
707
+ "unused_eips": unassociated_eips,
708
+ "eip_details": eip_details,
709
+ "count": len(unassociated_eips),
710
+ "total_scanned": len(addresses_response["Addresses"]),
711
+ }
712
+ result.mark_completed(OperationStatus.SUCCESS)
713
+ logger.info(
714
+ f"✅ Found {len(unassociated_eips)} unused EIPs out of {len(addresses_response['Addresses'])} total EIPs"
715
+ )
716
+
717
+ # SNS notification with detailed report
718
+ if unassociated_eips:
719
+ message = f"Found {len(unassociated_eips)} unused Elastic IPs in {context.region}:\n"
720
+ for eip in eip_details[:10]: # Limit to first 10 for readability
721
+ message += f"- {eip['PublicIp']} ({eip['AllocationId']}, {eip['Domain']})\n"
722
+ if len(eip_details) > 10:
723
+ message += f"... and {len(eip_details) - 10} more EIPs"
724
+
725
+ self.send_sns_notification("Unused Elastic IPs Found", message)
726
+
727
+ except ClientError as e:
728
+ error_msg = f"❌ AWS Client Error: {e}"
729
+ logger.error(error_msg)
730
+ result.mark_completed(OperationStatus.FAILED, error_msg)
731
+ except BotoCoreError as e:
732
+ error_msg = f"❌ BotoCore Error: {e}"
733
+ logger.error(error_msg)
734
+ result.mark_completed(OperationStatus.FAILED, error_msg)
735
+ except Exception as e:
736
+ error_msg = f"❌ Unexpected error: {e}"
737
+ logger.error(error_msg)
738
+ result.mark_completed(OperationStatus.FAILED, error_msg)
739
+
740
+ return [result]
741
+
742
+ def reboot_instances(self, context: OperationContext, instance_ids: List[str]) -> List[OperationResult]:
743
+ """Reboot EC2 instances."""
744
+ ec2_client = self.get_client("ec2", context.region)
745
+ results = []
746
+
747
+ for instance_id in instance_ids:
748
+ result = self.create_operation_result(context, "reboot_instances", "ec2:instance", instance_id)
749
+
750
+ try:
751
+ if not self.confirm_operation(context, instance_id, "reboot"):
752
+ result.mark_completed(OperationStatus.CANCELLED, "Operation cancelled by user")
753
+ results.append(result)
754
+ continue
755
+
756
+ if context.dry_run:
757
+ logger.info(f"[DRY-RUN] Would reboot instance {instance_id}")
758
+ result.mark_completed(OperationStatus.DRY_RUN)
759
+ else:
760
+ response = self.execute_aws_call(ec2_client, "reboot_instances", InstanceIds=[instance_id])
761
+
762
+ result.response_data = response
763
+ result.mark_completed(OperationStatus.SUCCESS)
764
+ logger.info(f"Successfully rebooted instance {instance_id}")
765
+
766
+ except ClientError as e:
767
+ error_msg = f"Failed to reboot instance {instance_id}: {e}"
768
+ logger.error(error_msg)
769
+ result.mark_completed(OperationStatus.FAILED, error_msg)
770
+
771
+ results.append(result)
772
+
773
+ return results
774
+
775
+
776
+ # Lambda handlers to append to ec2_operations.py
777
+
778
+ # ==============================
779
+ # AWS LAMBDA HANDLERS
780
+ # ==============================
781
+
782
+
783
+ def lambda_handler_terminate_instances(event, context):
784
+ """
785
+ AWS Lambda handler for terminating EC2 instances.
786
+
787
+ Based on original aws/ec2_terminate_instances.py Lambda handler.
788
+ """
789
+ try:
790
+ from runbooks.inventory.models.account import AWSAccount
791
+ from runbooks.operate.base import OperationContext
792
+
793
+ instance_ids = event.get("instance_ids", os.getenv("INSTANCE_IDS", "").split(","))
794
+ region = event.get("region", os.getenv("AWS_REGION", "us-east-1"))
795
+
796
+ if not instance_ids or instance_ids == [""]:
797
+ logger.error("No instance IDs provided in the Lambda event or environment.")
798
+ raise ValueError("Instance IDs are required to terminate EC2 instances.")
799
+
800
+ ec2_ops = EC2Operations()
801
+ account = AWSAccount(account_id="current", account_name="lambda-execution")
802
+ operation_context = OperationContext(
803
+ account=account,
804
+ region=region,
805
+ operation_type="terminate_instances",
806
+ resource_types=["ec2:instance"],
807
+ dry_run=False,
808
+ )
809
+
810
+ results = ec2_ops.terminate_instances(operation_context, instance_ids)
811
+ terminated_instances = [r.resource_id for r in results if r.success]
812
+
813
+ return {
814
+ "statusCode": 200,
815
+ "body": {
816
+ "message": "Instances terminated successfully.",
817
+ "terminated_instances": terminated_instances,
818
+ },
819
+ }
820
+ except Exception as e:
821
+ logger.error(f"Lambda function failed: {e}")
822
+ return {"statusCode": 500, "body": {"message": str(e)}}
823
+
824
+
825
+ def lambda_handler_run_instances(event, context):
826
+ """AWS Lambda handler for launching EC2 instances."""
827
+ try:
828
+ from runbooks.inventory.models.account import AWSAccount
829
+ from runbooks.operate.base import OperationContext
830
+
831
+ region = event.get("region", os.getenv("AWS_REGION", "us-east-1"))
832
+
833
+ ec2_ops = EC2Operations()
834
+ account = AWSAccount(account_id="current", account_name="lambda-execution")
835
+ operation_context = OperationContext(
836
+ account=account,
837
+ region=region,
838
+ operation_type="run_instances",
839
+ resource_types=["ec2:instance"],
840
+ dry_run=False,
841
+ )
842
+
843
+ kwargs = {
844
+ "image_id": event.get("image_id"),
845
+ "instance_type": event.get("instance_type"),
846
+ "min_count": event.get("min_count"),
847
+ "max_count": event.get("max_count"),
848
+ "key_name": event.get("key_name"),
849
+ "security_group_ids": event.get("security_group_ids"),
850
+ "subnet_id": event.get("subnet_id"),
851
+ "tags": event.get("tags"),
852
+ }
853
+
854
+ results = ec2_ops.run_instances(operation_context, **kwargs)
855
+
856
+ if results and results[0].success:
857
+ instance_ids = [inst["InstanceId"] for inst in results[0].response_data["Instances"]]
858
+ return {
859
+ "statusCode": 200,
860
+ "body": {"message": "Instances launched successfully.", "instance_ids": instance_ids},
861
+ }
862
+ else:
863
+ return {"statusCode": 500, "body": {"message": "Failed to launch instances"}}
864
+
865
+ except Exception as e:
866
+ logger.error(f"Lambda Handler Error: {e}")
867
+ return {"statusCode": 500, "body": {"error": str(e)}}
868
+
869
+
870
+ # CLI Support
871
+ def main():
872
+ """Main entry point for standalone execution."""
873
+ import sys
874
+
875
+ if len(sys.argv) < 2:
876
+ print("Usage: python ec2_operations.py <operation>")
877
+ print("Operations: terminate, run, cleanup-volumes, cleanup-eips")
878
+ sys.exit(1)
879
+
880
+ operation = sys.argv[1]
881
+
882
+ try:
883
+ from runbooks.inventory.models.account import AWSAccount
884
+ from runbooks.operate.base import OperationContext
885
+
886
+ ec2_ops = EC2Operations()
887
+ account = AWSAccount(account_id="current", account_name="cli-execution")
888
+ operation_context = OperationContext(
889
+ account=account,
890
+ region=os.getenv("AWS_REGION", "us-east-1"),
891
+ operation_type=operation,
892
+ resource_types=["ec2"],
893
+ dry_run=os.getenv("DRY_RUN", "false").lower() == "true",
894
+ )
895
+
896
+ if operation == "terminate":
897
+ instance_ids = os.getenv("INSTANCE_IDS", "").split(",")
898
+ if not instance_ids or instance_ids == [""]:
899
+ raise ValueError("INSTANCE_IDS environment variable is required")
900
+ results = ec2_ops.terminate_instances(operation_context, instance_ids)
901
+
902
+ elif operation == "run":
903
+ results = ec2_ops.run_instances(operation_context)
904
+
905
+ elif operation == "cleanup-volumes":
906
+ results = ec2_ops.cleanup_unused_volumes(operation_context)
907
+
908
+ elif operation == "cleanup-eips":
909
+ results = ec2_ops.cleanup_unused_eips(operation_context)
910
+
911
+ else:
912
+ raise ValueError(f"Unknown operation: {operation}")
913
+
914
+ for result in results:
915
+ if result.success:
916
+ print(f"✅ {result.operation_type} completed successfully")
917
+ else:
918
+ print(f"❌ {result.operation_type} failed: {result.error_message}")
919
+
920
+ except Exception as e:
921
+ logger.error(f"Error during operation: {e}")
922
+ sys.exit(1)
923
+
924
+
925
+ if __name__ == "__main__":
926
+ main()