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
@@ -9,39 +9,50 @@ status analysis, organizational hierarchy mapping, and cross-organization accoun
9
9
  **AWS API Mapping**: `organizations.list_accounts()`, `organizations.describe_organization()`
10
10
 
11
11
  Features:
12
- - Multi-organization account discovery
12
+ - Multi-organization account discovery via --all-profiles pattern
13
13
  - Management Account identification and validation
14
14
  - Account status tracking (ACTIVE, SUSPENDED, etc.)
15
15
  - Cross-organization account lookup by ID
16
+ - Multi-format export (JSON, CSV, Markdown, Table)
16
17
  - Short-form and detailed organizational views
17
18
  - Root profile discovery and listing
18
19
  - Account hierarchy visualization
19
20
 
21
+ Architecture (v1.1.10):
22
+ - Group-level with --all-profiles pattern (Option B)
23
+ - Shared utilities integration (organizations_utils.py + output_formatters.py)
24
+ - Modern CLI + Legacy Python Main dual compatibility
25
+ - Rich CLI output with enterprise UX standards
26
+
20
27
  Compatibility:
21
28
  - AWS Organizations with cross-account roles
22
29
  - AWS Control Tower managed accounts
23
30
  - Multiple AWS Organizations access
24
31
  - AWS Account Factory provisioned accounts
32
+ - Single-account fallback for non-Organizations environments
25
33
 
26
- Example:
27
- Discover all accounts across organizations:
34
+ Example (Modern CLI):
35
+ Multi-account Organizations discovery:
28
36
  ```bash
29
- python org_list_accounts.py --profile my-org-profile
37
+ runbooks inventory --all-profiles $MANAGEMENT_PROFILE list-org-accounts
30
38
  ```
31
39
 
32
- Short form listing for quick overview:
40
+ Brief listing with timing:
33
41
  ```bash
34
- python org_list_accounts.py --profile my-profile --short
42
+ runbooks inventory --profile mgmt list-org-accounts --short --timing
35
43
  ```
36
44
 
37
- Find which organization contains specific accounts:
45
+ Find specific accounts across organizations:
38
46
  ```bash
39
- python org_list_accounts.py --acct 123456789012 987654321098
47
+ runbooks inventory --all-profiles mgmt list-org-accounts --acct 123456789012 987654321098
40
48
  ```
41
49
 
42
- List only root profiles:
50
+ Example (Legacy Python Main):
43
51
  ```bash
44
- python org_list_accounts.py --rootonly
52
+ python src/runbooks/inventory/list_org_accounts.py --profile my-org-profile
53
+ python src/runbooks/inventory/list_org_accounts.py --profile my-profile --short
54
+ python src/runbooks/inventory/list_org_accounts.py --acct 123456789012 987654321098
55
+ python src/runbooks/inventory/list_org_accounts.py --rootonly
45
56
  ```
46
57
 
47
58
  Use Cases:
@@ -60,30 +71,231 @@ Author:
60
71
  AWS Cloud Foundations Team
61
72
 
62
73
  Version:
