runbooks 1.1.9__py3-none-any.whl → 1.1.10__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 +1 -1
- runbooks/__init___optimized.py +2 -1
- runbooks/_platform/__init__.py +1 -1
- runbooks/cfat/cli.py +4 -3
- runbooks/cfat/cloud_foundations_assessment.py +1 -2
- runbooks/cfat/tests/test_cli.py +4 -1
- runbooks/cli/commands/finops.py +68 -19
- runbooks/cli/commands/inventory.py +796 -7
- runbooks/cli/commands/operate.py +65 -4
- runbooks/cloudops/cost_optimizer.py +1 -3
- runbooks/common/cli_decorators.py +6 -4
- runbooks/common/config_loader.py +787 -0
- runbooks/common/config_schema.py +280 -0
- runbooks/common/dry_run_framework.py +14 -2
- runbooks/common/mcp_integration.py +238 -0
- runbooks/finops/ebs_cost_optimizer.py +7 -4
- runbooks/finops/elastic_ip_optimizer.py +7 -4
- runbooks/finops/infrastructure/__init__.py +3 -2
- runbooks/finops/infrastructure/commands.py +7 -4
- runbooks/finops/infrastructure/load_balancer_optimizer.py +7 -4
- runbooks/finops/infrastructure/vpc_endpoint_optimizer.py +7 -4
- runbooks/finops/nat_gateway_optimizer.py +7 -4
- runbooks/finops/tests/run_tests.py +1 -1
- runbooks/inventory/ArgumentsClass.py +2 -1
- runbooks/inventory/README.md +111 -12
- runbooks/inventory/Tests/test_Inventory_Modules.py +27 -10
- runbooks/inventory/Tests/test_cfn_describe_stacks.py +18 -7
- runbooks/inventory/Tests/test_ec2_describe_instances.py +30 -15
- runbooks/inventory/Tests/test_lambda_list_functions.py +17 -3
- runbooks/inventory/Tests/test_org_list_accounts.py +17 -4
- runbooks/inventory/account_class.py +0 -1
- runbooks/inventory/all_my_instances_wrapper.py +4 -8
- runbooks/inventory/aws_organization.png +0 -0
- runbooks/inventory/check_cloudtrail_compliance.py +4 -4
- runbooks/inventory/check_controltower_readiness.py +50 -47
- runbooks/inventory/check_landingzone_readiness.py +35 -31
- runbooks/inventory/cloud_foundations_integration.py +8 -3
- runbooks/inventory/core/collector.py +201 -1
- runbooks/inventory/discovery.md +2 -1
- runbooks/inventory/{draw_org_structure.py → draw_org.py} +55 -9
- runbooks/inventory/drift_detection_cli.py +8 -68
- runbooks/inventory/find_cfn_drift_detection.py +14 -4
- runbooks/inventory/find_cfn_orphaned_stacks.py +7 -5
- runbooks/inventory/find_cfn_stackset_drift.py +5 -5
- runbooks/inventory/find_ec2_security_groups.py +6 -3
- runbooks/inventory/find_landingzone_versions.py +5 -5
- runbooks/inventory/find_vpc_flow_logs.py +5 -5
- runbooks/inventory/inventory.sh +20 -7
- runbooks/inventory/inventory_mcp_cli.py +4 -0
- runbooks/inventory/inventory_modules.py +9 -7
- runbooks/inventory/list_cfn_stacks.py +18 -8
- runbooks/inventory/list_cfn_stackset_operation_results.py +2 -2
- runbooks/inventory/list_cfn_stackset_operations.py +32 -20
- runbooks/inventory/list_cfn_stacksets.py +7 -4
- runbooks/inventory/list_config_recorders_delivery_channels.py +4 -4
- runbooks/inventory/list_ds_directories.py +3 -3
- runbooks/inventory/list_ec2_availability_zones.py +7 -3
- runbooks/inventory/list_ec2_ebs_volumes.py +3 -3
- runbooks/inventory/list_ec2_instances.py +1 -1
- runbooks/inventory/list_ecs_clusters_and_tasks.py +8 -4
- runbooks/inventory/list_elbs_load_balancers.py +7 -3
- runbooks/inventory/list_enis_network_interfaces.py +3 -3
- runbooks/inventory/list_guardduty_detectors.py +9 -5
- runbooks/inventory/list_iam_policies.py +7 -3
- runbooks/inventory/list_iam_roles.py +3 -3
- runbooks/inventory/list_iam_saml_providers.py +8 -4
- runbooks/inventory/list_lambda_functions.py +8 -4
- runbooks/inventory/list_org_accounts.py +306 -276
- runbooks/inventory/list_org_accounts_users.py +45 -9
- runbooks/inventory/list_rds_db_instances.py +4 -4
- runbooks/inventory/list_route53_hosted_zones.py +3 -3
- runbooks/inventory/list_servicecatalog_provisioned_products.py +5 -5
- runbooks/inventory/list_sns_topics.py +4 -4
- runbooks/inventory/list_ssm_parameters.py +6 -3
- runbooks/inventory/list_vpc_subnets.py +8 -4
- runbooks/inventory/list_vpcs.py +15 -4
- runbooks/inventory/mcp_vpc_validator.py +6 -0
- runbooks/inventory/organizations_discovery.py +17 -3
- runbooks/inventory/organizations_utils.py +553 -0
- runbooks/inventory/output_formatters.py +422 -0
- runbooks/inventory/recover_cfn_stack_ids.py +5 -5
- runbooks/inventory/run_on_multi_accounts.py +3 -3
- runbooks/inventory/tag_coverage.py +481 -0
- runbooks/inventory/validation_utils.py +358 -0
- runbooks/inventory/verify_ec2_security_groups.py +18 -5
- runbooks/inventory/vpc_architecture_validator.py +7 -1
- runbooks/inventory/vpc_dependency_analyzer.py +6 -0
- runbooks/main_final.py +2 -2
- runbooks/main_ultra_minimal.py +2 -2
- runbooks/mcp/integration.py +6 -4
- runbooks/remediation/acm_remediation.py +2 -2
- runbooks/remediation/cloudtrail_remediation.py +2 -2
- runbooks/remediation/cognito_remediation.py +2 -2
- runbooks/remediation/dynamodb_remediation.py +2 -2
- runbooks/remediation/ec2_remediation.py +2 -2
- runbooks/remediation/kms_remediation.py +2 -2
- runbooks/remediation/lambda_remediation.py +2 -2
- runbooks/remediation/rds_remediation.py +2 -2
- runbooks/remediation/s3_remediation.py +1 -1
- runbooks/vpc/cloudtrail_audit_integration.py +1 -1
- {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/METADATA +74 -4
- {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/RECORD +106 -100
- runbooks/__init__.py.backup +0 -134
- {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/WHEEL +0 -0
- {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/entry_points.txt +0 -0
- {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/licenses/LICENSE +0 -0
- {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,553 @@
|
|
1
|
+
#!/usr/bin/env python3
|
2
|
+
"""
|
3
|
+
Organizations Utility Functions for Phase 2 Multi-Profile Pattern.
|
4
|
+
|
5
|
+
This module provides simplified utility functions for the 5 Organizations scripts
|
6
|
+
implementing the Group-Level with --all-profiles pattern (Option B).
|
7
|
+
|
8
|
+
Features:
|
9
|
+
- Simple profile discovery via Organizations API
|
10
|
+
- Account-to-profile mapping with fallback
|
11
|
+
- Graceful error handling and single-profile fallback
|
12
|
+
- Configuration-driven mappings
|
13
|
+
|
14
|
+
Architecture Decision: Phase 2 Multi-Profile Pattern (Option B)
|
15
|
+
Reference: artifacts/decisions/phase-2-multi-profile-pattern.md
|
16
|
+
|
17
|
+
Scripts using this module:
|
18
|
+
1. list_org_accounts.py
|
19
|
+
2. list_org_accounts_users.py
|
20
|
+
3. check_controltower_readiness.py
|
21
|
+
4. check_landingzone_readiness.py
|
22
|
+
5. find_landingzone_versions.py
|
23
|
+
|
24
|
+
Author: CloudOps Runbooks Team
|
25
|
+
Version: 1.1.10
|
26
|
+
"""
|
27
|
+
|
28
|
+
import json
|
29
|
+
import logging
|
30
|
+
from pathlib import Path
|
31
|
+
from typing import Dict, List, Optional, Tuple, Any
|
32
|
+
|
33
|
+
import boto3
|
34
|
+
from botocore.exceptions import ClientError
|
35
|
+
|
36
|
+
from runbooks.common.rich_utils import (
|
37
|
+
console,
|
38
|
+
print_error,
|
39
|
+
print_info,
|
40
|
+
print_success,
|
41
|
+
print_warning,
|
42
|
+
)
|
43
|
+
from runbooks.common.config_loader import get_config_loader
|
44
|
+
|
45
|
+
logger = logging.getLogger(__name__)
|
46
|
+
|
47
|
+
|
48
|
+
class SimpleProfileMapper:
|
49
|
+
"""
|
50
|
+
Simple account ID to profile name mapper with 3-tier fallback.
|
51
|
+
|
52
|
+
Priority:
|
53
|
+
1. User configuration (~/.aws/runbooks/account_mappings.json)
|
54
|
+
2. AWS SSO configuration (~/.aws/config)
|
55
|
+
3. Fallback: Use account ID as profile name
|
56
|
+
"""
|
57
|
+
|
58
|
+
def __init__(self):
|
59
|
+
"""Initialize profile mapper with configuration loading."""
|
60
|
+
self.config_path = Path.home() / ".aws" / "runbooks" / "account_mappings.json"
|
61
|
+
self.mappings = self._load_mappings()
|
62
|
+
|
63
|
+
def resolve_profile(self, account_id: str, account_name: Optional[str] = None) -> str:
|
64
|
+
"""
|
65
|
+
Resolve account ID to profile name using multiple strategies.
|
66
|
+
|
67
|
+
Args:
|
68
|
+
account_id: AWS account ID (12-digit)
|
69
|
+
account_name: Optional account name for logging
|
70
|
+
|
71
|
+
Returns:
|
72
|
+
Profile name string
|
73
|
+
"""
|
74
|
+
# Strategy 1: User configuration
|
75
|
+
if account_id in self.mappings:
|
76
|
+
profile = self.mappings[account_id]
|
77
|
+
logger.debug(f"Resolved {account_id} → {profile} (user config)")
|
78
|
+
return profile
|
79
|
+
|
80
|
+
# Strategy 2: AWS SSO configuration
|
81
|
+
sso_profile = self._parse_sso_config(account_id)
|
82
|
+
if sso_profile:
|
83
|
+
logger.debug(f"Resolved {account_id} → {sso_profile} (SSO config)")
|
84
|
+
return sso_profile
|
85
|
+
|
86
|
+
# Strategy 3: Fallback to account ID
|
87
|
+
logger.debug(f"Resolved {account_id} → {account_id} (fallback)")
|
88
|
+
return account_id
|
89
|
+
|
90
|
+
def _load_mappings(self) -> dict:
|
91
|
+
"""Load user-defined account mappings from JSON config."""
|
92
|
+
if not self.config_path.exists():
|
93
|
+
logger.debug(f"No user config found at {self.config_path}")
|
94
|
+
return {}
|
95
|
+
|
96
|
+
try:
|
97
|
+
with open(self.config_path, "r") as f:
|
98
|
+
data = json.load(f)
|
99
|
+
|
100
|
+
# Filter out comment keys (starting with _)
|
101
|
+
mappings = {k: v for k, v in data.items() if not k.startswith("_")}
|
102
|
+
|
103
|
+
logger.info(f"Loaded {len(mappings)} account mappings from {self.config_path}")
|
104
|
+
return mappings
|
105
|
+
|
106
|
+
except json.JSONDecodeError as e:
|
107
|
+
logger.error(f"Invalid JSON in {self.config_path}: {e}")
|
108
|
+
return {}
|
109
|
+
except Exception as e:
|
110
|
+
logger.error(f"Failed to load account mappings: {e}")
|
111
|
+
return {}
|
112
|
+
|
113
|
+
def _parse_sso_config(self, account_id: str) -> Optional[str]:
|
114
|
+
"""Parse AWS SSO configuration for account mapping."""
|
115
|
+
config_path = Path.home() / ".aws" / "config"
|
116
|
+
|
117
|
+
if not config_path.exists():
|
118
|
+
return None
|
119
|
+
|
120
|
+
try:
|
121
|
+
with open(config_path, "r") as f:
|
122
|
+
current_profile = None
|
123
|
+
|
124
|
+
for line in f:
|
125
|
+
line = line.strip()
|
126
|
+
|
127
|
+
# Parse profile headers: [profile profile-name]
|
128
|
+
if line.startswith("[profile "):
|
129
|
+
current_profile = line[9:-1].strip()
|
130
|
+
elif line.startswith("["):
|
131
|
+
current_profile = None
|
132
|
+
|
133
|
+
# Check for sso_account_id match
|
134
|
+
if current_profile and line.startswith("sso_account_id"):
|
135
|
+
config_account_id = line.split("=")[1].strip()
|
136
|
+
if config_account_id == account_id:
|
137
|
+
return current_profile
|
138
|
+
|
139
|
+
except Exception as e:
|
140
|
+
logger.debug(f"Failed to parse SSO config: {e}")
|
141
|
+
|
142
|
+
return None
|
143
|
+
|
144
|
+
|
145
|
+
def discover_organization_accounts(
|
146
|
+
management_profile: str, region: str = "us-east-1"
|
147
|
+
) -> Tuple[List[Dict], Optional[str]]:
|
148
|
+
"""
|
149
|
+
Discover ACTIVE accounts via Organizations API with graceful fallback.
|
150
|
+
|
151
|
+
This function implements the Group-Level with --all-profiles pattern,
|
152
|
+
discovering accounts via the Organizations API when available, or
|
153
|
+
falling back to single-profile mode when Organizations permissions
|
154
|
+
are unavailable.
|
155
|
+
|
156
|
+
Args:
|
157
|
+
management_profile: AWS profile with Organizations API permissions
|
158
|
+
region: AWS region (Organizations is global, defaults to us-east-1)
|
159
|
+
|
160
|
+
Returns:
|
161
|
+
Tuple of (accounts_list, error_message):
|
162
|
+
- accounts_list: List of account dictionaries with keys:
|
163
|
+
- id: Account ID (12-digit)
|
164
|
+
- name: Account name
|
165
|
+
- email: Account email
|
166
|
+
- profile: Mapped profile name
|
167
|
+
- status: Account status (ACTIVE, SUSPENDED, etc.)
|
168
|
+
- error_message: Error message if discovery failed, None if successful
|
169
|
+
|
170
|
+
Example:
|
171
|
+
accounts, error = discover_organization_accounts("my-mgmt-profile")
|
172
|
+
if error:
|
173
|
+
print(f"Fallback mode: {error}")
|
174
|
+
for account in accounts:
|
175
|
+
print(f"{account['id']}: {account['name']} → {account['profile']}")
|
176
|
+
"""
|
177
|
+
profile_mapper = SimpleProfileMapper()
|
178
|
+
|
179
|
+
try:
|
180
|
+
# Initialize Organizations client
|
181
|
+
session = boto3.Session(profile_name=management_profile, region_name=region)
|
182
|
+
org_client = session.client("organizations")
|
183
|
+
|
184
|
+
print_info(f"Discovering accounts via Organizations API (profile: {management_profile})")
|
185
|
+
|
186
|
+
# Use paginator for large organizations
|
187
|
+
paginator = org_client.get_paginator("list_accounts")
|
188
|
+
page_iterator = paginator.paginate()
|
189
|
+
|
190
|
+
accounts = []
|
191
|
+
account_count = 0
|
192
|
+
|
193
|
+
# Get organization details to identify management account
|
194
|
+
try:
|
195
|
+
org_details = org_client.describe_organization()
|
196
|
+
mgmt_account_id = org_details["Organization"]["MasterAccountId"]
|
197
|
+
except Exception as e:
|
198
|
+
logger.warning(f"Could not retrieve organization details: {e}")
|
199
|
+
mgmt_account_id = None
|
200
|
+
|
201
|
+
for page in page_iterator:
|
202
|
+
for account in page["Accounts"]:
|
203
|
+
# Filter ACTIVE accounts only
|
204
|
+
if account["Status"] != "ACTIVE":
|
205
|
+
logger.debug(f"Skipping account {account['Id']} with status {account['Status']}")
|
206
|
+
continue
|
207
|
+
|
208
|
+
# Map account ID to profile name
|
209
|
+
profile_name = profile_mapper.resolve_profile(account["Id"], account.get("Name"))
|
210
|
+
|
211
|
+
# Determine if this is the management account
|
212
|
+
is_mgmt = (mgmt_account_id and account["Id"] == mgmt_account_id)
|
213
|
+
|
214
|
+
account_data = {
|
215
|
+
"id": account["Id"],
|
216
|
+
"name": account.get("Name", ""),
|
217
|
+
"email": account.get("Email", ""),
|
218
|
+
"profile": profile_name,
|
219
|
+
"status": account["Status"],
|
220
|
+
"is_management_account": is_mgmt,
|
221
|
+
"parent_org": mgmt_account_id if mgmt_account_id else account["Id"],
|
222
|
+
}
|
223
|
+
|
224
|
+
# ENHANCEMENT: Add tag enrichment (v1.1.10 Tags-First)
|
225
|
+
account_data = enhance_account_with_tags(account_data, org_client, logger)
|
226
|
+
|
227
|
+
accounts.append(account_data)
|
228
|
+
account_count += 1
|
229
|
+
|
230
|
+
print_success(f"Discovered {account_count} ACTIVE accounts via Organizations API")
|
231
|
+
return accounts, None
|
232
|
+
|
233
|
+
except ClientError as e:
|
234
|
+
error_code = e.response["Error"]["Code"]
|
235
|
+
|
236
|
+
if error_code == "AccessDeniedException":
|
237
|
+
return _handle_access_denied_fallback(management_profile, region, profile_mapper)
|
238
|
+
else:
|
239
|
+
error_msg = f"Organizations API error: {error_code}"
|
240
|
+
print_error(error_msg)
|
241
|
+
return _handle_generic_fallback(management_profile, region, profile_mapper, error_msg)
|
242
|
+
|
243
|
+
except Exception as e:
|
244
|
+
error_msg = f"Unexpected error during Organizations discovery: {str(e)}"
|
245
|
+
logger.error(error_msg)
|
246
|
+
return _handle_generic_fallback(management_profile, region, profile_mapper, error_msg)
|
247
|
+
|
248
|
+
|
249
|
+
def _handle_access_denied_fallback(
|
250
|
+
management_profile: str, region: str, profile_mapper: SimpleProfileMapper
|
251
|
+
) -> Tuple[List[Dict], str]:
|
252
|
+
"""Handle AccessDeniedException with fallback to single profile."""
|
253
|
+
print_warning("Organizations API Access Denied - fallback to single profile mode")
|
254
|
+
|
255
|
+
console.print("\n[yellow]Required IAM Permissions:[/yellow]")
|
256
|
+
console.print(
|
257
|
+
"""
|
258
|
+
{
|
259
|
+
"Version": "2012-10-17",
|
260
|
+
"Statement": [{
|
261
|
+
"Effect": "Allow",
|
262
|
+
"Action": [
|
263
|
+
"organizations:ListAccounts",
|
264
|
+
"organizations:DescribeOrganization"
|
265
|
+
],
|
266
|
+
"Resource": "*"
|
267
|
+
}]
|
268
|
+
}
|
269
|
+
"""
|
270
|
+
)
|
271
|
+
|
272
|
+
console.print("\n[dim]Fallback: Using single profile mode[/dim]\n")
|
273
|
+
|
274
|
+
return _create_single_account_fallback(management_profile, region, profile_mapper)
|
275
|
+
|
276
|
+
|
277
|
+
def _handle_generic_fallback(
|
278
|
+
management_profile: str, region: str, profile_mapper: SimpleProfileMapper, error_msg: str
|
279
|
+
) -> Tuple[List[Dict], str]:
|
280
|
+
"""Handle generic errors with fallback to single profile."""
|
281
|
+
print_warning(f"Organizations discovery failed: {error_msg}")
|
282
|
+
print_info("Falling back to single profile mode")
|
283
|
+
|
284
|
+
return _create_single_account_fallback(management_profile, region, profile_mapper)
|
285
|
+
|
286
|
+
|
287
|
+
def enhance_account_with_tags(
|
288
|
+
account_data: Dict[str, Any],
|
289
|
+
org_client: boto3.client,
|
290
|
+
logger: logging.Logger,
|
291
|
+
tag_mappings: Optional[Dict[str, str]] = None
|
292
|
+
) -> Dict[str, Any]:
|
293
|
+
"""
|
294
|
+
Enhance account metadata with AWS Tags (TIER 1-4).
|
295
|
+
|
296
|
+
Implements Tags-First strategy: All enhanced metadata sourced from
|
297
|
+
AWS account tags via organizations:ListTagsForResource API.
|
298
|
+
|
299
|
+
Args:
|
300
|
+
account_data: Existing account dictionary with baseline 9 columns
|
301
|
+
org_client: boto3 Organizations client with ListTagsForResource permission
|
302
|
+
logger: Logger for error tracking and debugging
|
303
|
+
tag_mappings: Optional dict mapping field names to AWS tag keys.
|
304
|
+
If None, uses ConfigLoader with hierarchical precedence:
|
305
|
+
1. User config (~/.runbooks/config.yaml)
|
306
|
+
2. Project config (./.runbooks.yaml)
|
307
|
+
3. Environment variables (RUNBOOKS_TAG_*)
|
308
|
+
4. Defaults (WBS, CostGroup, TechnicalLead, etc.)
|
309
|
+
|
310
|
+
Returns:
|
311
|
+
Enhanced account dictionary with 14+ columns (baseline + TIER 1-4 tags)
|
312
|
+
|
313
|
+
Tag Schema (TIER 1-4 Classification):
|
314
|
+
TIER 1 - Critical Business Metadata (4 tags):
|
315
|
+
- WBS (Work Breakdown Structure code for cost allocation)
|
316
|
+
- CostGroup (Cost center assignment)
|
317
|
+
- TechnicalLead (Primary technical owner email)
|
318
|
+
- AccountOwner (Business owner email)
|
319
|
+
|
320
|
+
TIER 2 - Governance Metadata (4 tags):
|
321
|
+
- BusinessUnit (Organizational business unit)
|
322
|
+
- FunctionalArea (Functional domain: DevOps, Security, etc.)
|
323
|
+
- ManagedBy (Management service: Control Tower, manual, etc.)
|
324
|
+
- ProductOwner (Product ownership email)
|
325
|
+
|
326
|
+
TIER 3 - Operational Metadata (4 tags):
|
327
|
+
- Purpose (Account purpose description)
|
328
|
+
- Environment (Environment classification: prod, dev, test, etc.)
|
329
|
+
- ComplianceScope (Regulatory compliance requirements)
|
330
|
+
- DataClassification (Data sensitivity classification)
|
331
|
+
|
332
|
+
TIER 4 - Extended Metadata (5 tags, optional):
|
333
|
+
- ProjectName (Project or application name)
|
334
|
+
- BudgetCode (Budget allocation code)
|
335
|
+
- SupportTier (Support level: 24x7, business hours, etc.)
|
336
|
+
- CreatedDate (Account creation date)
|
337
|
+
- ExpiryDate (Account expiration date for temporary accounts)
|
338
|
+
|
339
|
+
Computed Fields:
|
340
|
+
- all_tags (Complete tag dictionary - raw AWS tags)
|
341
|
+
- wbs_comparison (WBS vs cht-wbs consistency validation)
|
342
|
+
|
343
|
+
Error Handling:
|
344
|
+
- AccessDeniedException: Graceful fallback with 'N/A' values
|
345
|
+
- API throttling: Exponential backoff retry (boto3 default)
|
346
|
+
- Missing tags: Default to 'N/A' for missing keys
|
347
|
+
- Any exception: Log error, return account with 'N/A' values
|
348
|
+
|
349
|
+
Example:
|
350
|
+
>>> # Use default hierarchical config loading
|
351
|
+
>>> account = {"id": "123456789012", "name": "Production"}
|
352
|
+
>>> enhanced = enhance_account_with_tags(account, org_client, logger)
|
353
|
+
>>> enhanced["wbs_code"]
|
354
|
+
'WBS-12345'
|
355
|
+
|
356
|
+
>>> # Explicit tag mapping override (testing/advanced use)
|
357
|
+
>>> custom_mappings = {'wbs_code': 'ProjectCode', 'cost_group': 'BillingGroup'}
|
358
|
+
>>> enhanced = enhance_account_with_tags(account, org_client, logger, custom_mappings)
|
359
|
+
>>> enhanced["wbs_code"]
|
360
|
+
'PROJECT-789'
|
361
|
+
"""
|
362
|
+
account_id = account_data.get("id", "unknown")
|
363
|
+
|
364
|
+
# Load tag mappings with hierarchical precedence (if not provided)
|
365
|
+
if tag_mappings is None:
|
366
|
+
config_loader = get_config_loader()
|
367
|
+
tag_mappings = config_loader.load_tag_mappings()
|
368
|
+
logger.debug(
|
369
|
+
f"Loaded tag mappings from: {', '.join(config_loader.get_config_sources())}"
|
370
|
+
)
|
371
|
+
|
372
|
+
try:
|
373
|
+
# Fetch tags from AWS Organizations API
|
374
|
+
tags_response = org_client.list_tags_for_resource(ResourceId=account_id)
|
375
|
+
tags = {tag['Key']: tag['Value'] for tag in tags_response.get('Tags', [])}
|
376
|
+
|
377
|
+
logger.debug(f"Retrieved {len(tags)} tags for account {account_id}")
|
378
|
+
|
379
|
+
# Map AWS tags to account fields using configured mappings
|
380
|
+
# TIER 1: Business Metadata
|
381
|
+
account_data['wbs_code'] = tags.get(tag_mappings.get('wbs_code'), 'N/A')
|
382
|
+
account_data['cost_group'] = tags.get(tag_mappings.get('cost_group'), 'N/A')
|
383
|
+
account_data['technical_lead'] = tags.get(tag_mappings.get('technical_lead'), 'N/A')
|
384
|
+
account_data['account_owner'] = tags.get(tag_mappings.get('account_owner'), 'N/A')
|
385
|
+
|
386
|
+
# TIER 2: Governance Metadata
|
387
|
+
account_data['business_unit'] = tags.get(tag_mappings.get('business_unit'), 'N/A')
|
388
|
+
account_data['functional_area'] = tags.get(tag_mappings.get('functional_area'), 'N/A')
|
389
|
+
account_data['managed_by'] = tags.get(tag_mappings.get('managed_by'), 'N/A')
|
390
|
+
account_data['product_owner'] = tags.get(tag_mappings.get('product_owner'), 'N/A')
|
391
|
+
|
392
|
+
# TIER 3: Operational Metadata
|
393
|
+
account_data['purpose'] = tags.get(tag_mappings.get('purpose'), 'N/A')
|
394
|
+
account_data['environment'] = tags.get(tag_mappings.get('environment'), 'N/A')
|
395
|
+
account_data['compliance_scope'] = tags.get(tag_mappings.get('compliance_scope'), 'N/A')
|
396
|
+
account_data['data_classification'] = tags.get(tag_mappings.get('data_classification'), 'N/A')
|
397
|
+
|
398
|
+
# TIER 4: Extended Metadata (if configured)
|
399
|
+
if 'project_name' in tag_mappings:
|
400
|
+
account_data['project_name'] = tags.get(tag_mappings.get('project_name'), 'N/A')
|
401
|
+
if 'budget_code' in tag_mappings:
|
402
|
+
account_data['budget_code'] = tags.get(tag_mappings.get('budget_code'), 'N/A')
|
403
|
+
if 'support_tier' in tag_mappings:
|
404
|
+
account_data['support_tier'] = tags.get(tag_mappings.get('support_tier'), 'N/A')
|
405
|
+
if 'created_date' in tag_mappings:
|
406
|
+
account_data['created_date'] = tags.get(tag_mappings.get('created_date'), 'N/A')
|
407
|
+
if 'expiry_date' in tag_mappings:
|
408
|
+
account_data['expiry_date'] = tags.get(tag_mappings.get('expiry_date'), 'N/A')
|
409
|
+
|
410
|
+
# Keep existing all_tags and wbs_comparison logic
|
411
|
+
account_data['all_tags'] = tags
|
412
|
+
|
413
|
+
# WBS comparison (if both tags exist)
|
414
|
+
wbs_value = tags.get(tag_mappings.get('wbs_code'), 'N/A')
|
415
|
+
cht_wbs_value = tags.get('cht-wbs', 'N/A')
|
416
|
+
account_data['wbs_comparison'] = {
|
417
|
+
'wbs': wbs_value,
|
418
|
+
'cht_wbs': cht_wbs_value,
|
419
|
+
'match': wbs_value == cht_wbs_value if wbs_value != 'N/A' and cht_wbs_value != 'N/A' else False
|
420
|
+
}
|
421
|
+
|
422
|
+
return account_data
|
423
|
+
|
424
|
+
except org_client.exceptions.AccessDeniedException:
|
425
|
+
logger.warning(f"AccessDenied for ListTagsForResource on account {account_id}")
|
426
|
+
# Graceful fallback: Return account with N/A values for all tag fields
|
427
|
+
return _add_na_tag_fields(account_data)
|
428
|
+
|
429
|
+
except Exception as e:
|
430
|
+
logger.error(f"Failed to fetch tags for account {account_id}: {e}", exc_info=True)
|
431
|
+
# Same graceful fallback for any unexpected error
|
432
|
+
return _add_na_tag_fields(account_data)
|
433
|
+
|
434
|
+
|
435
|
+
def _add_na_tag_fields(account_data: Dict[str, Any]) -> Dict[str, Any]:
|
436
|
+
"""Helper: Add N/A values for all tag-based fields."""
|
437
|
+
account_data.update({
|
438
|
+
# TIER 1: Business Metadata
|
439
|
+
'wbs_code': 'N/A',
|
440
|
+
'cost_group': 'N/A',
|
441
|
+
'technical_lead': 'N/A',
|
442
|
+
'account_owner': 'N/A',
|
443
|
+
# TIER 2: Governance Metadata
|
444
|
+
'business_unit': 'N/A',
|
445
|
+
'functional_area': 'N/A',
|
446
|
+
'managed_by': 'N/A',
|
447
|
+
'product_owner': 'N/A',
|
448
|
+
# TIER 3: Operational Metadata
|
449
|
+
'purpose': 'N/A',
|
450
|
+
'environment': 'N/A',
|
451
|
+
'compliance_scope': 'N/A',
|
452
|
+
'data_classification': 'N/A',
|
453
|
+
# TIER 4: Extended Metadata
|
454
|
+
'project_name': 'N/A',
|
455
|
+
'budget_code': 'N/A',
|
456
|
+
'support_tier': 'N/A',
|
457
|
+
'created_date': 'N/A',
|
458
|
+
'expiry_date': 'N/A',
|
459
|
+
# Computed fields
|
460
|
+
'all_tags': {},
|
461
|
+
'wbs_comparison': {'wbs': 'N/A', 'cht_wbs': 'N/A', 'match': False}
|
462
|
+
})
|
463
|
+
return account_data
|
464
|
+
|
465
|
+
|
466
|
+
def _create_single_account_fallback(
|
467
|
+
management_profile: str, region: str, profile_mapper: SimpleProfileMapper
|
468
|
+
) -> Tuple[List[Dict], str]:
|
469
|
+
"""Create single account fallback entry."""
|
470
|
+
try:
|
471
|
+
# Get account ID from STS for the current profile
|
472
|
+
session = boto3.Session(profile_name=management_profile, region_name=region)
|
473
|
+
sts_client = session.client("sts")
|
474
|
+
account_id = sts_client.get_caller_identity()["Account"]
|
475
|
+
|
476
|
+
# Resolve profile name (may be different from management_profile)
|
477
|
+
profile_name = profile_mapper.resolve_profile(account_id)
|
478
|
+
|
479
|
+
fallback_account = {
|
480
|
+
"id": account_id,
|
481
|
+
"name": "Single Account (Fallback)",
|
482
|
+
"email": "N/A",
|
483
|
+
"profile": profile_name,
|
484
|
+
"status": "ACTIVE",
|
485
|
+
"is_management_account": False,
|
486
|
+
"parent_org": account_id,
|
487
|
+
"is_standalone": True,
|
488
|
+
}
|
489
|
+
|
490
|
+
print_info(f"Single profile mode: {management_profile} → Account {account_id}")
|
491
|
+
|
492
|
+
error_msg = "Organizations API unavailable - using single profile mode"
|
493
|
+
return [fallback_account], error_msg
|
494
|
+
|
495
|
+
except Exception as e:
|
496
|
+
logger.error(f"Fallback failed: {e}")
|
497
|
+
print_error("Failed to retrieve account information for fallback mode")
|
498
|
+
error_msg = f"Complete failure: {str(e)}"
|
499
|
+
return [], error_msg
|
500
|
+
|
501
|
+
|
502
|
+
def create_account_mappings_template() -> None:
|
503
|
+
"""
|
504
|
+
Create template account_mappings.json if it doesn't exist.
|
505
|
+
|
506
|
+
Creates ~/.aws/runbooks/ directory and account_mappings.json template
|
507
|
+
with documentation for user configuration.
|
508
|
+
"""
|
509
|
+
config_dir = Path.home() / ".aws" / "runbooks"
|
510
|
+
config_file = config_dir / "account_mappings.json"
|
511
|
+
|
512
|
+
# Create directory if it doesn't exist
|
513
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
514
|
+
|
515
|
+
# Don't overwrite existing config
|
516
|
+
if config_file.exists():
|
517
|
+
logger.debug(f"Account mappings config already exists: {config_file}")
|
518
|
+
return
|
519
|
+
|
520
|
+
# Create template
|
521
|
+
template = {
|
522
|
+
"_comment": "AWS Account ID to Profile Name Mappings",
|
523
|
+
"_instructions": "Add your account mappings below. Format: 'account-id': 'profile-name'",
|
524
|
+
"_example_1": "Replace these examples with your actual account mappings",
|
525
|
+
"_example_2": "Remove lines starting with _ before using",
|
526
|
+
"123456789012": "production",
|
527
|
+
"234567890123": "staging",
|
528
|
+
"345678901234": "development",
|
529
|
+
"456789012345": "sandbox",
|
530
|
+
}
|
531
|
+
|
532
|
+
try:
|
533
|
+
with open(config_file, "w") as f:
|
534
|
+
json.dump(template, f, indent=2)
|
535
|
+
|
536
|
+
print_success(f"Created account mappings template: {config_file}")
|
537
|
+
console.print("\n[dim]Edit this file to configure account-to-profile mappings[/dim]\n")
|
538
|
+
|
539
|
+
except Exception as e:
|
540
|
+
logger.error(f"Failed to create account mappings template: {e}")
|
541
|
+
|
542
|
+
|
543
|
+
# Initialize configuration template on module import
|
544
|
+
def _initialize_config():
|
545
|
+
"""Initialize configuration template on module load."""
|
546
|
+
try:
|
547
|
+
create_account_mappings_template()
|
548
|
+
except Exception as e:
|
549
|
+
logger.debug(f"Config template initialization skipped: {e}")
|
550
|
+
|
551
|
+
|
552
|
+
# Run initialization
|
553
|
+
_initialize_config()
|