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,655 @@
1
+ """
2
+ Tagging Operations Module.
3
+
4
+ Provides comprehensive cross-service AWS resource tagging capabilities including
5
+ bulk tagging operations, tag compliance enforcement, and tag management workflows.
6
+
7
+ Migrated and enhanced from:
8
+ - aws/tagging_lambda_handler.py
9
+ """
10
+
11
+ import json
12
+ from datetime import datetime
13
+ from typing import Any, Dict, List, Optional, Union
14
+
15
+ import boto3
16
+ from botocore.exceptions import ClientError
17
+ from loguru import logger
18
+
19
+ from runbooks.operate.base import BaseOperation, OperationContext, OperationResult, OperationStatus
20
+
21
+
22
+ class TaggingOperations(BaseOperation):
23
+ """
24
+ Cross-service AWS resource tagging operations.
25
+
26
+ Handles tagging operations across multiple AWS services with support for
27
+ bulk operations, compliance enforcement, and tag lifecycle management.
28
+ """
29
+
30
+ service_name = "resourcegroupstaggingapi"
31
+ supported_operations = {
32
+ "tag_resources",
33
+ "untag_resources",
34
+ "get_resources_by_tags",
35
+ "apply_tag_template",
36
+ "enforce_tag_compliance",
37
+ "generate_tag_report",
38
+ "copy_tags",
39
+ "standardize_tags",
40
+ }
41
+ requires_confirmation = False # Tagging is generally safe
42
+
43
+ def __init__(self, profile: Optional[str] = None, region: Optional[str] = None, dry_run: bool = False):
44
+ """Initialize tagging operations."""
45
+ super().__init__(profile, region, dry_run)
46
+ self.default_tags = self._load_default_tags()
47
+
48
+ def _load_default_tags(self) -> Dict[str, str]:
49
+ """Load default tag templates."""
50
+ # In production, this could load from a configuration file
51
+ return {
52
+ "Environment": "Production",
53
+ "Project": "CloudOps",
54
+ "ManagedBy": "CloudOps-Runbooks",
55
+ "CreatedDate": datetime.utcnow().strftime("%Y-%m-%d"),
56
+ "CostCenter": "IT-Operations",
57
+ }
58
+
59
+ def execute_operation(self, context: OperationContext, operation_type: str, **kwargs) -> List[OperationResult]:
60
+ """
61
+ Execute tagging operation.
62
+
63
+ Args:
64
+ context: Operation context
65
+ operation_type: Type of operation to execute
66
+ **kwargs: Operation-specific arguments
67
+
68
+ Returns:
69
+ List of operation results
70
+ """
71
+ self.validate_context(context)
72
+
73
+ if operation_type == "tag_resources":
74
+ return self.tag_resources(context, **kwargs)
75
+ elif operation_type == "untag_resources":
76
+ return self.untag_resources(context, **kwargs)
77
+ elif operation_type == "get_resources_by_tags":
78
+ return self.get_resources_by_tags(context, **kwargs)
79
+ elif operation_type == "apply_tag_template":
80
+ return self.apply_tag_template(context, **kwargs)
81
+ elif operation_type == "enforce_tag_compliance":
82
+ return self.enforce_tag_compliance(context, **kwargs)
83
+ elif operation_type == "generate_tag_report":
84
+ return self.generate_tag_report(context, **kwargs)
85
+ elif operation_type == "copy_tags":
86
+ return self.copy_tags(context, **kwargs)
87
+ elif operation_type == "standardize_tags":
88
+ return self.standardize_tags(context, **kwargs)
89
+ else:
90
+ raise ValueError(f"Unsupported operation: {operation_type}")
91
+
92
+ def tag_resources(
93
+ self,
94
+ context: OperationContext,
95
+ resource_arns: List[str],
96
+ tags: Dict[str, str],
97
+ merge_with_defaults: bool = True,
98
+ ) -> List[OperationResult]:
99
+ """
100
+ Add tags to AWS resources.
101
+
102
+ Args:
103
+ context: Operation context
104
+ resource_arns: List of resource ARNs to tag
105
+ tags: Tags to apply to resources
106
+ merge_with_defaults: Whether to merge with default tags
107
+
108
+ Returns:
109
+ List of operation results
110
+ """
111
+ tagging_client = self.get_client("resourcegroupstaggingapi", context.region)
112
+
113
+ # Merge with default tags if requested
114
+ final_tags = {}
115
+ if merge_with_defaults:
116
+ final_tags.update(self.default_tags)
117
+ final_tags.update(tags)
118
+
119
+ results = []
120
+
121
+ for resource_arn in resource_arns:
122
+ result = self.create_operation_result(context, "tag_resources", "aws:resource", resource_arn)
123
+
124
+ try:
125
+ if context.dry_run:
126
+ logger.info(f"[DRY-RUN] Would tag resource {resource_arn} with {len(final_tags)} tags")
127
+ result.mark_completed(OperationStatus.DRY_RUN)
128
+ else:
129
+ response = self.execute_aws_call(
130
+ tagging_client, "tag_resources", ResourceARNList=[resource_arn], Tags=final_tags
131
+ )
132
+
133
+ if response.get("FailedResourcesMap"):
134
+ failed_arns = list(response["FailedResourcesMap"].keys())
135
+ if resource_arn in failed_arns:
136
+ error_info = response["FailedResourcesMap"][resource_arn]
137
+ error_msg = f"Failed to tag resource: {error_info.get('ErrorMessage', 'Unknown error')}"
138
+ result.mark_completed(OperationStatus.FAILED, error_msg)
139
+ else:
140
+ result.mark_completed(OperationStatus.SUCCESS)
141
+ logger.info(f"Successfully tagged resource {resource_arn}")
142
+ else:
143
+ result.mark_completed(OperationStatus.SUCCESS)
144
+ logger.info(f"Successfully tagged resource {resource_arn}")
145
+
146
+ result.response_data = response
147
+
148
+ except ClientError as e:
149
+ error_msg = f"Failed to tag resource {resource_arn}: {e}"
150
+ logger.error(error_msg)
151
+ result.mark_completed(OperationStatus.FAILED, error_msg)
152
+
153
+ results.append(result)
154
+
155
+ return results
156
+
157
+ def untag_resources(
158
+ self, context: OperationContext, resource_arns: List[str], tag_keys: List[str]
159
+ ) -> List[OperationResult]:
160
+ """
161
+ Remove tags from AWS resources.
162
+
163
+ Args:
164
+ context: Operation context
165
+ resource_arns: List of resource ARNs to untag
166
+ tag_keys: Tag keys to remove
167
+
168
+ Returns:
169
+ List of operation results
170
+ """
171
+ tagging_client = self.get_client("resourcegroupstaggingapi", context.region)
172
+ results = []
173
+
174
+ for resource_arn in resource_arns:
175
+ result = self.create_operation_result(context, "untag_resources", "aws:resource", resource_arn)
176
+
177
+ try:
178
+ if context.dry_run:
179
+ logger.info(f"[DRY-RUN] Would remove {len(tag_keys)} tags from resource {resource_arn}")
180
+ result.mark_completed(OperationStatus.DRY_RUN)
181
+ else:
182
+ response = self.execute_aws_call(
183
+ tagging_client, "untag_resources", ResourceARNList=[resource_arn], TagKeys=tag_keys
184
+ )
185
+
186
+ if response.get("FailedResourcesMap"):
187
+ failed_arns = list(response["FailedResourcesMap"].keys())
188
+ if resource_arn in failed_arns:
189
+ error_info = response["FailedResourcesMap"][resource_arn]
190
+ error_msg = f"Failed to untag resource: {error_info.get('ErrorMessage', 'Unknown error')}"
191
+ result.mark_completed(OperationStatus.FAILED, error_msg)
192
+ else:
193
+ result.mark_completed(OperationStatus.SUCCESS)
194
+ logger.info(f"Successfully untagged resource {resource_arn}")
195
+ else:
196
+ result.mark_completed(OperationStatus.SUCCESS)
197
+ logger.info(f"Successfully untagged resource {resource_arn}")
198
+
199
+ result.response_data = response
200
+
201
+ except ClientError as e:
202
+ error_msg = f"Failed to untag resource {resource_arn}: {e}"
203
+ logger.error(error_msg)
204
+ result.mark_completed(OperationStatus.FAILED, error_msg)
205
+
206
+ results.append(result)
207
+
208
+ return results
209
+
210
+ def get_resources_by_tags(
211
+ self,
212
+ context: OperationContext,
213
+ tag_filters: Optional[List[Dict[str, Union[str, List[str]]]]] = None,
214
+ resource_type_filters: Optional[List[str]] = None,
215
+ include_compliance_details: bool = False,
216
+ ) -> List[OperationResult]:
217
+ """
218
+ Find AWS resources by tags.
219
+
220
+ Args:
221
+ context: Operation context
222
+ tag_filters: Tag filters to apply
223
+ resource_type_filters: Resource type filters
224
+ include_compliance_details: Include compliance information
225
+
226
+ Returns:
227
+ List of operation results with resource information
228
+ """
229
+ tagging_client = self.get_client("resourcegroupstaggingapi", context.region)
230
+
231
+ result = self.create_operation_result(context, "get_resources_by_tags", "aws:search", "tag_search")
232
+
233
+ try:
234
+ get_params = {}
235
+
236
+ if tag_filters:
237
+ get_params["TagFilters"] = tag_filters
238
+ if resource_type_filters:
239
+ get_params["ResourceTypeFilters"] = resource_type_filters
240
+ if include_compliance_details:
241
+ get_params["IncludeComplianceDetails"] = True
242
+
243
+ resources = []
244
+ paginator = tagging_client.get_paginator("get_resources")
245
+
246
+ for page in paginator.paginate(**get_params):
247
+ resources.extend(page.get("ResourceTagMappingList", []))
248
+
249
+ result.response_data = {
250
+ "resources": resources,
251
+ "resource_count": len(resources),
252
+ "search_filters": {
253
+ "tag_filters": tag_filters,
254
+ "resource_type_filters": resource_type_filters,
255
+ },
256
+ }
257
+ result.mark_completed(OperationStatus.SUCCESS)
258
+ logger.info(f"Found {len(resources)} resources matching tag criteria")
259
+
260
+ except ClientError as e:
261
+ error_msg = f"Failed to search resources by tags: {e}"
262
+ logger.error(error_msg)
263
+ result.mark_completed(OperationStatus.FAILED, error_msg)
264
+
265
+ return [result]
266
+
267
+ def apply_tag_template(
268
+ self,
269
+ context: OperationContext,
270
+ resource_arns: List[str],
271
+ template_name: str,
272
+ template_values: Optional[Dict[str, str]] = None,
273
+ ) -> List[OperationResult]:
274
+ """
275
+ Apply a predefined tag template to resources.
276
+
277
+ Args:
278
+ context: Operation context
279
+ resource_arns: Resources to tag
280
+ template_name: Name of tag template to apply
281
+ template_values: Values to substitute in template
282
+
283
+ Returns:
284
+ List of operation results
285
+ """
286
+ # Define tag templates
287
+ templates = {
288
+ "production": {
289
+ "Environment": "Production",
290
+ "Backup": "Required",
291
+ "Monitoring": "Enabled",
292
+ "CostCenter": "Production-Workloads",
293
+ "Owner": "DevOps-Team",
294
+ },
295
+ "development": {
296
+ "Environment": "Development",
297
+ "Backup": "Optional",
298
+ "Monitoring": "Basic",
299
+ "CostCenter": "Development",
300
+ "Owner": "Development-Team",
301
+ },
302
+ "security": {
303
+ "SecurityLevel": "High",
304
+ "DataClassification": "Confidential",
305
+ "ComplianceRequired": "true",
306
+ "EncryptionRequired": "true",
307
+ "AccessLogging": "Enabled",
308
+ },
309
+ "cost-optimization": {
310
+ "CostOptimization": "Enabled",
311
+ "AutoShutdown": "Enabled",
312
+ "RightSizing": "Required",
313
+ "ResourceReview": "Monthly",
314
+ },
315
+ }
316
+
317
+ if template_name not in templates:
318
+ raise ValueError(f"Unknown template: {template_name}. Available: {list(templates.keys())}")
319
+
320
+ template_tags = templates[template_name].copy()
321
+
322
+ # Apply template value substitutions
323
+ if template_values:
324
+ for key, value in template_tags.items():
325
+ for placeholder, replacement in template_values.items():
326
+ template_tags[key] = value.replace(f"{{{placeholder}}}", replacement)
327
+
328
+ # Add template metadata
329
+ template_tags["TagTemplate"] = template_name
330
+ template_tags["TaggedDate"] = datetime.utcnow().strftime("%Y-%m-%d")
331
+
332
+ return self.tag_resources(context, resource_arns, template_tags, merge_with_defaults=True)
333
+
334
+ def enforce_tag_compliance(
335
+ self,
336
+ context: OperationContext,
337
+ required_tags: List[str],
338
+ resource_type_filters: Optional[List[str]] = None,
339
+ auto_remediate: bool = False,
340
+ ) -> List[OperationResult]:
341
+ """
342
+ Enforce tag compliance across resources.
343
+
344
+ Args:
345
+ context: Operation context
346
+ required_tags: List of required tag keys
347
+ resource_type_filters: Resource types to check
348
+ auto_remediate: Automatically add missing tags
349
+
350
+ Returns:
351
+ List of operation results with compliance information
352
+ """
353
+ result = self.create_operation_result(context, "enforce_tag_compliance", "aws:compliance", "tag_compliance")
354
+
355
+ try:
356
+ # Get all resources in scope
357
+ search_results = self.get_resources_by_tags(
358
+ context, resource_type_filters=resource_type_filters, include_compliance_details=True
359
+ )
360
+
361
+ if not search_results or search_results[0].status != OperationStatus.SUCCESS:
362
+ result.mark_completed(OperationStatus.FAILED, "Failed to retrieve resources for compliance check")
363
+ return [result]
364
+
365
+ resources = search_results[0].response_data["resources"]
366
+ non_compliant_resources = []
367
+
368
+ for resource in resources:
369
+ resource_arn = resource["ResourceARN"]
370
+ existing_tags = {tag["Key"]: tag["Value"] for tag in resource.get("Tags", [])}
371
+ missing_tags = [tag for tag in required_tags if tag not in existing_tags]
372
+
373
+ if missing_tags:
374
+ non_compliant_resources.append(
375
+ {
376
+ "resource_arn": resource_arn,
377
+ "missing_tags": missing_tags,
378
+ "existing_tags": existing_tags,
379
+ }
380
+ )
381
+
382
+ compliance_rate = (
383
+ (len(resources) - len(non_compliant_resources)) / len(resources) * 100 if resources else 100
384
+ )
385
+
386
+ compliance_report = {
387
+ "total_resources": len(resources),
388
+ "compliant_resources": len(resources) - len(non_compliant_resources),
389
+ "non_compliant_resources": len(non_compliant_resources),
390
+ "compliance_rate": compliance_rate,
391
+ "required_tags": required_tags,
392
+ "non_compliant_details": non_compliant_resources,
393
+ }
394
+
395
+ # Auto-remediation if enabled
396
+ if auto_remediate and non_compliant_resources:
397
+ logger.info(f"Auto-remediating {len(non_compliant_resources)} non-compliant resources")
398
+
399
+ for resource_info in non_compliant_resources:
400
+ # Apply default values for missing tags
401
+ remediation_tags = {}
402
+ for tag_key in resource_info["missing_tags"]:
403
+ remediation_tags[tag_key] = "AUTO-REMEDIATED"
404
+
405
+ self.tag_resources(
406
+ context, [resource_info["resource_arn"]], remediation_tags, merge_with_defaults=False
407
+ )
408
+
409
+ result.response_data = compliance_report
410
+ result.mark_completed(OperationStatus.SUCCESS)
411
+ logger.info(f"Tag compliance check completed: {compliance_rate:.1f}% compliant")
412
+
413
+ except Exception as e:
414
+ error_msg = f"Failed to enforce tag compliance: {e}"
415
+ logger.error(error_msg)
416
+ result.mark_completed(OperationStatus.FAILED, error_msg)
417
+
418
+ return [result]
419
+
420
+ def generate_tag_report(
421
+ self,
422
+ context: OperationContext,
423
+ resource_type_filters: Optional[List[str]] = None,
424
+ include_cost_allocation: bool = False,
425
+ ) -> List[OperationResult]:
426
+ """
427
+ Generate comprehensive tag usage report.
428
+
429
+ Args:
430
+ context: Operation context
431
+ resource_type_filters: Resource types to include in report
432
+ include_cost_allocation: Include cost allocation tag analysis
433
+
434
+ Returns:
435
+ List of operation results with tag report
436
+ """
437
+ result = self.create_operation_result(context, "generate_tag_report", "aws:report", "tag_report")
438
+
439
+ try:
440
+ # Get all resources
441
+ search_results = self.get_resources_by_tags(context, resource_type_filters=resource_type_filters)
442
+
443
+ if not search_results or search_results[0].status != OperationStatus.SUCCESS:
444
+ result.mark_completed(OperationStatus.FAILED, "Failed to retrieve resources for report")
445
+ return [result]
446
+
447
+ resources = search_results[0].response_data["resources"]
448
+
449
+ # Analyze tag usage
450
+ tag_usage = {}
451
+ resource_types = {}
452
+ untagged_resources = []
453
+
454
+ for resource in resources:
455
+ resource_type = resource["ResourceARN"].split(":")[2] # Extract service from ARN
456
+ resource_types[resource_type] = resource_types.get(resource_type, 0) + 1
457
+
458
+ tags = resource.get("Tags", [])
459
+ if not tags:
460
+ untagged_resources.append(resource["ResourceARN"])
461
+ continue
462
+
463
+ for tag in tags:
464
+ key = tag["Key"]
465
+ value = tag["Value"]
466
+
467
+ if key not in tag_usage:
468
+ tag_usage[key] = {"count": 0, "values": {}}
469
+
470
+ tag_usage[key]["count"] += 1
471
+ tag_usage[key]["values"][value] = tag_usage[key]["values"].get(value, 0) + 1
472
+
473
+ # Generate report
474
+ report = {
475
+ "summary": {
476
+ "total_resources": len(resources),
477
+ "tagged_resources": len(resources) - len(untagged_resources),
478
+ "untagged_resources": len(untagged_resources),
479
+ "tagging_coverage": (len(resources) - len(untagged_resources)) / len(resources) * 100
480
+ if resources
481
+ else 0,
482
+ "unique_tag_keys": len(tag_usage),
483
+ },
484
+ "resource_types": resource_types,
485
+ "tag_usage": tag_usage,
486
+ "untagged_resources": untagged_resources,
487
+ "recommendations": self._generate_tag_recommendations(tag_usage, len(resources)),
488
+ }
489
+
490
+ result.response_data = report
491
+ result.mark_completed(OperationStatus.SUCCESS)
492
+ logger.info(f"Generated tag report: {len(resources)} resources analyzed")
493
+
494
+ except Exception as e:
495
+ error_msg = f"Failed to generate tag report: {e}"
496
+ logger.error(error_msg)
497
+ result.mark_completed(OperationStatus.FAILED, error_msg)
498
+
499
+ return [result]
500
+
501
+ def _generate_tag_recommendations(self, tag_usage: Dict[str, Any], total_resources: int) -> List[str]:
502
+ """Generate tag usage recommendations."""
503
+ recommendations = []
504
+
505
+ # Find inconsistent tag naming
506
+ similar_tags = {}
507
+ for tag_key in tag_usage.keys():
508
+ normalized_key = tag_key.lower().replace("-", "").replace("_", "").replace(" ", "")
509
+ if normalized_key not in similar_tags:
510
+ similar_tags[normalized_key] = []
511
+ similar_tags[normalized_key].append(tag_key)
512
+
513
+ for normalized_key, tag_keys in similar_tags.items():
514
+ if len(tag_keys) > 1:
515
+ recommendations.append(f"Consider standardizing similar tag keys: {', '.join(tag_keys)}")
516
+
517
+ # Find rarely used tags
518
+ rare_tags = [
519
+ tag_key
520
+ for tag_key, usage in tag_usage.items()
521
+ if usage["count"] < total_resources * 0.05 # Used on less than 5% of resources
522
+ ]
523
+
524
+ if rare_tags:
525
+ recommendations.append(f"Consider removing rarely used tags: {', '.join(rare_tags[:5])}")
526
+
527
+ # Suggest common tags
528
+ common_tag_suggestions = ["Environment", "Owner", "Project", "CostCenter", "CreatedBy"]
529
+ missing_common_tags = [tag for tag in common_tag_suggestions if tag not in tag_usage]
530
+
531
+ if missing_common_tags:
532
+ recommendations.append(f"Consider adding common tags: {', '.join(missing_common_tags)}")
533
+
534
+ return recommendations
535
+
536
+ def copy_tags(
537
+ self,
538
+ context: OperationContext,
539
+ source_resource_arn: str,
540
+ target_resource_arns: List[str],
541
+ tag_keys: Optional[List[str]] = None,
542
+ ) -> List[OperationResult]:
543
+ """
544
+ Copy tags from one resource to others.
545
+
546
+ Args:
547
+ context: Operation context
548
+ source_resource_arn: Source resource to copy tags from
549
+ target_resource_arns: Target resources to copy tags to
550
+ tag_keys: Specific tag keys to copy (all if None)
551
+
552
+ Returns:
553
+ List of operation results
554
+ """
555
+ # First get tags from source resource
556
+ search_results = self.get_resources_by_tags(context)
557
+
558
+ if not search_results or search_results[0].status != OperationStatus.SUCCESS:
559
+ result = self.create_operation_result(context, "copy_tags", "aws:operation", "copy_tags_failed")
560
+ result.mark_completed(OperationStatus.FAILED, "Failed to retrieve source resource tags")
561
+ return [result]
562
+
563
+ # Find source resource in results
564
+ source_tags = {}
565
+ for resource in search_results[0].response_data["resources"]:
566
+ if resource["ResourceARN"] == source_resource_arn:
567
+ source_tags = {tag["Key"]: tag["Value"] for tag in resource.get("Tags", [])}
568
+ break
569
+
570
+ if not source_tags:
571
+ result = self.create_operation_result(context, "copy_tags", "aws:operation", "copy_tags_failed")
572
+ result.mark_completed(
573
+ OperationStatus.FAILED, f"Source resource {source_resource_arn} not found or has no tags"
574
+ )
575
+ return [result]
576
+
577
+ # Filter tags if specific keys requested
578
+ if tag_keys:
579
+ source_tags = {k: v for k, v in source_tags.items() if k in tag_keys}
580
+
581
+ # Apply tags to target resources
582
+ return self.tag_resources(context, target_resource_arns, source_tags, merge_with_defaults=False)
583
+
584
+ def standardize_tags(
585
+ self, context: OperationContext, resource_arns: List[str], standardization_rules: Dict[str, str]
586
+ ) -> List[OperationResult]:
587
+ """
588
+ Standardize tag keys and values according to rules.
589
+
590
+ Args:
591
+ context: Operation context
592
+ resource_arns: Resources to standardize
593
+ standardization_rules: Mapping of old tag keys to new tag keys
594
+
595
+ Returns:
596
+ List of operation results
597
+ """
598
+ results = []
599
+
600
+ for resource_arn in resource_arns:
601
+ result = self.create_operation_result(context, "standardize_tags", "aws:resource", resource_arn)
602
+
603
+ try:
604
+ # Get current tags for resource
605
+ search_results = self.get_resources_by_tags(context)
606
+
607
+ if not search_results or search_results[0].status != OperationStatus.SUCCESS:
608
+ result.mark_completed(OperationStatus.FAILED, "Failed to retrieve resource tags")
609
+ results.append(result)
610
+ continue
611
+
612
+ # Find resource in results
613
+ current_tags = {}
614
+ for resource in search_results[0].response_data["resources"]:
615
+ if resource["ResourceARN"] == resource_arn:
616
+ current_tags = {tag["Key"]: tag["Value"] for tag in resource.get("Tags", [])}
617
+ break
618
+
619
+ # Apply standardization rules
620
+ new_tags = {}
621
+ tags_to_remove = []
622
+
623
+ for old_key, value in current_tags.items():
624
+ if old_key in standardization_rules:
625
+ new_key = standardization_rules[old_key]
626
+ new_tags[new_key] = value
627
+ tags_to_remove.append(old_key)
628
+ else:
629
+ new_tags[old_key] = value
630
+
631
+ if context.dry_run:
632
+ logger.info(f"[DRY-RUN] Would standardize {len(tags_to_remove)} tags on {resource_arn}")
633
+ result.mark_completed(OperationStatus.DRY_RUN)
634
+ else:
635
+ # Remove old tags if they're being renamed
636
+ if tags_to_remove:
637
+ self.untag_resources(context, [resource_arn], tags_to_remove)
638
+
639
+ # Apply new standardized tags
640
+ tag_results = self.tag_resources(context, [resource_arn], new_tags, merge_with_defaults=False)
641
+
642
+ if tag_results and tag_results[0].status == OperationStatus.SUCCESS:
643
+ result.mark_completed(OperationStatus.SUCCESS)
644
+ logger.info(f"Successfully standardized tags on {resource_arn}")
645
+ else:
646
+ result.mark_completed(OperationStatus.FAILED, "Failed to apply standardized tags")
647
+
648
+ except Exception as e:
649
+ error_msg = f"Failed to standardize tags on {resource_arn}: {e}"
650
+ logger.error(error_msg)
651
+ result.mark_completed(OperationStatus.FAILED, error_msg)
652
+
653
+ results.append(result)
654
+
655
+ return results