63
- 2024.05.08
74
+ 1.1.10 (v1.1.10 parameter patterns + shared utilities)
64
75
  """
65
76
 
66
77
  import logging
67
78
  import sys
79
+ import json
68
80
  from os.path import split
69
81
  from time import time
82
+ from typing import Dict, List, Optional
83
+
84
+ from runbooks.inventory.ArgumentsClass import CommonArguments
85
+ from runbooks.inventory.organizations_utils import discover_organization_accounts
86
+ from runbooks.inventory.output_formatters import OrganizationsFormatter, export_to_file
87
+ from runbooks.common.rich_utils import (
88
+ console,
89
+ print_header,
90
+ print_success,
91
+ print_error,
92
+ print_warning,
93
+ print_info,
94
+ create_table,
95
+ )
96
+ from runbooks.common.config_loader import get_config_loader
97
+ from runbooks import __version__
98
+
99
+ logger = logging.getLogger(__name__)
70
100
 
71
- from ArgumentsClass import CommonArguments
101
+ begin_time = time()
72
102
 
73
- # from botocore.exceptions import ClientError, NoCredentialsError, InvalidConfigError
74
- from runbooks.common.rich_utils import console
75
- from Inventory_Modules import display_results, get_org_accounts_from_profiles, get_profiles
76
103
 
77
- __version__ = "2024.05.08"
78
- begin_time = time()
104
+ ##################
105
+ # Core Functions
106
+ ##################
107
+ def list_organization_accounts(
108
+ profiles: List[str],
109
+ short_form: bool = False,
110
+ root_only: bool = False,
111
+ account_lookup: Optional[List[str]] = None,
112
+ export_format: str = "table",
113
+ output_file: Optional[str] = None,
114
+ skip_profiles: Optional[List[str]] = None,
115
+ verbose: int = logging.ERROR,
116
+ ) -> Dict:
117
+ """
118
+ List all accounts in AWS Organizations with multi-profile support.
119
+
120
+ This function serves both Modern CLI and legacy Python Main modes,
121
+ implementing the Group-Level with --all-profiles pattern (Option B).
122
+
123
+ Args:
124
+ profiles: List of AWS profiles to query (Organizations management accounts)
125
+ short_form: Show only profile-level info, skip child accounts (performance optimization)
126
+ root_only: Show only management accounts (governance focus)
127
+ account_lookup: Specific account IDs to find across organizations (cross-org search)
128
+ export_format: Output format (json, csv, markdown, table)
129
+ output_file: Output filename (None = console only)
130
+ skip_profiles: Profiles to exclude from discovery
131
+ verbose: Logging level (logging.ERROR, WARNING, INFO, DEBUG)
132
+
133
+ Returns:
134
+ Dictionary containing:
135
+ - OrgsFound: List of management account IDs discovered
136
+ - AccountList: Complete account inventory with metadata
137
+ - ClosedAccounts: Suspended/closed account IDs
138
+ - FailedProfiles: Profiles that failed authentication/access
139
+ - StandAloneAccounts: Non-organizational standalone accounts
140
+
141
+ Architecture:
142
+ - Uses organizations_utils.discover_organization_accounts() for AWS API integration
143
+ - Uses output_formatters.OrganizationsFormatter() for multi-format export
144
+ - Graceful fallback to single-account mode when Organizations unavailable
145
+ - Profile-level caching to avoid redundant API calls
146
+ """
147
+ # Configure logging
148
+ logger.setLevel(verbose)
149
+
150
+ # Print header
151
+ print_header("Organizations Account Inventory", __version__)
152
+
153
+ # Filter profiles
154
+ active_profiles = [p for p in profiles if not skip_profiles or p not in skip_profiles]
155
+
156
+ if skip_profiles:
157
+ excluded_count = len(profiles) - len(active_profiles)
158
+ print_info(f"Excluding {excluded_count} profile(s): {', '.join(skip_profiles)}")
159
+
160
+ print_info(f"Scanning {len(active_profiles)} profile(s) for Organizations membership")
161
+
162
+ # Account discovery across profiles
163
+ all_accounts = []
164
+ orgs_found = set()
165
+ failed_profiles = []
166
+ profile_errors = {}
167
+
168
+ for profile in active_profiles:
169
+ try:
170
+ logger.info(f"Discovering accounts for profile: {profile}")
171
+
172
+ # Use shared utility for Organizations API discovery
173
+ accounts, error_msg = discover_organization_accounts(profile, region="us-east-1")
79
174
 
175
+ if error_msg:
176
+ # Fallback mode - single account
177
+ logger.warning(f"Profile {profile} fallback mode: {error_msg}")
178
+ profile_errors[profile] = error_msg
80
179
 
81
- # TODO: If they provide a profile that isn't a root profile, you should find out which org it belongs to, and then show the org for that.
82
- # This will be difficult, since we don't know which profile that belongs to. Hmmm...
180
+ if accounts:
181
+ # Track management accounts
182
+ mgmt_accounts = [acc for acc in accounts if acc.get("is_management_account", False)]
183
+ for mgmt_acc in mgmt_accounts:
184
+ orgs_found.add(mgmt_acc["id"])
185
+
186
+ # Add profile metadata to each account
187
+ for account in accounts:
188
+ account["discovery_profile"] = profile
189
+
190
+ all_accounts.extend(accounts)
191
+ print_success(f"Profile '{profile}': {len(accounts)} accounts discovered")
192
+ else:
193
+ failed_profiles.append(profile)
194
+ print_error(f"Profile '{profile}': Discovery failed")
195
+
196
+ except Exception as e:
197
+ error_msg = str(e)
198
+ logger.error(f"Profile {profile} failed: {error_msg}", exc_info=True)
199
+ failed_profiles.append(profile)
200
+ profile_errors[profile] = error_msg
201
+ print_error(f"Profile '{profile}': {error_msg}")
202
+
203
+ # Summary statistics
204
+ console.print()
205
+ console.print("[cyan]📊 Discovery Summary:[/cyan]")
206
+ console.print(f"Organizations found: {len(orgs_found)}")
207
+ console.print(f"Total accounts: {len(all_accounts)}")
208
+
209
+ # Status breakdown
210
+ active_count = sum(1 for acc in all_accounts if acc.get("status") == "ACTIVE")
211
+ suspended_count = sum(1 for acc in all_accounts if acc.get("status") == "SUSPENDED")
212
+ closed_count = sum(1 for acc in all_accounts if acc.get("status") == "CLOSED")
213
+
214
+ console.print(f" - Active: {active_count}")
215
+ if suspended_count > 0:
216
+ console.print(f" - Suspended: {suspended_count}")
217
+ if closed_count > 0:
218
+ console.print(f" - Closed: {closed_count}")
219
+
220
+ if failed_profiles:
221
+ console.print(f"[yellow]Failed profiles: {len(failed_profiles)}[/yellow]")
222
+ console.print()
223
+
224
+ # Apply filters
225
+ if root_only:
226
+ all_accounts = [acc for acc in all_accounts if acc.get("is_management_account", False)]
227
+ print_info(f"Root-only filter: {len(all_accounts)} management accounts")
228
+
229
+ # Account lookup (cross-organization search)
230
+ if account_lookup:
231
+ found_accounts = [acc for acc in all_accounts if acc["id"] in account_lookup]
232
+
233
+ console.print("[cyan]🔍 Account Lookup Results:[/cyan]")
234
+ if found_accounts:
235
+ for acc in found_accounts:
236
+ org_id = acc.get("parent_org", "N/A")
237
+ console.print(
238
+ f" Account: [bold]{acc['id']}[/bold] | "
239
+ f"Name: {acc.get('name', 'N/A')} | "
240
+ f"Org: {org_id} | "
241
+ f"Status: {acc['status']} | "
242
+ f"Profile: {acc.get('profile', 'N/A')}"
243
+ )
244
+ else:
245
+ console.print(f"[yellow] No accounts found matching IDs: {', '.join(account_lookup)}[/yellow]")
246
+ console.print()
247
+
248
+ # Output formatting
249
+ formatter = OrganizationsFormatter()
250
+
251
+ if export_format == "table":
252
+ # Rich table for console display
253
+ table = formatter.format_accounts_table(all_accounts, title="AWS Organization Accounts")
254
+ console.print(table)
255
+
256
+ elif export_format == "json":
257
+ # JSON export with metadata
258
+ output_filename = output_file or "organizations_accounts.json"
259
+ metadata = {
260
+ "organizations_count": len(orgs_found),
261
+ "total_accounts": len(all_accounts),
262
+ "discovery_profiles": active_profiles,
263
+ "failed_profiles": failed_profiles,
264
+ }
265
+ formatter.export_json(all_accounts, output_filename, metadata=metadata)
266
+
267
+ elif export_format == "csv":
268
+ # CSV export
269
+ output_filename = output_file or "organizations_accounts.csv"
270
+ formatter.export_csv(all_accounts, output_filename)
271
+
272
+ elif export_format == "markdown":
273
+ # Markdown export
274
+ output_filename = output_file or "organizations_accounts.md"
275
+ formatter.export_markdown(all_accounts, output_filename, title="AWS Organization Accounts")
276
+
277
+ # Closed/suspended account tracking
278
+ closed_accounts = [acc["id"] for acc in all_accounts if acc["status"] != "ACTIVE"]
279
+
280
+ # Standalone account detection (accounts with no parent org)
281
+ standalone_accounts = [
282
+ acc["id"]
283
+ for acc in all_accounts
284
+ if acc.get("email") == "N/A" and acc.get("id") == acc.get("parent_org")
285
+ ]
286
+
287
+ return {
288
+ "OrgsFound": list(orgs_found),
289
+ "AccountList": all_accounts,
290
+ "ClosedAccounts": closed_accounts,
291
+ "FailedProfiles": failed_profiles,
292
+ "StandAloneAccounts": standalone_accounts,
293
+ "ProfileErrors": profile_errors,
294
+ }
83
295
 
84
296
 
85
297
  ##################
86
- # Functions
298
+ # Legacy Python Main Entry Point
87
299
  ##################
88
300
  def parse_args(f_arguments):
89
301
  """
