tarmac-cli 0.1.3__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 (53) hide show
  1. tarmac/__init__.py +8 -0
  2. tarmac/cli/__init__.py +1 -0
  3. tarmac/cli/account.py +541 -0
  4. tarmac/cli/baseline.py +448 -0
  5. tarmac/cli/common.py +77 -0
  6. tarmac/cli/deploy.py +456 -0
  7. tarmac/cli/deploy_dry_run.py +677 -0
  8. tarmac/cli/deploy_phases.py +626 -0
  9. tarmac/cli/identity.py +80 -0
  10. tarmac/cli/main.py +589 -0
  11. tarmac/cli/org.py +294 -0
  12. tarmac/cli/ou.py +71 -0
  13. tarmac/cli/preflight.py +194 -0
  14. tarmac/cli/ui.py +54 -0
  15. tarmac/core/__init__.py +71 -0
  16. tarmac/core/aws_client.py +114 -0
  17. tarmac/core/config_loader.py +377 -0
  18. tarmac/core/config_schema.py +387 -0
  19. tarmac/core/exceptions.py +43 -0
  20. tarmac/core/logging.py +42 -0
  21. tarmac/core/models.py +191 -0
  22. tarmac/core/tagging.py +127 -0
  23. tarmac/core/templates.py +109 -0
  24. tarmac/py.typed +1 -0
  25. tarmac/services/__init__.py +35 -0
  26. tarmac/services/accounts.py +619 -0
  27. tarmac/services/baseline.py +515 -0
  28. tarmac/services/cloudformation.py +334 -0
  29. tarmac/services/drift.py +493 -0
  30. tarmac/services/identity.py +728 -0
  31. tarmac/services/organization.py +318 -0
  32. tarmac/services/ou.py +115 -0
  33. tarmac/services/preflight.py +583 -0
  34. tarmac/services/scaffold.py +234 -0
  35. tarmac/services/scp.py +440 -0
  36. tarmac/services/security.py +270 -0
  37. tarmac/services/status.py +102 -0
  38. tarmac/services/teardown.py +1317 -0
  39. tarmac/templates/__init__.py +1 -0
  40. tarmac/templates/cloudformation/account-budget.yaml +114 -0
  41. tarmac/templates/cloudformation/audit-account-hardening.yaml +310 -0
  42. tarmac/templates/cloudformation/log-archive-hardening.yaml +287 -0
  43. tarmac/templates/cloudformation/organization-cloudtrail.yaml +112 -0
  44. tarmac/templates/cloudformation/terraform-backend.yaml +141 -0
  45. tarmac/templates/policies/scp/deny-leave-organization.json +13 -0
  46. tarmac/templates/policies/scp/deny-member-root.json +19 -0
  47. tarmac/templates/policies/scp/foundational-guardrails.json +27 -0
  48. tarmac_cli-0.1.3.dist-info/METADATA +204 -0
  49. tarmac_cli-0.1.3.dist-info/RECORD +53 -0
  50. tarmac_cli-0.1.3.dist-info/WHEEL +5 -0
  51. tarmac_cli-0.1.3.dist-info/entry_points.txt +3 -0
  52. tarmac_cli-0.1.3.dist-info/licenses/LICENSE +176 -0
  53. tarmac_cli-0.1.3.dist-info/top_level.txt +1 -0
