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.
Files changed (107) 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 +796 -7
  9. runbooks/cli/commands/operate.py +65 -4
  10. runbooks/cloudops/cost_optimizer.py +1 -3
  11. runbooks/common/cli_decorators.py +6 -4
  12. runbooks/common/config_loader.py +787 -0
  13. runbooks/common/config_schema.py +280 -0
  14. runbooks/common/dry_run_framework.py +14 -2
  15. runbooks/common/mcp_integration.py +238 -0
  16. runbooks/finops/ebs_cost_optimizer.py +7 -4
  17. runbooks/finops/elastic_ip_optimizer.py +7 -4
  18. runbooks/finops/infrastructure/__init__.py +3 -2
  19. runbooks/finops/infrastructure/commands.py +7 -4
  20. runbooks/finops/infrastructure/load_balancer_optimizer.py +7 -4
  21. runbooks/finops/infrastructure/vpc_endpoint_optimizer.py +7 -4
  22. runbooks/finops/nat_gateway_optimizer.py +7 -4
  23. runbooks/finops/tests/run_tests.py +1 -1
  24. runbooks/inventory/ArgumentsClass.py +2 -1
  25. runbooks/inventory/README.md +111 -12
  26. runbooks/inventory/Tests/test_Inventory_Modules.py +27 -10
  27. runbooks/inventory/Tests/test_cfn_describe_stacks.py +18 -7
  28. runbooks/inventory/Tests/test_ec2_describe_instances.py +30 -15
  29. runbooks/inventory/Tests/test_lambda_list_functions.py +17 -3
  30. runbooks/inventory/Tests/test_org_list_accounts.py +17 -4
  31. runbooks/inventory/account_class.py +0 -1
  32. runbooks/inventory/all_my_instances_wrapper.py +4 -8
  33. runbooks/inventory/aws_organization.png +0 -0
  34. runbooks/inventory/check_cloudtrail_compliance.py +4 -4
  35. runbooks/inventory/check_controltower_readiness.py +50 -47
  36. runbooks/inventory/check_landingzone_readiness.py +35 -31
  37. runbooks/inventory/cloud_foundations_integration.py +8 -3
  38. runbooks/inventory/core/collector.py +201 -1
  39. runbooks/inventory/discovery.md +2 -1
  40. runbooks/inventory/{draw_org_structure.py → draw_org.py} +55 -9
  41. runbooks/inventory/drift_detection_cli.py +8 -68
  42. runbooks/inventory/find_cfn_drift_detection.py +14 -4
  43. runbooks/inventory/find_cfn_orphaned_stacks.py +7 -5
  44. runbooks/inventory/find_cfn_stackset_drift.py +5 -5
  45. runbooks/inventory/find_ec2_security_groups.py +6 -3
  46. runbooks/inventory/find_landingzone_versions.py +5 -5
  47. runbooks/inventory/find_vpc_flow_logs.py +5 -5
  48. runbooks/inventory/inventory.sh +20 -7
  49. runbooks/inventory/inventory_mcp_cli.py +4 -0
  50. runbooks/inventory/inventory_modules.py +9 -7
  51. runbooks/inventory/list_cfn_stacks.py +18 -8
  52. runbooks/inventory/list_cfn_stackset_operation_results.py +2 -2
  53. runbooks/inventory/list_cfn_stackset_operations.py +32 -20
  54. runbooks/inventory/list_cfn_stacksets.py +7 -4
  55. runbooks/inventory/list_config_recorders_delivery_channels.py +4 -4
  56. runbooks/inventory/list_ds_directories.py +3 -3
  57. runbooks/inventory/list_ec2_availability_zones.py +7 -3
  58. runbooks/inventory/list_ec2_ebs_volumes.py +3 -3
  59. runbooks/inventory/list_ec2_instances.py +1 -1
  60. runbooks/inventory/list_ecs_clusters_and_tasks.py +8 -4
  61. runbooks/inventory/list_elbs_load_balancers.py +7 -3
  62. runbooks/inventory/list_enis_network_interfaces.py +3 -3
  63. runbooks/inventory/list_guardduty_detectors.py +9 -5
  64. runbooks/inventory/list_iam_policies.py +7 -3
  65. runbooks/inventory/list_iam_roles.py +3 -3
  66. runbooks/inventory/list_iam_saml_providers.py +8 -4
  67. runbooks/inventory/list_lambda_functions.py +8 -4
  68. runbooks/inventory/list_org_accounts.py +306 -276
  69. runbooks/inventory/list_org_accounts_users.py +45 -9
  70. runbooks/inventory/list_rds_db_instances.py +4 -4
  71. runbooks/inventory/list_route53_hosted_zones.py +3 -3
  72. runbooks/inventory/list_servicecatalog_provisioned_products.py +5 -5
  73. runbooks/inventory/list_sns_topics.py +4 -4
  74. runbooks/inventory/list_ssm_parameters.py +6 -3
  75. runbooks/inventory/list_vpc_subnets.py +8 -4
  76. runbooks/inventory/list_vpcs.py +15 -4
  77. runbooks/inventory/mcp_vpc_validator.py +6 -0
  78. runbooks/inventory/organizations_discovery.py +17 -3
  79. runbooks/inventory/organizations_utils.py +553 -0
  80. runbooks/inventory/output_formatters.py +422 -0
  81. runbooks/inventory/recover_cfn_stack_ids.py +5 -5
  82. runbooks/inventory/run_on_multi_accounts.py +3 -3
  83. runbooks/inventory/tag_coverage.py +481 -0
  84. runbooks/inventory/validation_utils.py +358 -0
  85. runbooks/inventory/verify_ec2_security_groups.py +18 -5
  86. runbooks/inventory/vpc_architecture_validator.py +7 -1
  87. runbooks/inventory/vpc_dependency_analyzer.py +6 -0
  88. runbooks/main_final.py +2 -2
  89. runbooks/main_ultra_minimal.py +2 -2
  90. runbooks/mcp/integration.py +6 -4
  91. runbooks/remediation/acm_remediation.py +2 -2
  92. runbooks/remediation/cloudtrail_remediation.py +2 -2
  93. runbooks/remediation/cognito_remediation.py +2 -2
  94. runbooks/remediation/dynamodb_remediation.py +2 -2
  95. runbooks/remediation/ec2_remediation.py +2 -2
  96. runbooks/remediation/kms_remediation.py +2 -2
  97. runbooks/remediation/lambda_remediation.py +2 -2
  98. runbooks/remediation/rds_remediation.py +2 -2
  99. runbooks/remediation/s3_remediation.py +1 -1
  100. runbooks/vpc/cloudtrail_audit_integration.py +1 -1
  101. {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/METADATA +74 -4
  102. {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/RECORD +106 -100
  103. runbooks/__init__.py.backup +0 -134
  104. {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/WHEEL +0 -0
  105. {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/entry_points.txt +0 -0
  106. {runbooks-1.1.9.dist-info → runbooks-1.1.10.dist-info}/licenses/LICENSE +0 -0
  107. {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()