@@ -103,7 +315,7 @@ def parse_args(f_arguments):
103
315
  - pShortform: Brief output format (profiles only, not child accounts)
104
316
  - accountList: Specific account IDs to lookup across organizations
105
317
  - SkipProfiles: Profiles to exclude from discovery
106
- - Filename: Output file prefix for CSV export
318
+ - Filename: Output file prefix for export
107
319
  - Time: Enable execution timing measurements
108
320
  - Other standard framework arguments
109
321
 
@@ -137,7 +349,7 @@ def parse_args(f_arguments):
137
349
  # Add execution timing capabilities
138
350
  parser.timing()
139
351
 
140
- # Enable CSV file export functionality
352
+ # Enable file export functionality
141
353
  parser.save_to_file()
142
354
 
143
355
  # Configure logging verbosity levels
@@ -166,279 +378,97 @@ def parse_args(f_arguments):
166
378
  "-A", "--acct", help="Find which Org this account is a part of", nargs="*", dest="accountList", default=None
167
379
  )
168
380
 
169
- return parser.my_parser.parse_args(f_arguments)
170
-
171
-
172
- def all_my_orgs(
173
- f_Profiles: list,
174
- f_SkipProfiles: list,
175
- f_AccountList: list,
176
- f_Timing: bool,
177
- f_RootOnly: bool,
178
- f_SaveFilename: str,
179
- f_Shortform: bool,
180
- f_verbose,
181
- ):
182
- """
183
- Execute comprehensive AWS Organizations discovery across multiple management accounts.
184
-
185
- This is the core orchestration function that discovers and maps AWS Organizations
186
- hierarchies, identifies management accounts, enumerates child accounts, and provides
187
- detailed organizational visibility across multiple AWS Organizations.
188
-
189
- Args:
190
- f_Profiles (list): AWS profiles to analyze for organization membership
191
- f_SkipProfiles (list): Profiles to exclude from discovery process
192
- f_AccountList (list): Specific account IDs to lookup across organizations
193
- f_Timing (bool): Enable execution timing measurements and display
194
- f_RootOnly (bool): Limit output to Management Accounts only
195
- f_SaveFilename (str): Output file prefix for CSV export (None for console only)
196
- f_Shortform (bool): Brief output format excluding child account details
197
- f_verbose: Logging verbosity level for detailed operational logging
198
-
199
- Returns:
200
- dict: Comprehensive organizational data containing:
201
- - OrgsFound: List of Management Account IDs discovered
202
- - StandAloneAccounts: Non-organizational standalone accounts
203
- - ClosedAccounts: Suspended or closed account IDs
204
- - FailedProfiles: Profiles that failed authentication/access
205
- - AccountList: Complete account inventory with metadata
206
-
207
- Processing Workflow:
208
- 1. Profile Resolution: Convert profile names to validated credential sets
209
- 2. Organization Discovery: Query each profile for organizational context
210
- 3. Account Enumeration: List all child accounts for Management Accounts
211
- 4. Status Analysis: Identify suspended, closed, or problematic accounts
212
- 5. Output Generation: Format results for console display or CSV export
213
- 6. Cross-Reference Lookup: Match requested account IDs to organizations
214
-
215
- Output Formats:
216
- - Console Mode: Formatted tables with colored output for status highlighting
217
- - CSV Mode: Pipe-delimited files suitable for data analysis and reporting
218
- - Short Mode: Profile-level summary without child account enumeration
219
- - Lookup Mode: Targeted account-to-organization mapping
220
-
221
- Error Handling:
222
- - Profile failures are logged and tracked but don't stop processing
223
- - Authentication errors are captured with detailed error messages
224
- - Missing organizations are handled gracefully with fallback behavior
225
- - API rate limits and throttling are managed through sequential processing
226
-
227
- Performance Considerations:
228
- - Sequential profile processing (no threading due to AWS API complexity)
229
- - Cached organization data to avoid redundant API calls
230
- - Progress indicators for long-running discovery operations
231
- - Memory-efficient handling of large organizational hierarchies
232
-
233
- Security Features:
234
- - Read-only operations with minimal required permissions
235
- - No credential storage or caching beyond execution scope
236
- - Audit trail through comprehensive logging
237
- - Safe handling of cross-account access patterns
238
- """
239
- ProfileList = get_profiles(fSkipProfiles=f_SkipProfiles, fprofiles=f_Profiles)
240
- # print("Capturing info for supplied profiles")
241
- logging.info(f"These profiles were requested {f_Profiles}.")
242
- logging.warning(f"These profiles are being checked {ProfileList}.")
243
- print(f"Please bear with us as we run through {len(ProfileList)} profiles")
244
- AllProfileAccounts = get_org_accounts_from_profiles(ProfileList)
245
- AccountList = []
246
- FailedProfiles = []
247
- OrgsFound = []
248
-
249
- # Print out the results
250
- if f_Timing:
251
- print()
252
- print(f"It's taken [green]{time() - begin_time:.2f} seconds to find profile accounts...")
253
- print()
254
- fmt = "%-23s %-15s %-15s %-12s %-10s"
255
- print("<------------------------------------>")
256
- print(fmt % ("Profile Name", "Account Number", "Payer Org Acct", "Org ID", "Root Acct?"))
257
- print(fmt % ("------------", "--------------", "--------------", "------", "----------"))
258
-
259
- for item in AllProfileAccounts:
260
- if not item["Success"]:
261
- # If the profile failed, don't print anything and continue on.
262
- FailedProfiles.append(item["profile"])
263
- logging.error(f"{item['profile']} errored. Message: {item['ErrorMessage']}")
264
- else:
265
- if item["RootAcct"]:
266
- # If the account is a root account, capture it for display later
267
- OrgsFound.append(item["MgmtAccount"])
268
- # Print results for all profiles
269
- item["AccountId"] = item["aws_acct"].acct_number
270
- item["AccountStatus"] = item["aws_acct"].AccountStatus
271
- # item['AccountEmail'] = item['aws_acct'].
272
- try:
273
- if f_RootOnly and not item["RootAcct"]:
274
- # If we're only looking for root accounts, and this isn't one, don't print anything and continue on.
275
- continue
276
- else:
277
- logging.info(f"{item['profile']} was successful.")
278
- print(
279
- f"{Fore.RED if item['RootAcct'] else ''}{item['profile']:23s} {item['aws_acct'].acct_number:15s} {item['MgmtAccount']:15s} {str(item['OrgId']):12s} {item['RootAcct']}"
280
- )
281
- except TypeError as my_Error:
282
- logging.error(f"Error - {my_Error} on {item}")
283
- pass
284
-
285
- """
286
- If I create a dictionary from the Root Accts and Root Profiles Lists -
287
- I can use that to determine which profile belongs to the root user of my (child) account.
288
- But this dictionary is only guaranteed to be valid after ALL profiles have been checked,
289
- so... it doesn't solve our issue - unless we don't write anything to the screen until *everything* is done,
290
- and we keep all output in another dictionary - where we can populate the missing data at the end...
291
- but that takes a long time, since nothing would be sent to the screen in the meantime.
292
- """
293
-
294
- print(ERASE_LINE)
295
- print("-------------------")
296
-
297
- if f_Shortform:
298
- # The user specified "short-form" which means they don't want any information on child accounts.
299
- return_response = {
300
- "OrgsFound": OrgsFound,
301
- "FailedProfiles": FailedProfiles,
302
- "AllProfileAccounts": AllProfileAccounts,
303
- }
304
- else:
305
- NumOfOrgAccounts = 0
306
- ClosedAccounts = []
307
- FailedAccounts = 0
308
- account = dict()
309
- ProfileNameLength = len("Organization's Profile")
310
-
311
- for item in AllProfileAccounts:
312
- # AllProfileAccounts holds the list of account class objects of the accounts associated with the profiles it found.
313
- if item["Success"] and not item["RootAcct"]:
314
- account.update(item["aws_acct"].ChildAccounts[0])
315
- account.update({"Profile": item["profile"]})
316
- AccountList.append(account.copy())
317
- elif item["Success"] and item["RootAcct"]:
318
- for child_acct in item["aws_acct"].ChildAccounts:
319
- account.update(child_acct)
320
- account.update({"Profile": item["profile"]})
321
- ProfileNameLength = max(len(item["profile"]), ProfileNameLength)
322
- AccountList.append(account.copy())
323
- if not child_acct["AccountStatus"] == "ACTIVE":
324
- ClosedAccounts.append(child_acct["AccountId"])
325
-
326
- NumOfOrgAccounts += len(item["aws_acct"].ChildAccounts)
327
- elif not item["Success"]:
328
- FailedAccounts += 1
329
- continue
330
-
331
- # Display results on screen
332
- if f_SaveFilename is None:
333
- fmt = "%-23s %-15s"
334
- print()
335
- print(fmt % ("Organization's Profile", "Root Account"))
336
- print(fmt % ("----------------------", "------------"))
337
- for item in AllProfileAccounts:
338
- if item["Success"] and item["RootAcct"]:
339
- print(
340
- f"{item['profile']:{ProfileNameLength}s} [bold]{item['MgmtAccount']:15s}"
341
- )
342
- print(
343
- f"\t{'Child Account Number':{len('Child Account Number')}s} {'Child Account Status':{len('Child Account Status')}s} {'Child Email Address'}"
344
- )
345
- for child_acct in item["aws_acct"].ChildAccounts:
346
- print(
347
- f"\t{Fore.RED if not child_acct['AccountStatus'] == 'ACTIVE' else ''}{child_acct['AccountId']:{len('Child Account Number')}s} {child_acct['AccountStatus']:{len('Child Account Status')}s} {child_acct['AccountEmail']}"
348
- )
349
-
350
- elif f_SaveFilename is not None:
351
- # The user specified a file name, which means they want a (pipe-delimited) CSV file with the relevant output.
352
- display_dict = {
353
- "MgmtAccount": {"DisplayOrder": 1, "Heading": "Parent Acct"},
354
- "AccountId": {"DisplayOrder": 2, "Heading": "Account Number"},
355
- "AccountStatus": {"DisplayOrder": 3, "Heading": "Account Status", "Condition": ["SUSPENDED", "CLOSED"]},
356
- "AccountEmail": {"DisplayOrder": 4, "Heading": "Email"},
357
- }
358
- if pRootOnly:
359
- sorted_Results = sorted(AllProfileAccounts, key=lambda d: (d["MgmtAccount"], d["AccountId"]))
360
- else:
361
- sorted_Results = sorted(AccountList, key=lambda d: (d["MgmtAccount"], d["AccountId"]))
362
- display_results(sorted_Results, display_dict, "None", f_SaveFilename)
363
-
364
- StandAloneAccounts = [
365
- x["AccountId"]
366
- for x in AccountList
367
- if x["MgmtAccount"] == x["AccountId"] and x["AccountEmail"] == "Not an Org Management Account"
368
- ]
369
- FailedProfiles = [i["profile"] for i in AllProfileAccounts if not i["Success"]]
370
- OrgsFound = [i["MgmtAccount"] for i in AllProfileAccounts if i["RootAcct"]]
371
- StandAloneAccounts.sort()
372
- FailedProfiles.sort()
373
- OrgsFound.sort()
374
- ClosedAccounts.sort()
375
-
376
- print()
377
- print(f"Number of Organizations: {len(OrgsFound)}")
378
- print(f"Number of Organization Accounts: {NumOfOrgAccounts}")
379
- print(f"Number of Standalone Accounts: {len(StandAloneAccounts)}")
380
- print(f"Number of suspended or closed accounts: {len(ClosedAccounts)}")
381
- print(f"Number of profiles that failed: {len(FailedProfiles)}")
382
- if f_verbose < 50:
383
- print("----------------------")
384
- print(f"The following accounts are the Org Accounts: {OrgsFound}")
385
- print(f"The following accounts are Standalone: {StandAloneAccounts}") if len(
386
- StandAloneAccounts
387
- ) > 0 else None
388
- print(f"The following accounts are closed or suspended: {ClosedAccounts}") if len(
389
- ClosedAccounts
390
- ) > 0 else None
391
- print(f"The following profiles failed: {FailedProfiles}") if len(FailedProfiles) > 0 else None
392
- print("----------------------")
393
- print()
394
- return_response = {
395
- "OrgsFound": OrgsFound,
396
- "StandAloneAccounts": StandAloneAccounts,
397
- "ClosedAccounts": ClosedAccounts,
398
- "FailedProfiles": FailedProfiles,
399
- "AccountList": AccountList,
400
- }
401
-
402
- if f_AccountList is not None:
403
- print(f"Found the requested account number{'' if len(AccountList) == 1 else 's'}:")
404
- for acct in AccountList:
405
- if acct["AccountId"] in f_AccountList:
406
- print(
407
- f"Profile: {acct['Profile']} | Org: {acct['MgmtAccount']} | Account: {acct['AccountId']} | Status: {acct['AccountStatus']} | Email: {acct['AccountEmail']}"
408
- )
381
+ # Tag mappings configuration (v1.1.10 config-aware feature)
382
+ local.add_argument(
383
+ "--tag-mappings",
384
+ type=str,
385
+ default=None,
386
+ dest="tagMappings",
387
+ help='JSON string mapping field names to AWS tag keys. '
388
+ 'Example: \'{"wbs_code": "ProjectCode", "cost_group": "BillingGroup"}\'. '
389
+ 'Overrides hierarchical config (user/project/env defaults).',
390
+ )
409
391
 