tarmac/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """AWS Governance CLI and Multi-Account Landing Zone Automation."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("tarmac-cli")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.1.2"
tarmac/cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """CLI package for AWS Governance."""
tarmac/cli/account.py ADDED
@@ -0,0 +1,541 @@
1
+ """CLI commands for the AWS Account Factory."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import typer
7
+ from rich.panel import Panel
8
+ from rich.table import Table
9
+
10
+ from ..core.aws_client import AwsSessionManager
11
+ from ..core.config_loader import normalize_account_name
12
+ from ..core.config_schema import AccountDef
13
+ from ..core.exceptions import GovernanceError
14
+ from ..core.models import BaselineReport
15
+ from ..services.accounts import AccountService
16
+ from ..services.baseline import BaselineService
17
+ from ..services.ou import OUService
18
+ from .common import load_configs_or_exit
19
+ from .ui import console, create_table, error_console, print_header, print_info, print_success
20
+
21
+ app = typer.Typer(help="Declarative AWS Account Factory commands.")
22
+
23
+
24
+ @app.command("plan")
25
+ def plan_accounts(
26
+ config_dir: Path | None = typer.Option(None, "--config-dir", "-c", help="Path to config directory"),
27
+ region: str | None = typer.Option(None, "--region", "-r", help="Override primary AWS region"),
28
+ json_output: bool = typer.Option(False, "--json", help="Output results in JSON format"),
29
+ ):
30
+ """Generate and display the account provisioning plan."""
31
+ bundle = load_configs_or_exit(config_dir)
32
+ target_region = region or bundle.org.organization.primary_region
33
+
34
+ try:
35
+ session_mgr = AwsSessionManager(region_name=target_region)
36
+ ou_service = OUService(session_mgr)
37
+ service = AccountService(session_mgr)
38
+
39
+ root_id = ou_service.get_root_id()
40
+ ou_map = ou_service.list_all_ous(root_id)
41
+ plan_items = service.plan(bundle.accounts, ou_map)
42
+
43
+ if json_output:
44
+ import json
45
+
46
+ plan_dicts = [
47
+ {
48
+ "name": item.name,
49
+ "email": item.email,
50
+ "ou": item.ou,
51
+ "action": item.action,
52
+ "account_id": item.account_id,
53
+ "reason": item.reason,
54
+ "baseline": item.baseline.model_dump(),
55
+ }
56
+ for item in plan_items
57
+ ]
58
+ print(json.dumps(plan_dicts, indent=2))
59
+ return
60
+
61
+ print_header("Account Factory: Execution Plan")
62
+ table = create_table(title="Account Factory Plan")
63
+ table.add_column("Account Name", style="cyan")
64
+ table.add_column("Email", style="white")
65
+ table.add_column("Target OU", style="magenta")
66
+ table.add_column("Action", style="bold")
67
+ table.add_column("Details", style="dim")
68
+
69
+ for item in plan_items:
70
+ if item.action == "CREATE":
71
+ action_style = "green"
72
+ elif item.action == "MOVE":
73
+ action_style = "yellow"
74
+ elif item.action == "SUSPEND":
75
+ action_style = "bold red"
76
+ elif item.action == "SUSPENDED":
77
+ action_style = "magenta"
78
+ else:
79
+ action_style = "dim"
80
+
81
+ table.add_row(
82
+ item.name,
83
+ item.email,
84
+ item.ou,
85
+ f"[{action_style}]{item.action}[/{action_style}]",
86
+ item.reason,
87
+ )
88
+
89
+ console.print(table)
90
+ suspended_items = [i for i in plan_items if i.action == "SUSPENDED"]
91
+ if suspended_items:
92
+ print_info(
93
+ f"Discovered {len(suspended_items)} suspended or quarantined account(s) in AWS Organizations."
94
+ )
95
+ except GovernanceError as e:
96
+ error_console.print(
97
+ Panel(str(e), title="[bold red]Account Plan Failed[/bold red]", border_style="red")
98
+ )
99
+ raise typer.Exit(code=1)
100
+
101
+
102
+ def _provision_declared_accounts(
103
+ bundle: Any,
104
+ service: AccountService,
105
+ baseliner: BaselineService,
106
+ ou_map: dict[str, str],
107
+ target_region: str,
108
+ dry_run: bool,
109
+ skip_baseline: bool,
110
+ results_table: Table,
111
+ ) -> None:
112
+ results = service.provision_accounts(bundle.accounts.accounts, ou_map=ou_map, dry_run=dry_run)
113
+ acct_def_map = {a.name: a for a in bundle.accounts.accounts}
114
+
115
+ for result in results:
116
+ status_style = "green" if result.status == "SUCCEEDED" else "dim"
117
+ results_table.add_row(
118
+ result.name,
119
+ result.account_id or "-",
120
+ result.ou_name,
121
+ result.action_taken,
122
+ f"[{status_style}]{result.status}[/{status_style}]",
123
+ )
124
+
125
+ acct_def = acct_def_map.get(result.name)
126
+ if (
127
+ acct_def
128
+ and result.account_id
129
+ and result.account_id != "DRY-RUN-ID"
130
+ and not skip_baseline
131
+ and not dry_run
132
+ ):
133
+ baseline_report = baseliner.baseline_account(
134
+ acct_def,
135
+ result.account_id,
136
+ regions=None,
137
+ dry_run=dry_run,
138
+ org_tags=bundle.get_organization_tags(),
139
+ )
140
+ _print_baseline_summary(baseline_report)
141
+
142
+ suspended_ou_id = ou_map.get("Suspended")
143
+ plan_items = service.plan(bundle.accounts, ou_map)
144
+ for item in plan_items:
145
+ if item.action == "SUSPEND" and item.account_id:
146
+ sus_res = service.suspend_account(
147
+ account_id=item.account_id,
148
+ account_name=item.name,
149
+ suspended_ou_id=suspended_ou_id,
150
+ dry_run=dry_run,
151
+ )
152
+ status_style = "yellow" if sus_res.status == "SUCCEEDED" else "dim"
153
+ results_table.add_row(
154
+ sus_res.name,
155
+ sus_res.account_id,
156
+ "Suspended",
157
+ "SUSPEND",
158
+ f"[{status_style}]{sus_res.status}[/{status_style}]",
159
+ )
160
+
161
+
162
+ @app.command("apply")
163
+ def apply_accounts(
164
+ config_dir: Path | None = typer.Option(None, "--config-dir", "-c", help="Path to config directory"),
165
+ region: str | None = typer.Option(None, "--region", "-r", help="Override primary AWS region"),
166
+ dry_run: bool = typer.Option(False, "--dry-run", help="Simulate provisioning without making changes"),
167
+ skip_baseline: bool = typer.Option(False, "--skip-baseline", help="Skip running post-creation baselines"),
168
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt for automated run"),
169
+ ):
170
+ """Provision declared accounts and place them into target OUs."""
171
+ if not _confirm_action("Are you sure you want to provision/move accounts in AWS?", yes, dry_run):
172
+ print_info("Account provisioning cancelled by user.")
173
+ raise typer.Exit(code=0)
174
+
175
+ print_header("Account Factory: Provisioning Accounts")
176
+ bundle = load_configs_or_exit(config_dir)
177
+ target_region = region or bundle.org.organization.primary_region
178
+
179
+ try:
180
+ session_mgr = AwsSessionManager(region_name=target_region)
181
+ ou_service = OUService(session_mgr)
182
+ service = AccountService(session_mgr)
183
+ baseliner = BaselineService(session_mgr)
184
+
185
+ ou_report = ou_service.sync_ou_structure(bundle.org.ou_structure)
186
+ ou_map = ou_report.synced_ous
187
+
188
+ results_table = create_table(title="Provisioning Summary")
189
+ results_table.add_column("Account Name", style="cyan")
190
+ results_table.add_column("Account ID", style="green")
191
+ results_table.add_column("OU", style="magenta")
192
+ results_table.add_column("Action", style="yellow")
193
+ results_table.add_column("Status", style="bold")
194
+
195
+ _provision_declared_accounts(
196
+ bundle, service, baseliner, ou_map, target_region, dry_run, skip_baseline, results_table
197
+ )
198
+
199
+ console.print(results_table)
200
+ project_root = bundle.config_dir.parent
201
+ output_dir = project_root / "outputs"
202
+ export_path = service.export_inventory(bundle.accounts, ou_map, output_dir)
203
+ print_success(f"Updated inventory export at [cyan]{export_path}[/cyan]")
204
+
205
+ except GovernanceError as e:
206
+ error_console.print(
207
+ Panel(str(e), title="[bold red]Account Provisioning Failed[/bold red]", border_style="red")
208
+ )
209
+ raise typer.Exit(code=1)
210
+
211
+
212
+ def _get_tag_sync_status(dry_run: bool, updated: bool) -> str:
213
+ """Format status label for tag sync."""
214
+ if dry_run and updated:
215
+ return "[yellow]WOULD_UPDATE[/yellow]"
216
+ return "[green]UPDATED[/green]" if updated else "[dim]IN_SYNC[/dim]"
217
+
218
+
219
+ @app.command("sync-tags")
220
+ def sync_account_tags_cmd(
221
+ account_name: str | None = typer.Argument(None, help="Specific account name to sync tags for, or all"),
222
+ config_dir: Path | None = typer.Option(None, "--config-dir", "-c", help="Path to config directory"),
223
+ region: str | None = typer.Option(None, "--region", "-r", help="Override primary AWS region"),
224
+ dry_run: bool = typer.Option(False, "--dry-run", help="Simulate tag sync without making changes"),
225
+ ) -> None:
226
+ """Synchronize declared tags with AWS Organizations member accounts."""
227
+ print_header("Account Factory: Sync Account Tags")
228
+ bundle = load_configs_or_exit(config_dir)
229
+ target_region = region or bundle.org.organization.primary_region
230
+
231
+ try:
232
+ session_mgr = AwsSessionManager(region_name=target_region)
233
+ service = AccountService(session_mgr)
234
+ existing = service.list_existing_accounts()
235
+
236
+ table = create_table(title="Account Tag Sync")
237
+ table.add_column("Account Name", style="cyan")
238
+ table.add_column("Account ID", style="green")
239
+ table.add_column("Declared Tags", style="magenta")
240
+ table.add_column("Status", style="bold")
241
+
242
+ for acct_def in bundle.accounts.accounts:
243
+ if account_name and acct_def.name != account_name:
244
+ continue
245
+
246
+ acct_data = service.find_existing_account(acct_def.name, existing)
247
+ if not acct_data:
248
+ table.add_row(acct_def.name, "-", str(len(acct_def.tags)), "[yellow]NOT_FOUND[/yellow]")
249
+ continue
250
+
251
+ acct_id = str(acct_data["Id"])
252
+ updated = service.sync_account_tags(acct_id, acct_def.name, acct_def.tags, dry_run=dry_run)
253
+ table.add_row(
254
+ acct_def.name,
255
+ acct_id,
256
+ str(len(acct_def.tags)),
257
+ _get_tag_sync_status(dry_run, updated),
258
+ )
259
+
260
+ console.print(table)
261
+ except GovernanceError as e:
262
+ error_console.print(Panel(str(e), title="[bold red]Tag Sync Failed[/bold red]", border_style="red"))
263
+ raise typer.Exit(code=1)
264
+
265
+
266
+ def _baseline_single_account(
267
+ acct_def: AccountDef,
268
+ acct_id: str,
269
+ baseliner: BaselineService,
270
+ service: AccountService,
271
+ dry_run: bool,
272
+ org_tags: dict[str, str] | None = None,
273
+ regions: list[str] | None = None,
274
+ ) -> None:
275
+ """Baseline a single account: sync tags, delete default VPCs across all enabled regions, setup budgets."""
276
+ service.sync_account_tags(acct_id, acct_def.name, acct_def.tags, dry_run=dry_run)
277
+ report = baseliner.baseline_account(
278
+ acct_def, acct_id, regions=regions, dry_run=dry_run, org_tags=org_tags
279
+ )
280
+ _print_baseline_summary(report)
281
+
282
+
283
+ def _confirm_action(prompt: str, yes: bool, dry_run: bool) -> bool:
284
+ """Prompt for user confirmation unless yes or dry_run is set."""
285
+ if dry_run or yes:
286
+ return True
287
+ return bool(typer.confirm(prompt, default=False))
288
+
289
+
290
+ @app.command("baseline")
291
+ def baseline_accounts(
292
+ account_name: str | None = typer.Argument(None, help="Specific account name to baseline, or all"),
293
+ config_dir: Path | None = typer.Option(None, "--config-dir", "-c", help="Path to config directory"),
294
+ region: str | None = typer.Option(None, "--region", "-r", help="Override primary AWS region"),
295
+ dry_run: bool = typer.Option(False, "--dry-run", help="Simulate baselining without making changes"),
296
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt for automated run"),
297
+ ) -> None:
298
+ """Run security and operational baselines (default VPC purge, budgets, anomaly alerts)."""
299
+ msg = "Are you sure you want to baseline accounts (delete default VPCs & setup budgets)?"
300
+ if not _confirm_action(msg, yes, dry_run):
301
+ print_info("Account baselining cancelled by user.")
302
+ raise typer.Exit(code=0)
303
+
304
+ print_header("Account Factory: Baselining")
305
+ bundle = load_configs_or_exit(config_dir)
306
+ target_region = region or bundle.org.organization.primary_region
307
+
308
+ try:
309
+ session_mgr = AwsSessionManager(region_name=target_region)
310
+ service = AccountService(session_mgr)
311
+ baseliner = BaselineService(session_mgr)
312
+
313
+ existing = service.list_existing_accounts()
314
+
315
+ for acct_def in bundle.accounts.accounts:
316
+ if account_name and acct_def.name != account_name:
317
+ continue
318
+
319
+ acct_data = service.find_existing_account(acct_def.name, existing)
320
+ if not acct_data:
321
+ print_info(f"Skipping {acct_def.name}: not present in AWS Organizations.")
322
+ continue
323
+
324
+ acct_id = str(acct_data["Id"])
325
+ _baseline_single_account(
326
+ acct_def,
327
+ acct_id,
328
+ baseliner,
329
+ service,
330
+ dry_run,
331
+ org_tags=bundle.get_organization_tags(),
332
+ regions=None,
333
+ )
334
+
335
+ except GovernanceError as e:
336
+ error_console.print(Panel(str(e), title="[bold red]Baselining Failed[/bold red]", border_style="red"))
337
+ raise typer.Exit(code=1)
338
+
339
+
340
+ @app.command("export")
341
+ def export_inventory(
342
+ config_dir: Path | None = typer.Option(None, "--config-dir", "-c", help="Path to config directory"),
343
+ region: str | None = typer.Option(None, "--region", "-r", help="Override primary AWS region"),
344
+ ):
345
+ """Export current account inventory to JSON and shell environment formats."""
346
+ print_header("Export Account Inventory")
347
+ bundle = load_configs_or_exit(config_dir)
348
+ target_region = region or bundle.org.organization.primary_region
349
+
350
+ try:
351
+ session_mgr = AwsSessionManager(region_name=target_region)
352
+ ou_service = OUService(session_mgr)
353
+ service = AccountService(session_mgr)
354
+
355
+ root_id = ou_service.get_root_id()
356
+ ou_map = ou_service.list_all_ous(root_id)
357
+
358
+ project_root = bundle.config_dir.parent
359
+ output_dir = project_root / "outputs"
360
+ json_path = service.export_inventory(bundle.accounts, ou_map, output_dir)
361
+ print_success(f"Exported account inventory to [cyan]{json_path}[/cyan]")
362
+ except GovernanceError as e:
363
+ error_console.print(Panel(str(e), title="[bold red]Export Failed[/bold red]", border_style="red"))
364
+ raise typer.Exit(code=1)
365
+
366
+
367
+ def _print_baseline_summary(report: BaselineReport) -> None:
368
+ """Helper to render baseline report summaries."""
369
+ for vpc_rep in report.vpc_reports:
370
+ if vpc_rep.deleted_vpc:
371
+ print_success(
372
+ f"[{report.account_name}] Purged default VPC ({vpc_rep.vpc_id}) in {vpc_rep.region}"
373
+ )
374
+ elif not vpc_rep.skipped:
375
+ print_info(f"[{report.account_name}] {vpc_rep.message}")
376
+
377
+ if report.budget_report:
378
+ br = report.budget_report
379
+ status_color = "green" if br.status in ["CREATED", "UPDATED", "EXISTING"] else "yellow"
380
+ console.print(
381
+ f"[{report.account_name}] Budget {br.budget_name}: [{status_color}]{br.status}[/{status_color}] (${br.limit_usd:.2f}/mo)"
382
+ )
383
+
384
+ if report.anomaly_report:
385
+ ar = report.anomaly_report
386
+ status_color = "green" if ar.status in ["CREATED", "EXISTING"] else "yellow"
387
+ console.print(
388
+ f"[{report.account_name}] Anomaly Monitor: [{status_color}]{ar.status}[/{status_color}] (Threshold: ${ar.threshold_usd:.2f})"
389
+ )
390
+
391
+
392
+ @app.command("suspend")
393
+ def suspend_account_cmd(
394
+ account_name_or_id: str = typer.Argument(..., help="Account Name or 12-digit Account ID to suspend"),
395
+ config_dir: Path | None = typer.Option(None, "--config-dir", "-c", help="Path to config directory"),
396
+ region: str | None = typer.Option(None, "--region", "-r", help="Override primary AWS region"),
397
+ close: bool = typer.Option(
398
+ False, "--close", help="Also initiate permanent account closure via CloseAccount API"
399
+ ),
400
+ dry_run: bool = typer.Option(False, "--dry-run", help="Simulate suspension without making changes"),
401
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
402
+ ) -> None:
403
+ """Quarantine an account into the Suspended OU and apply governance tags."""
404
+ action_desc = (
405
+ f"suspend and close account '{account_name_or_id}'"
406
+ if close
407
+ else f"suspend account '{account_name_or_id}' into Suspended OU"
408
+ )
409
+ if not _confirm_action(f"Are you sure you want to {action_desc}?", yes, dry_run):
410
+ print_info("Account suspension cancelled by user.")
411
+ raise typer.Exit(code=0)
412
+
413
+ print_header(f"Account Factory: Suspend Account '{account_name_or_id}'")
414
+ bundle = load_configs_or_exit(config_dir)
415
+ target_region = region or bundle.org.organization.primary_region
416
+
417
+ try:
418
+ session_mgr = AwsSessionManager(region_name=target_region)
419
+ ou_service = OUService(session_mgr)
420
+ service = AccountService(session_mgr)
421
+
422
+ existing = service.list_existing_accounts()
423
+ matched_acct = None
424
+ norm_target = normalize_account_name(account_name_or_id)
425
+ for name, acct in existing.items():
426
+ if normalize_account_name(name) == norm_target or str(acct["Id"]) == account_name_or_id:
427
+ matched_acct = acct
428
+ break
429
+
430
+ if not matched_acct:
431
+ error_console.print(f"[red]✗[/red] Account '{account_name_or_id}' not found in AWS Organization.")
432
+ raise typer.Exit(code=1)
433
+
434
+ root_id = ou_service.get_root_id()
435
+ ou_map = ou_service.list_all_ous(root_id)
436
+ suspended_ou_id = ou_map.get("Suspended")
437
+
438
+ res = service.suspend_account(
439
+ account_id=str(matched_acct["Id"]),
440
+ account_name=str(matched_acct["Name"]),
441
+ suspended_ou_id=suspended_ou_id,
442
+ dry_run=dry_run,
443
+ close=close,
444
+ )
445
+
446
+ print_success(f"Account {res.name} ({res.account_id}): {res.message}")
447
+
448
+ except GovernanceError as e:
449
+ error_console.print(
450
+ Panel(str(e), title="[bold red]Account Suspension Failed[/bold red]", border_style="red")
451
+ )
452
+ raise typer.Exit(code=1)
453
+
454
+
455
+ @app.command("list")
456
+ def list_accounts_cmd(
457
+ config_dir: Path | None = typer.Option(None, "--config-dir", "-c", help="Path to config directory"),
458
+ region: str | None = typer.Option(None, "--region", "-r", help="Override primary AWS region"),
459
+ suspended: bool = typer.Option(
460
+ False, "--suspended", "-s", help="Show only suspended or quarantined accounts"
461
+ ),
462
+ active: bool = typer.Option(False, "--active", "-a", help="Show only active accounts"),
463
+ json_output: bool = typer.Option(False, "--json", help="Output results in JSON format"),
464
+ ) -> None:
465
+ """List all accounts in the AWS Organization with their status and parent OU."""
466
+ import json
467
+
468
+ bundle = load_configs_or_exit(config_dir)
469
+ target_region = region or bundle.org.organization.primary_region
470
+
471
+ try:
472
+ session_mgr = AwsSessionManager(region_name=target_region)
473
+ ou_service = OUService(session_mgr)
474
+ service = AccountService(session_mgr)
475
+
476
+ root_id = ou_service.get_root_id()
477
+ ou_map = ou_service.list_all_ous(root_id)
478
+ ou_id_to_name = {v: k for k, v in ou_map.items()}
479
+ ou_id_to_name[root_id] = "Root"
480
+
481
+ all_accts = service.list_all_accounts()
482
+
483
+ acct_rows = []
484
+ for acct in all_accts:
485
+ acct_id = str(acct["Id"])
486
+ status = str(acct.get("Status", "ACTIVE"))
487
+ parent_id = service.get_account_parent_id(acct_id) or ""
488
+ parent_name = ou_id_to_name.get(parent_id, parent_id or "Unknown")
489
+
490
+ is_suspended = status == "SUSPENDED" or parent_name == "Suspended"
491
+ if suspended and not is_suspended:
492
+ continue
493
+ if active and is_suspended:
494
+ continue
495
+
496
+ acct_rows.append(
497
+ {
498
+ "name": str(acct["Name"]),
499
+ "id": acct_id,
500
+ "email": str(acct.get("Email", "")),
501
+ "status": status,
502
+ "parent_ou": parent_name,
503
+ }
504
+ )
505
+
506
+ if json_output:
507
+ print(json.dumps(acct_rows, indent=2))
508
+ return
509
+
510
+ title_suffix = " (Suspended Only)" if suspended else (" (Active Only)" if active else "")
511
+ print_header(f"AWS Organization Accounts{title_suffix}")
512
+
513
+ table = create_table(title="Accounts Inventory")
514
+ table.add_column("Account Name", style="cyan")
515
+ table.add_column("Account ID", style="green")
516
+ table.add_column("Email", style="white")
517
+ table.add_column("Parent OU", style="magenta")
518
+ table.add_column("Status", style="bold")
519
+
520
+ for row in acct_rows:
521
+ status_style = "green" if row["status"] == "ACTIVE" else "magenta"
522
+ table.add_row(
523
+ row["name"],
524
+ row["id"],
525
+ row["email"],
526
+ row["parent_ou"],
527
+ f"[{status_style}]{row['status']}[/{status_style}]",
528
+ )
529
+
530
+ console.print(table)
531
+ suspended_count = sum(
532
+ 1 for r in acct_rows if r["status"] == "SUSPENDED" or r["parent_ou"] == "Suspended"
533
+ )
534
+ if not suspended and suspended_count > 0:
535
+ print_info(f"Total accounts: {len(acct_rows)} ({suspended_count} suspended / quarantined)")
536
+
537
+ except GovernanceError as e:
538
+ error_console.print(
539
+ Panel(str(e), title="[bold red]List Accounts Failed[/bold red]", border_style="red")
540
+ )
541
+ raise typer.Exit(code=1)