runbooks 1.1.7__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.
Files changed (113) hide show
  1. runbooks/__init__.py +1 -1
  2. runbooks/__init___optimized.py +2 -1
  3. runbooks/_platform/__init__.py +1 -1
  4. runbooks/cfat/cli.py +4 -3
  5. runbooks/cfat/cloud_foundations_assessment.py +1 -2
  6. runbooks/cfat/tests/test_cli.py +4 -1
  7. runbooks/cli/commands/finops.py +68 -19
  8. runbooks/cli/commands/inventory.py +838 -14
  9. runbooks/cli/commands/operate.py +65 -4
  10. runbooks/cli/commands/vpc.py +1 -1
  11. runbooks/cloudops/cost_optimizer.py +1 -3
  12. runbooks/common/cli_decorators.py +6 -4
  13. runbooks/common/config_loader.py +787 -0
  14. runbooks/common/config_schema.py +280 -0
  15. runbooks/common/dry_run_framework.py +14 -2
  16. runbooks/common/mcp_integration.py +238 -0
  17. runbooks/finops/ebs_cost_optimizer.py +7 -4
  18. runbooks/finops/elastic_ip_optimizer.py +7 -4
  19. runbooks/finops/infrastructure/__init__.py +3 -2
  20. runbooks/finops/infrastructure/commands.py +7 -4
  21. runbooks/finops/infrastructure/load_balancer_optimizer.py +7 -4
  22. runbooks/finops/infrastructure/vpc_endpoint_optimizer.py +7 -4
  23. runbooks/finops/nat_gateway_optimizer.py +7 -4
  24. runbooks/finops/tests/run_tests.py +1 -1
  25. runbooks/inventory/ArgumentsClass.py +2 -1
  26. runbooks/inventory/CLAUDE.md +41 -0
  27. runbooks/inventory/README.md +210 -2
  28. runbooks/inventory/Tests/test_Inventory_Modules.py +27 -10
  29. runbooks/inventory/Tests/test_cfn_describe_stacks.py +18 -7
  30. runbooks/inventory/Tests/test_ec2_describe_instances.py +30 -15
  31. runbooks/inventory/Tests/test_lambda_list_functions.py +17 -3
  32. runbooks/inventory/Tests/test_org_list_accounts.py +17 -4
  33. runbooks/inventory/account_class.py +0 -1
  34. runbooks/inventory/all_my_instances_wrapper.py +4 -8
  35. runbooks/inventory/aws_organization.png +0 -0
  36. runbooks/inventory/check_cloudtrail_compliance.py +4 -4
  37. runbooks/inventory/check_controltower_readiness.py +50 -47
  38. runbooks/inventory/check_landingzone_readiness.py +35 -31
  39. runbooks/inventory/cloud_foundations_integration.py +8 -3
  40. runbooks/inventory/collectors/aws_compute.py +59 -11
  41. runbooks/inventory/collectors/aws_management.py +39 -5
  42. runbooks/inventory/core/collector.py +1655 -159
  43. runbooks/inventory/core/concurrent_paginator.py +511 -0
  44. runbooks/inventory/discovery.md +15 -6
  45. runbooks/inventory/{draw_org_structure.py → draw_org.py} +55 -9
  46. runbooks/inventory/drift_detection_cli.py +8 -68
  47. runbooks/inventory/find_cfn_drift_detection.py +14 -4
  48. runbooks/inventory/find_cfn_orphaned_stacks.py +7 -5
  49. runbooks/inventory/find_cfn_stackset_drift.py +5 -5
  50. runbooks/inventory/find_ec2_security_groups.py +6 -3
  51. runbooks/inventory/find_landingzone_versions.py +5 -5
  52. runbooks/inventory/find_vpc_flow_logs.py +5 -5
  53. runbooks/inventory/inventory.sh +20 -7
  54. runbooks/inventory/inventory_mcp_cli.py +4 -0
  55. runbooks/inventory/inventory_modules.py +9 -7
  56. runbooks/inventory/list_cfn_stacks.py +18 -8
  57. runbooks/inventory/list_cfn_stackset_operation_results.py +2 -2
  58. runbooks/inventory/list_cfn_stackset_operations.py +32 -20
  59. runbooks/inventory/list_cfn_stacksets.py +7 -4
  60. runbooks/inventory/list_config_recorders_delivery_channels.py +4 -4
  61. runbooks/inventory/list_ds_directories.py +3 -3
  62. runbooks/inventory/list_ec2_availability_zones.py +7 -3
  63. runbooks/inventory/list_ec2_ebs_volumes.py +3 -3
  64. runbooks/inventory/list_ec2_instances.py +1 -1
  65. runbooks/inventory/list_ecs_clusters_and_tasks.py +8 -4
  66. runbooks/inventory/list_elbs_load_balancers.py +7 -3
  67. runbooks/inventory/list_enis_network_interfaces.py +3 -3
  68. runbooks/inventory/list_guardduty_detectors.py +9 -5
  69. runbooks/inventory/list_iam_policies.py +7 -3
  70. runbooks/inventory/list_iam_roles.py +3 -3
  71. runbooks/inventory/list_iam_saml_providers.py +8 -4
  72. runbooks/inventory/list_lambda_functions.py +8 -4
  73. runbooks/inventory/list_org_accounts.py +306 -276
  74. runbooks/inventory/list_org_accounts_users.py +45 -9
  75. runbooks/inventory/list_rds_db_instances.py +4 -4
  76. runbooks/inventory/list_route53_hosted_zones.py +3 -3
  77. runbooks/inventory/list_servicecatalog_provisioned_products.py +5 -5
  78. runbooks/inventory/list_sns_topics.py +4 -4
  79. runbooks/inventory/list_ssm_parameters.py +6 -3
  80. runbooks/inventory/list_vpc_subnets.py +8 -4
  81. runbooks/inventory/list_vpcs.py +15 -4
  82. runbooks/inventory/mcp_inventory_validator.py +771 -134
  83. runbooks/inventory/mcp_vpc_validator.py +6 -0
  84. runbooks/inventory/organizations_discovery.py +17 -3
  85. runbooks/inventory/organizations_utils.py +553 -0
  86. runbooks/inventory/output_formatters.py +422 -0
  87. runbooks/inventory/recover_cfn_stack_ids.py +5 -5
  88. runbooks/inventory/run_on_multi_accounts.py +3 -3
  89. runbooks/inventory/tag_coverage.py +481 -0
  90. runbooks/inventory/validation_utils.py +358 -0
  91. runbooks/inventory/verify_ec2_security_groups.py +18 -5
  92. runbooks/inventory/vpc_architecture_validator.py +7 -1
  93. runbooks/inventory/vpc_dependency_analyzer.py +6 -0
  94. runbooks/main_final.py +2 -2
  95. runbooks/main_ultra_minimal.py +2 -2
  96. runbooks/mcp/integration.py +6 -4
  97. runbooks/remediation/acm_remediation.py +2 -2
  98. runbooks/remediation/cloudtrail_remediation.py +2 -2
  99. runbooks/remediation/cognito_remediation.py +2 -2
  100. runbooks/remediation/dynamodb_remediation.py +2 -2
  101. runbooks/remediation/ec2_remediation.py +2 -2
  102. runbooks/remediation/kms_remediation.py +2 -2
  103. runbooks/remediation/lambda_remediation.py +2 -2
  104. runbooks/remediation/rds_remediation.py +2 -2
  105. runbooks/remediation/s3_remediation.py +1 -1
  106. runbooks/vpc/cloudtrail_audit_integration.py +1 -1
  107. {runbooks-1.1.7.dist-info → runbooks-1.1.10.dist-info}/METADATA +74 -4
  108. {runbooks-1.1.7.dist-info → runbooks-1.1.10.dist-info}/RECORD +112 -105
  109. runbooks/__init__.py.backup +0 -134
  110. {runbooks-1.1.7.dist-info → runbooks-1.1.10.dist-info}/WHEEL +0 -0
  111. {runbooks-1.1.7.dist-info → runbooks-1.1.10.dist-info}/entry_points.txt +0 -0
  112. {runbooks-1.1.7.dist-info → runbooks-1.1.10.dist-info}/licenses/LICENSE +0 -0
  113. {runbooks-1.1.7.dist-info → runbooks-1.1.10.dist-info}/top_level.txt +0 -0