410
- return return_response
392
+ return parser.my_parser.parse_args(f_arguments)
411
393
 
412
394
 
413
395
  ##################
414
396
  # Main
415
397
  ##################
416
-
417
398
  if __name__ == "__main__":
418
399
  args = parse_args(sys.argv[1:])
419
400
 
401
+ # Extract arguments
420
402
  pProfiles = args.Profiles
421
403
  pRootOnly = args.RootOnly
422
404
  pTiming = args.Time
423
- pSkipAccounts = args.SkipAccounts
424
405
  pSkipProfiles = args.SkipProfiles
425
406
  verbose = args.loglevel
426
407
  pSaveFilename = args.Filename
427
408
  pShortform = args.pShortform
428
409
  pAccountList = args.accountList
410
+ pTagMappings = args.tagMappings
411
+
412
+ # Configure logging
429
413
  logging.basicConfig(
430
- level=verbose, format="[%(filename)s:%(lineno)s - %(processName)s %(threadName)s %(funcName)20s() ] %(message)s"
414
+ level=verbose, format="[%(filename)s:%(lineno)s - %(funcName)20s() ] %(message)s"
431
415
  )
432
- logging.getLogger("boto3").setLevel(logging.CRITICAL)
433
- logging.getLogger("botocore").setLevel(logging.CRITICAL)
434
- logging.getLogger("s3transfer").setLevel(logging.CRITICAL)
435
- logging.getLogger("urllib3").setLevel(logging.CRITICAL)
436
416
 
