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.
- runbooks/__init__.py +87 -37
- runbooks/cfat/README.md +300 -49
- runbooks/cfat/__init__.py +2 -2
- runbooks/finops/__init__.py +1 -1
- runbooks/finops/cli.py +1 -1
- runbooks/inventory/collectors/__init__.py +8 -0
- runbooks/inventory/collectors/aws_management.py +791 -0
- runbooks/inventory/collectors/aws_networking.py +3 -3
- runbooks/main.py +3389 -782
- runbooks/operate/__init__.py +207 -0
- runbooks/operate/base.py +311 -0
- runbooks/operate/cloudformation_operations.py +619 -0
- runbooks/operate/cloudwatch_operations.py +496 -0
- runbooks/operate/dynamodb_operations.py +812 -0
- runbooks/operate/ec2_operations.py +926 -0
- runbooks/operate/iam_operations.py +569 -0
- runbooks/operate/s3_operations.py +1211 -0
- runbooks/operate/tagging_operations.py +655 -0
- runbooks/remediation/CLAUDE.md +100 -0
- runbooks/remediation/DOME9.md +218 -0
- runbooks/remediation/README.md +26 -0
- runbooks/remediation/Tests/__init__.py +0 -0
- runbooks/remediation/Tests/update_policy.py +74 -0
- runbooks/remediation/__init__.py +95 -0
- runbooks/remediation/acm_cert_expired_unused.py +98 -0
- runbooks/remediation/acm_remediation.py +875 -0
- runbooks/remediation/api_gateway_list.py +167 -0
- runbooks/remediation/base.py +643 -0
- runbooks/remediation/cloudtrail_remediation.py +908 -0
- runbooks/remediation/cloudtrail_s3_modifications.py +296 -0
- runbooks/remediation/cognito_active_users.py +78 -0
- runbooks/remediation/cognito_remediation.py +856 -0
- runbooks/remediation/cognito_user_password_reset.py +163 -0
- runbooks/remediation/commons.py +455 -0
- runbooks/remediation/dynamodb_optimize.py +155 -0
- runbooks/remediation/dynamodb_remediation.py +744 -0
- runbooks/remediation/dynamodb_server_side_encryption.py +108 -0
- runbooks/remediation/ec2_public_ips.py +134 -0
- runbooks/remediation/ec2_remediation.py +892 -0
- runbooks/remediation/ec2_subnet_disable_auto_ip_assignment.py +72 -0
- runbooks/remediation/ec2_unattached_ebs_volumes.py +448 -0
- runbooks/remediation/ec2_unused_security_groups.py +202 -0
- runbooks/remediation/kms_enable_key_rotation.py +651 -0
- runbooks/remediation/kms_remediation.py +717 -0
- runbooks/remediation/lambda_list.py +243 -0
- runbooks/remediation/lambda_remediation.py +971 -0
- runbooks/remediation/multi_account.py +569 -0
- runbooks/remediation/rds_instance_list.py +199 -0
- runbooks/remediation/rds_remediation.py +873 -0
- runbooks/remediation/rds_snapshot_list.py +192 -0
- runbooks/remediation/requirements.txt +118 -0
- runbooks/remediation/s3_block_public_access.py +159 -0
- runbooks/remediation/s3_bucket_public_access.py +143 -0
- runbooks/remediation/s3_disable_static_website_hosting.py +74 -0
- runbooks/remediation/s3_downloader.py +215 -0
- runbooks/remediation/s3_enable_access_logging.py +562 -0
- runbooks/remediation/s3_encryption.py +526 -0
- runbooks/remediation/s3_force_ssl_secure_policy.py +143 -0
- runbooks/remediation/s3_list.py +141 -0
- runbooks/remediation/s3_object_search.py +201 -0
- runbooks/remediation/s3_remediation.py +816 -0
- runbooks/remediation/scan_for_phrase.py +425 -0
- runbooks/remediation/workspaces_list.py +220 -0
- runbooks/security/__init__.py +9 -10
- runbooks/security/security_baseline_tester.py +4 -2
- runbooks-0.7.6.dist-info/METADATA +608 -0
- {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/RECORD +84 -76
- {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/entry_points.txt +0 -1
- {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/top_level.txt +0 -1
- jupyter-agent/.env +0 -2
- jupyter-agent/.env.template +0 -2
- jupyter-agent/.gitattributes +0 -35
- jupyter-agent/.gradio/certificate.pem +0 -31
- jupyter-agent/README.md +0 -16
- jupyter-agent/__main__.log +0 -8
- jupyter-agent/app.py +0 -256
- jupyter-agent/cloudops-agent.png +0 -0
- jupyter-agent/ds-system-prompt.txt +0 -154
- jupyter-agent/jupyter-agent.png +0 -0
- jupyter-agent/llama3_template.jinja +0 -123
- jupyter-agent/requirements.txt +0 -9
- jupyter-agent/tmp/4ojbs8a02ir/jupyter-agent.ipynb +0 -68
- jupyter-agent/tmp/cm5iasgpm3p/jupyter-agent.ipynb +0 -91
- jupyter-agent/tmp/crqbsseag5/jupyter-agent.ipynb +0 -91
- jupyter-agent/tmp/hohanq1u097/jupyter-agent.ipynb +0 -57
- jupyter-agent/tmp/jns1sam29wm/jupyter-agent.ipynb +0 -53
- jupyter-agent/tmp/jupyter-agent.ipynb +0 -27
- jupyter-agent/utils.py +0 -409
- runbooks/aws/__init__.py +0 -58
- runbooks/aws/dynamodb_operations.py +0 -231
- runbooks/aws/ec2_copy_image_cross-region.py +0 -195
- runbooks/aws/ec2_describe_instances.py +0 -202
- runbooks/aws/ec2_ebs_snapshots_delete.py +0 -186
- runbooks/aws/ec2_run_instances.py +0 -213
- runbooks/aws/ec2_start_stop_instances.py +0 -212
- runbooks/aws/ec2_terminate_instances.py +0 -143
- runbooks/aws/ec2_unused_eips.py +0 -196
- runbooks/aws/ec2_unused_volumes.py +0 -188
- runbooks/aws/s3_create_bucket.py +0 -142
- runbooks/aws/s3_list_buckets.py +0 -152
- runbooks/aws/s3_list_objects.py +0 -156
- runbooks/aws/s3_object_operations.py +0 -183
- runbooks/aws/tagging_lambda_handler.py +0 -183
- runbooks/inventory/FAILED_SCRIPTS_TROUBLESHOOTING.md +0 -619
- runbooks/inventory/PASSED_SCRIPTS_GUIDE.md +0 -738
- runbooks/inventory/aws_organization.png +0 -0
- runbooks/inventory/cfn_move_stack_instances.py +0 -1526
- runbooks/inventory/delete_s3_buckets_objects.py +0 -169
- runbooks/inventory/lockdown_cfn_stackset_role.py +0 -224
- runbooks/inventory/update_aws_actions.py +0 -173
- runbooks/inventory/update_cfn_stacksets.py +0 -1215
- runbooks/inventory/update_cloudwatch_logs_retention_policy.py +0 -294
- runbooks/inventory/update_iam_roles_cross_accounts.py +0 -478
- runbooks/inventory/update_s3_public_access_block.py +0 -539
- runbooks/organizations/__init__.py +0 -12
- runbooks/organizations/manager.py +0 -374
- runbooks-0.7.0.dist-info/METADATA +0 -375
- /runbooks/inventory/{tests → Tests}/common_test_data.py +0 -0
- /runbooks/inventory/{tests → Tests}/common_test_functions.py +0 -0
- /runbooks/inventory/{tests → Tests}/script_test_data.py +0 -0
- /runbooks/inventory/{tests → Tests}/setup.py +0 -0
- /runbooks/inventory/{tests → Tests}/src.py +0 -0
- /runbooks/inventory/{tests/test_inventory_modules.py → Tests/test_Inventory_Modules.py} +0 -0
- /runbooks/inventory/{tests → Tests}/test_cfn_describe_stacks.py +0 -0
- /runbooks/inventory/{tests → Tests}/test_ec2_describe_instances.py +0 -0
- /runbooks/inventory/{tests → Tests}/test_lambda_list_functions.py +0 -0
- /runbooks/inventory/{tests → Tests}/test_moto_integration_example.py +0 -0
- /runbooks/inventory/{tests → Tests}/test_org_list_accounts.py +0 -0
- /runbooks/inventory/{Inventory_Modules.py → inventory_modules.py} +0 -0
- /runbooks/{aws → operate}/tags.json +0 -0
- {runbooks-0.7.0.dist-info → runbooks-0.7.6.dist-info}/WHEEL +0 -0
- {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
|