@@ -40,6 +40,9 @@ import boto3
40
40
  from botocore.exceptions import ClientError
41
41
 
42
42
  from runbooks.common.rich_utils import (
43
+
44
+
45
+ # Terminal control constants
43
46
  console,
44
47
  print_header,
45
48
  print_success,
@@ -50,6 +53,9 @@ from runbooks.common.rich_utils import (
50
53
  STATUS_INDICATORS,
51
54
  )
52
55
 
56
+
57
+ # Terminal control constants
58
+ ERASE_LINE = '\x1b[2K'
53
59
  logger = logging.getLogger(__name__)
54
60
 
55
61
 
@@ -40,6 +40,9 @@ from ..utils.logger import configure_logger
40
40
  from ..common.performance_optimization_engine import get_optimization_engine
41
41
  from ..common.rich_utils import console, Progress
42
42
 
43
+
44
+ # Terminal control constants
45
+ ERASE_LINE = '\x1b[2K'
43
46
  logger = configure_logger(__name__)
44
47
 
45
48
  # Global Organizations cache to prevent duplicate API calls across all instances
@@ -71,6 +74,7 @@ def _set_global_organizations_cache(data):
71
74
  # Universal AWS Environment Profile Support (Compatible with ANY AWS Setup)
72
75
  import os
73
76
 
77
+
74
78
  ENTERPRISE_PROFILES = {
75
79
  "BILLING_PROFILE": os.getenv("BILLING_PROFILE", "default"), # Universal compatibility
76
80
  "MANAGEMENT_PROFILE": os.getenv("MANAGEMENT_PROFILE", "default"), # Works with any profile
@@ -1414,7 +1418,13 @@ if __name__ == "__main__":
1414
1418
  description="Enhanced Organizations Discovery Engine with 4-Profile AWS SSO Architecture"
1415
1419
  )
1416
1420
  parser.add_argument(
1421
+ "--profile",
1422
+ help=f"AWS profile for single account operations (default: {ENTERPRISE_PROFILES['SINGLE_ACCOUNT_PROFILE']})",
1423
+ )
1424
+ parser.add_argument(
1425
+ "--all-profile",
1417
1426
  "--management-profile",
1427
+ dest="management_profile",
1418
1428
  help=f"AWS profile with Organizations access (default: {ENTERPRISE_PROFILES['MANAGEMENT_PROFILE']})",
1419
1429
  )
1420
1430
  parser.add_argument(
@@ -1441,19 +1451,23 @@ if __name__ == "__main__":
1441
1451
  args = parser.parse_args()
1442
1452
 
1443
1453
  async def main():
1454
+ # Use --profile as fallback for single-account mode
1455
+ single_account = args.single_account_profile or args.profile
1456
+ management = args.management_profile or args.profile
1457
+
1444
1458
  if args.legacy:
1445
1459
  console.print("[yellow]⚠️ Using legacy compatibility mode[/yellow]")
1446
1460
  results = await run_organizations_discovery(
1447
- management_profile=args.management_profile or ENTERPRISE_PROFILES["MANAGEMENT_PROFILE"],
1461
+ management_profile=management or ENTERPRISE_PROFILES["MANAGEMENT_PROFILE"],
1448
1462
  billing_profile=args.billing_profile or ENTERPRISE_PROFILES["BILLING_PROFILE"],
1449
1463
  )
1450
1464
  else:
1451
1465
  console.print("[cyan]🚀 Using enhanced 4-profile discovery engine[/cyan]")
1452
1466
  results = await run_enhanced_organizations_discovery(
1453
- management_profile=args.management_profile,
1467
+ management_profile=management,
1454
1468
  billing_profile=args.billing_profile,
1455
1469
  operational_profile=args.operational_profile,
1456
- single_account_profile=args.single_account_profile,
1470
+ single_account_profile=single_account,
1457
1471
  performance_target_seconds=args.performance_target,
1458
1472
  )
1459
1473
 
@@ -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()