437
- all_my_orgs(pProfiles, pSkipProfiles, pAccountList, pTiming, pRootOnly, pSaveFilename, pShortform, verbose)
417
+ # Suppress AWS SDK noise unless in DEBUG mode
418
+ if verbose > logging.DEBUG:
419
+ for logger_name in ["boto3", "botocore", "s3transfer", "urllib3"]:
420
+ logging.getLogger(logger_name).setLevel(logging.CRITICAL)
421
+
422
+ # Parse CLI tag mappings (if provided)
423
+ cli_tag_overrides = None
424
+ if pTagMappings:
425
+ try:
426
+ cli_tag_overrides = json.loads(pTagMappings)
427
+ logger.info(f"Using CLI tag mapping overrides: {cli_tag_overrides}")
428
+ except json.JSONDecodeError as e:
429
+ console.print(f"[red]Error: Invalid JSON in --tag-mappings: {e}[/red]")
430
+ console.print("[yellow]Example: --tag-mappings '{\"wbs_code\": \"ProjectCode\", \"cost_group\": \"BillingGroup\"}'[/yellow]")
431
+ sys.exit(1)
432
+
433
+ # Load tag mappings with hierarchical precedence
434
+ config_loader = get_config_loader()
435
+ final_tag_mappings = config_loader.load_tag_mappings(cli_overrides=cli_tag_overrides)
436
+
437
+ # Display configuration sources
438
+ config_sources = config_loader.get_config_sources()
439
+ if verbose <= logging.INFO:
440
+ print_info(f"Tag mapping sources: {' → '.join(config_sources)}")
441
+ logger.info(f"Loaded {len(final_tag_mappings)} tag mappings from {len(config_sources)} sources")
442
+
443
+ # Determine export format based on filename
444
+ export_format = "table"
445
+ if pSaveFilename:
446
+ if pSaveFilename.endswith(".json"):
447
+ export_format = "json"
448
+ elif pSaveFilename.endswith(".csv"):
449
+ export_format = "csv"
450
+ elif pSaveFilename.endswith(".md"):
451
+ export_format = "markdown"
452
+ else:
453
+ # Default to CSV if no extension provided
454
+ export_format = "csv"
455
+ pSaveFilename = f"{pSaveFilename}.csv"
456
+
457
+ # Execute discovery
458
+ results = list_organization_accounts(
459
+ profiles=pProfiles,
460
+ short_form=pShortform,
461
+ root_only=pRootOnly,
462
+ account_lookup=pAccountList,
463
+ export_format=export_format,
464
+ output_file=pSaveFilename,
465
+ skip_profiles=pSkipProfiles,
466
+ verbose=verbose,
467
+ )
438
468
 
439
- print()
469
+ # Timing summary
440
470
  if pTiming:
441
- print(f"[green]This script took {time() - begin_time:.2f} seconds")
442
- print()
443
- print("Thanks for using this script")
444
- print()
471
+ elapsed = time() - begin_time
472
+ console.print(f"\n[green]⏱️ Execution time: {elapsed:.2f}s[/green]")
473
+
474
+ console.print("\n[dim]Thanks for using this script[/dim]\n")