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.
- tarmac/__init__.py +8 -0
- tarmac/cli/__init__.py +1 -0
- tarmac/cli/account.py +541 -0
- tarmac/cli/baseline.py +448 -0
- tarmac/cli/common.py +77 -0
- tarmac/cli/deploy.py +456 -0
- tarmac/cli/deploy_dry_run.py +677 -0
- tarmac/cli/deploy_phases.py +626 -0
- tarmac/cli/identity.py +80 -0
- tarmac/cli/main.py +589 -0
- tarmac/cli/org.py +294 -0
- tarmac/cli/ou.py +71 -0
- tarmac/cli/preflight.py +194 -0
- tarmac/cli/ui.py +54 -0
- tarmac/core/__init__.py +71 -0
- tarmac/core/aws_client.py +114 -0
- tarmac/core/config_loader.py +377 -0
- tarmac/core/config_schema.py +387 -0
- tarmac/core/exceptions.py +43 -0
- tarmac/core/logging.py +42 -0
- tarmac/core/models.py +191 -0
- tarmac/core/tagging.py +127 -0
- tarmac/core/templates.py +109 -0
- tarmac/py.typed +1 -0
- tarmac/services/__init__.py +35 -0
- tarmac/services/accounts.py +619 -0
- tarmac/services/baseline.py +515 -0
- tarmac/services/cloudformation.py +334 -0
- tarmac/services/drift.py +493 -0
- tarmac/services/identity.py +728 -0
- tarmac/services/organization.py +318 -0
- tarmac/services/ou.py +115 -0
- tarmac/services/preflight.py +583 -0
- tarmac/services/scaffold.py +234 -0
- tarmac/services/scp.py +440 -0
- tarmac/services/security.py +270 -0
- tarmac/services/status.py +102 -0
- tarmac/services/teardown.py +1317 -0
- tarmac/templates/__init__.py +1 -0
- tarmac/templates/cloudformation/account-budget.yaml +114 -0
- tarmac/templates/cloudformation/audit-account-hardening.yaml +310 -0
- tarmac/templates/cloudformation/log-archive-hardening.yaml +287 -0
- tarmac/templates/cloudformation/organization-cloudtrail.yaml +112 -0
- tarmac/templates/cloudformation/terraform-backend.yaml +141 -0
- tarmac/templates/policies/scp/deny-leave-organization.json +13 -0
- tarmac/templates/policies/scp/deny-member-root.json +19 -0
- tarmac/templates/policies/scp/foundational-guardrails.json +27 -0
- tarmac_cli-0.1.3.dist-info/METADATA +204 -0
- tarmac_cli-0.1.3.dist-info/RECORD +53 -0
- tarmac_cli-0.1.3.dist-info/WHEEL +5 -0
- tarmac_cli-0.1.3.dist-info/entry_points.txt +3 -0
- tarmac_cli-0.1.3.dist-info/licenses/LICENSE +176 -0
- tarmac_cli-0.1.3.dist-info/top_level.txt +1 -0
tarmac/__init__.py
ADDED
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)
|