aws-inventory-manager 0.17.12__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 (152) hide show
  1. aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
  2. aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
  3. aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
  4. aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
  5. aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
  6. aws_inventory_manager-0.17.12.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +12 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +4046 -0
  15. src/cloudtrail/__init__.py +5 -0
  16. src/cloudtrail/query.py +642 -0
  17. src/config_service/__init__.py +21 -0
  18. src/config_service/collector.py +346 -0
  19. src/config_service/detector.py +256 -0
  20. src/config_service/resource_type_mapping.py +328 -0
  21. src/cost/__init__.py +5 -0
  22. src/cost/analyzer.py +226 -0
  23. src/cost/explorer.py +209 -0
  24. src/cost/reporter.py +237 -0
  25. src/delta/__init__.py +5 -0
  26. src/delta/calculator.py +206 -0
  27. src/delta/differ.py +185 -0
  28. src/delta/formatters.py +272 -0
  29. src/delta/models.py +154 -0
  30. src/delta/reporter.py +234 -0
  31. src/matching/__init__.py +6 -0
  32. src/matching/config.py +52 -0
  33. src/matching/normalizer.py +450 -0
  34. src/matching/prompts.py +33 -0
  35. src/models/__init__.py +21 -0
  36. src/models/config_diff.py +135 -0
  37. src/models/cost_report.py +87 -0
  38. src/models/deletion_operation.py +104 -0
  39. src/models/deletion_record.py +97 -0
  40. src/models/delta_report.py +122 -0
  41. src/models/efs_resource.py +80 -0
  42. src/models/elasticache_resource.py +90 -0
  43. src/models/group.py +318 -0
  44. src/models/inventory.py +133 -0
  45. src/models/protection_rule.py +123 -0
  46. src/models/report.py +288 -0
  47. src/models/resource.py +111 -0
  48. src/models/security_finding.py +102 -0
  49. src/models/snapshot.py +122 -0
  50. src/restore/__init__.py +20 -0
  51. src/restore/audit.py +175 -0
  52. src/restore/cleaner.py +461 -0
  53. src/restore/config.py +209 -0
  54. src/restore/deleter.py +976 -0
  55. src/restore/dependency.py +254 -0
  56. src/restore/safety.py +115 -0
  57. src/security/__init__.py +0 -0
  58. src/security/checks/__init__.py +0 -0
  59. src/security/checks/base.py +56 -0
  60. src/security/checks/ec2_checks.py +88 -0
  61. src/security/checks/elasticache_checks.py +149 -0
  62. src/security/checks/iam_checks.py +102 -0
  63. src/security/checks/rds_checks.py +140 -0
  64. src/security/checks/s3_checks.py +95 -0
  65. src/security/checks/secrets_checks.py +96 -0
  66. src/security/checks/sg_checks.py +142 -0
  67. src/security/cis_mapper.py +97 -0
  68. src/security/models.py +53 -0
  69. src/security/reporter.py +174 -0
  70. src/security/scanner.py +87 -0
  71. src/snapshot/__init__.py +6 -0
  72. src/snapshot/capturer.py +453 -0
  73. src/snapshot/filter.py +259 -0
  74. src/snapshot/inventory_storage.py +236 -0
  75. src/snapshot/report_formatter.py +250 -0
  76. src/snapshot/reporter.py +189 -0
  77. src/snapshot/resource_collectors/__init__.py +5 -0
  78. src/snapshot/resource_collectors/apigateway.py +140 -0
  79. src/snapshot/resource_collectors/backup.py +136 -0
  80. src/snapshot/resource_collectors/base.py +81 -0
  81. src/snapshot/resource_collectors/cloudformation.py +55 -0
  82. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  83. src/snapshot/resource_collectors/codebuild.py +69 -0
  84. src/snapshot/resource_collectors/codepipeline.py +82 -0
  85. src/snapshot/resource_collectors/dynamodb.py +65 -0
  86. src/snapshot/resource_collectors/ec2.py +240 -0
  87. src/snapshot/resource_collectors/ecs.py +215 -0
  88. src/snapshot/resource_collectors/efs_collector.py +102 -0
  89. src/snapshot/resource_collectors/eks.py +200 -0
  90. src/snapshot/resource_collectors/elasticache_collector.py +79 -0
  91. src/snapshot/resource_collectors/elb.py +126 -0
  92. src/snapshot/resource_collectors/eventbridge.py +156 -0
  93. src/snapshot/resource_collectors/glue.py +199 -0
  94. src/snapshot/resource_collectors/iam.py +188 -0
  95. src/snapshot/resource_collectors/kms.py +111 -0
  96. src/snapshot/resource_collectors/lambda_func.py +139 -0
  97. src/snapshot/resource_collectors/rds.py +109 -0
  98. src/snapshot/resource_collectors/route53.py +86 -0
  99. src/snapshot/resource_collectors/s3.py +105 -0
  100. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  101. src/snapshot/resource_collectors/sns.py +68 -0
  102. src/snapshot/resource_collectors/sqs.py +82 -0
  103. src/snapshot/resource_collectors/ssm.py +160 -0
  104. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  105. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  106. src/snapshot/resource_collectors/waf.py +159 -0
  107. src/snapshot/storage.py +351 -0
  108. src/storage/__init__.py +21 -0
  109. src/storage/audit_store.py +419 -0
  110. src/storage/database.py +294 -0
  111. src/storage/group_store.py +763 -0
  112. src/storage/inventory_store.py +320 -0
  113. src/storage/resource_store.py +416 -0
  114. src/storage/schema.py +339 -0
  115. src/storage/snapshot_store.py +363 -0
  116. src/utils/__init__.py +12 -0
  117. src/utils/export.py +305 -0
  118. src/utils/hash.py +60 -0
  119. src/utils/logging.py +63 -0
  120. src/utils/pagination.py +41 -0
  121. src/utils/paths.py +51 -0
  122. src/utils/progress.py +41 -0
  123. src/utils/unsupported_resources.py +306 -0
  124. src/web/__init__.py +5 -0
  125. src/web/app.py +97 -0
  126. src/web/dependencies.py +69 -0
  127. src/web/routes/__init__.py +1 -0
  128. src/web/routes/api/__init__.py +18 -0
  129. src/web/routes/api/charts.py +156 -0
  130. src/web/routes/api/cleanup.py +186 -0
  131. src/web/routes/api/filters.py +253 -0
  132. src/web/routes/api/groups.py +305 -0
  133. src/web/routes/api/inventories.py +80 -0
  134. src/web/routes/api/queries.py +202 -0
  135. src/web/routes/api/resources.py +393 -0
  136. src/web/routes/api/snapshots.py +314 -0
  137. src/web/routes/api/views.py +260 -0
  138. src/web/routes/pages.py +198 -0
  139. src/web/services/__init__.py +1 -0
  140. src/web/templates/base.html +955 -0
  141. src/web/templates/components/navbar.html +31 -0
  142. src/web/templates/components/sidebar.html +104 -0
  143. src/web/templates/pages/audit_logs.html +86 -0
  144. src/web/templates/pages/cleanup.html +279 -0
  145. src/web/templates/pages/dashboard.html +227 -0
  146. src/web/templates/pages/diff.html +175 -0
  147. src/web/templates/pages/error.html +30 -0
  148. src/web/templates/pages/groups.html +721 -0
  149. src/web/templates/pages/queries.html +246 -0
  150. src/web/templates/pages/resources.html +2429 -0
  151. src/web/templates/pages/snapshot_detail.html +271 -0
  152. src/web/templates/pages/snapshots.html +429 -0
src/cli/main.py ADDED
@@ -0,0 +1,4046 @@
1
+ """Main CLI entry point using Typer."""
2
+
3
+ import logging
4
+ import sys
5
+ from datetime import datetime
6
+ from typing import List, Optional
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.markdown import Markdown
11
+ from rich.panel import Panel
12
+ from rich.table import Table
13
+
14
+ from ..aws.credentials import CredentialValidationError, validate_credentials
15
+ from ..snapshot.storage import SnapshotStorage
16
+ from ..utils.logging import setup_logging
17
+ from .config import Config
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Create Typer app
22
+ app = typer.Typer(
23
+ name="awsinv",
24
+ help="AWS Inventory Manager - Resource Snapshot & Delta Tracking CLI tool",
25
+ add_completion=False,
26
+ )
27
+
28
+ # Create Rich console for output
29
+ console = Console()
30
+
31
+ # Global config
32
+ config: Optional[Config] = None
33
+
34
+
35
+ def show_quickstart():
36
+ """Display quickstart guide for new users."""
37
+ quickstart_content = """
38
+ # AWS Inventory Manager - Quick Start
39
+
40
+ Welcome to AWS Inventory Manager! This tool helps you track AWS resources, create snapshots, and analyze costs.
41
+
42
+ ## Complete Walkthrough
43
+
44
+ Follow these steps to get started. All commands use the same inventory and snapshot names
45
+ for continuity - you can run them in sequence!
46
+
47
+ ### 1. Create an Inventory
48
+ An inventory is a named collection of snapshots for tracking resource changes over time.
49
+
50
+ ```bash
51
+ awsinv inventory create prod-baseline --description "Production baseline resources"
52
+ ```
53
+
54
+ ### 2. Take Your First Snapshot
55
+ Capture the current state of AWS resources in your region(s).
56
+
57
+ ```bash
58
+ awsinv snapshot create initial --regions us-east-1 --inventory prod-baseline
59
+ ```
60
+
61
+ This creates a snapshot named "initial" in the "prod-baseline" inventory.
62
+
63
+ ### 3. (Optional) Make Some Changes
64
+ Make changes to your AWS environment - deploy resources, update configurations, etc.
65
+ Then take another snapshot to see what changed.
66
+
67
+ ```bash
68
+ awsinv snapshot create current --regions us-east-1 --inventory prod-baseline
69
+ ```
70
+
71
+ ### 4. Compare Snapshots (Delta Analysis)
72
+ See exactly what resources were added, removed, or changed since your snapshot.
73
+
74
+ ```bash
75
+ awsinv delta --snapshot initial --inventory prod-baseline
76
+ ```
77
+
78
+ ### 5. Analyze Costs
79
+ Get cost breakdown for the resources in your snapshot.
80
+
81
+ ```bash
82
+ # Costs since snapshot was created
83
+ awsinv cost --snapshot initial --inventory prod-baseline
84
+
85
+ # Costs for specific date range
86
+ awsinv cost --snapshot initial --inventory prod-baseline \
87
+ --start-date 2025-01-01 --end-date 2025-01-31
88
+ ```
89
+
90
+ ## Common Commands
91
+
92
+ Using the inventory and snapshots from above:
93
+
94
+ ### List Resources
95
+ ```bash
96
+ # List all inventories
97
+ awsinv inventory list
98
+
99
+ # List snapshots in your inventory
100
+ awsinv snapshot list --inventory prod-baseline
101
+
102
+ # Show snapshot details
103
+ awsinv snapshot show initial --inventory prod-baseline
104
+ ```
105
+
106
+ ### Advanced Filtering
107
+ ```bash
108
+ # Create inventory with tag filters (production resources only)
109
+ awsinv inventory create production \\
110
+ --description "Production resources only" \\
111
+ --include-tags Environment=production
112
+
113
+ # Snapshot only resources created after a specific date
114
+ awsinv snapshot create recent --regions us-east-1 \\
115
+ --inventory prod-baseline --after-date 2025-01-01
116
+ ```
117
+
118
+ ## Getting Help
119
+
120
+ ```bash
121
+ # General help
122
+ awsinv --help
123
+
124
+ # Help for specific command
125
+ awsinv inventory --help
126
+ awsinv snapshot create --help
127
+ awsinv cost --help
128
+
129
+ # Show version
130
+ awsinv version
131
+ ```
132
+
133
+ ## Next Steps
134
+
135
+ **Ready to get started?** Follow the walkthrough above, starting with:
136
+
137
+ ```bash
138
+ awsinv inventory create prod-baseline --description "Production baseline resources"
139
+ ```
140
+
141
+ Then continue with the remaining steps to take snapshots, compare changes, and analyze costs.
142
+
143
+ For detailed help on any command, use `--help`:
144
+
145
+ ```bash
146
+ awsinv snapshot create --help
147
+ awsinv cost --help
148
+ ```
149
+ """
150
+
151
+ console.print(
152
+ Panel(
153
+ Markdown(quickstart_content),
154
+ title="[bold cyan]🚀 AWS Inventory Manager[/bold cyan]",
155
+ border_style="cyan",
156
+ padding=(1, 2),
157
+ )
158
+ )
159
+
160
+
161
+ @app.callback()
162
+ def main(
163
+ profile: Optional[str] = typer.Option(
164
+ None, "--profile", "-p", help="AWS profile name", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
165
+ ),
166
+ storage_path: Optional[str] = typer.Option(
167
+ None,
168
+ "--storage-path",
169
+ help="Custom path for snapshot storage (default: ~/.snapshots or $AWS_INVENTORY_STORAGE_PATH)",
170
+ envvar=["AWSINV_STORAGE_PATH", "AWS_INVENTORY_STORAGE_PATH"],
171
+ ),
172
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging"),
173
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress output except errors"),
174
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
175
+ ):
176
+ """AWS Inventory Manager - Resource Snapshot & Delta Tracking CLI tool."""
177
+ global config
178
+
179
+ # Load configuration
180
+ config = Config.load()
181
+
182
+ # Override with CLI options
183
+ if profile:
184
+ config.aws_profile = profile
185
+
186
+ # Store storage path in config for use by commands
187
+ if storage_path:
188
+ config.storage_path = storage_path
189
+ else:
190
+ config.storage_path = None
191
+
192
+ # Setup logging
193
+ log_level = "ERROR" if quiet else ("DEBUG" if verbose else config.log_level)
194
+ setup_logging(level=log_level, verbose=verbose)
195
+
196
+ # Disable colors if requested
197
+ if no_color:
198
+ console.no_color = True
199
+
200
+
201
+ @app.command()
202
+ def version():
203
+ """Show version information."""
204
+ import boto3
205
+
206
+ from .. import __version__
207
+
208
+ console.print(f"aws-inventory-manager version {__version__}")
209
+ console.print(f"Python {sys.version.split()[0]}")
210
+ console.print(f"boto3 {boto3.__version__}")
211
+
212
+
213
+ # Inventory commands group
214
+ inventory_app = typer.Typer(help="Inventory management commands")
215
+ app.add_typer(inventory_app, name="inventory")
216
+
217
+
218
+ # Helper function to parse tag strings (shared by snapshot and inventory commands)
219
+ def parse_tags(tag_string: str) -> dict:
220
+ """Parse comma-separated Key=Value pairs into dict."""
221
+ tags = {}
222
+ for tag_pair in tag_string.split(","):
223
+ if "=" not in tag_pair:
224
+ console.print("✗ Invalid tag format. Use Key=Value", style="bold red")
225
+ raise typer.Exit(code=1)
226
+ key, value = tag_pair.split("=", 1)
227
+ tags[key.strip()] = value.strip()
228
+ return tags
229
+
230
+
231
+ @inventory_app.command("create")
232
+ def inventory_create(
233
+ name: str = typer.Argument(..., help="Inventory name (alphanumeric, hyphens, underscores only)"),
234
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Human-readable description"),
235
+ include_tags: Optional[str] = typer.Option(
236
+ None, "--include-tags", help="Include only resources with ALL these tags (Key=Value,Key2=Value2)"
237
+ ),
238
+ exclude_tags: Optional[str] = typer.Option(
239
+ None, "--exclude-tags", help="Exclude resources with ANY of these tags (Key=Value,Key2=Value2)"
240
+ ),
241
+ profile: Optional[str] = typer.Option(
242
+ None, "--profile", "-p", help="AWS profile name to use", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
243
+ ),
244
+ ):
245
+ """Create a new inventory for organizing snapshots.
246
+
247
+ Inventories allow you to organize snapshots by purpose (e.g., baseline, team-a-resources)
248
+ with optional tag-based filters that automatically apply to all snapshots in that inventory.
249
+
250
+ Examples:
251
+ # Create basic inventory with no filters
252
+ aws-baseline inventory create baseline --description "Production baseline resources"
253
+
254
+ # Create filtered inventory for team resources
255
+ aws-baseline inventory create team-a-resources \\
256
+ --description "Team Alpha project resources" \\
257
+ --include-tags "team=alpha,env=prod" \\
258
+ --exclude-tags "managed-by=terraform"
259
+ """
260
+ try:
261
+ from datetime import datetime, timezone
262
+
263
+ from ..aws.credentials import get_account_id
264
+ from ..models.inventory import Inventory
265
+ from ..snapshot.inventory_storage import InventoryStorage
266
+
267
+ # Use profile parameter if provided, otherwise use config
268
+ aws_profile = profile if profile else config.aws_profile
269
+
270
+ # Validate credentials and get account ID
271
+ console.print("🔐 Validating AWS credentials...")
272
+ account_id = get_account_id(aws_profile)
273
+ console.print(f"✓ Authenticated for account: {account_id}\n", style="green")
274
+
275
+ # Validate inventory name format
276
+ import re
277
+
278
+ if not re.match(r"^[a-zA-Z0-9_-]+$", name):
279
+ console.print("✗ Error: Invalid inventory name", style="bold red")
280
+ console.print("Name must contain only alphanumeric characters, hyphens, and underscores\n")
281
+ raise typer.Exit(code=1)
282
+
283
+ if len(name) > 50:
284
+ console.print("✗ Error: Inventory name too long", style="bold red")
285
+ console.print("Name must be 50 characters or less\n")
286
+ raise typer.Exit(code=1)
287
+
288
+ # Check for duplicate
289
+ storage = InventoryStorage(config.storage_path)
290
+ if storage.exists(name, account_id):
291
+ console.print(f"✗ Error: Inventory '{name}' already exists for account {account_id}", style="bold red")
292
+ console.print("\nUse a different name or delete the existing inventory first:")
293
+ console.print(f" aws-baseline inventory delete {name}\n")
294
+ raise typer.Exit(code=1)
295
+
296
+ # Parse tags if provided
297
+ include_tag_dict = {}
298
+ exclude_tag_dict = {}
299
+
300
+ if include_tags:
301
+ include_tag_dict = parse_tags(include_tags)
302
+
303
+ if exclude_tags:
304
+ exclude_tag_dict = parse_tags(exclude_tags)
305
+
306
+ # Create inventory
307
+ inventory = Inventory(
308
+ name=name,
309
+ account_id=account_id,
310
+ description=description or "",
311
+ include_tags=include_tag_dict,
312
+ exclude_tags=exclude_tag_dict,
313
+ snapshots=[],
314
+ active_snapshot=None,
315
+ created_at=datetime.now(timezone.utc),
316
+ last_updated=datetime.now(timezone.utc),
317
+ )
318
+
319
+ # Save inventory
320
+ storage.save(inventory)
321
+
322
+ # T042: Audit logging for create operation
323
+ logger.info(
324
+ f"Created inventory '{name}' for account {account_id} with "
325
+ f"{len(include_tag_dict)} include filters and {len(exclude_tag_dict)} exclude filters"
326
+ )
327
+
328
+ # Display success message
329
+ console.print(f"✓ Created inventory '[bold]{name}[/bold]' for account {account_id}", style="green")
330
+ console.print()
331
+ console.print("[bold]Inventory Details:[/bold]")
332
+ console.print(f" Name: {name}")
333
+ console.print(f" Account: {account_id}")
334
+ console.print(f" Description: {description or '(none)'}")
335
+
336
+ # Display filters
337
+ if include_tag_dict or exclude_tag_dict:
338
+ console.print(" Filters:")
339
+ if include_tag_dict:
340
+ tag_str = ", ".join(f"{k}={v}" for k, v in include_tag_dict.items())
341
+ console.print(f" Include Tags: {tag_str} (resources must have ALL)")
342
+ if exclude_tag_dict:
343
+ tag_str = ", ".join(f"{k}={v}" for k, v in exclude_tag_dict.items())
344
+ console.print(f" Exclude Tags: {tag_str} (resources must NOT have ANY)")
345
+ else:
346
+ console.print(" Filters: None")
347
+
348
+ console.print(" Snapshots: 0")
349
+ console.print()
350
+
351
+ except typer.Exit:
352
+ raise
353
+ except Exception as e:
354
+ console.print(f"✗ Error creating inventory: {e}", style="bold red")
355
+ raise typer.Exit(code=2)
356
+
357
+
358
+ @inventory_app.command("list")
359
+ def inventory_list(
360
+ profile: Optional[str] = typer.Option(
361
+ None, "--profile", "-p", help="AWS profile name to use", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
362
+ ),
363
+ ):
364
+ """List all inventories for the current AWS account.
365
+
366
+ Displays a table showing all inventories with their snapshot counts,
367
+ filter settings, and descriptions.
368
+ """
369
+ try:
370
+ from ..aws.credentials import get_account_id
371
+ from ..snapshot.inventory_storage import InventoryStorage
372
+
373
+ # Use profile parameter if provided, otherwise use config
374
+ aws_profile = profile if profile else config.aws_profile
375
+
376
+ # Get account ID
377
+ account_id = get_account_id(aws_profile)
378
+
379
+ # Load inventories
380
+ storage = InventoryStorage(config.storage_path)
381
+ inventories = storage.load_by_account(account_id)
382
+
383
+ if not inventories:
384
+ console.print(f"No inventories found for account {account_id}", style="yellow")
385
+ console.print("\nCreate one with: aws-baseline inventory create <name>")
386
+ return
387
+
388
+ # Create table
389
+ table = Table(title=f"Inventories for Account {account_id}", show_header=True, header_style="bold magenta")
390
+ table.add_column("Name", style="cyan", width=25)
391
+ table.add_column("Snapshots", justify="center", width=12)
392
+ table.add_column("Filters", width=15)
393
+ table.add_column("Description", width=40)
394
+
395
+ for inv in inventories:
396
+ # Determine filter summary
397
+ if inv.include_tags or inv.exclude_tags:
398
+ inc_count = len(inv.include_tags)
399
+ exc_count = len(inv.exclude_tags)
400
+ filter_text = f"Yes ({inc_count}/{exc_count})"
401
+ else:
402
+ filter_text = "None"
403
+
404
+ table.add_row(inv.name, str(len(inv.snapshots)), filter_text, inv.description or "(no description)")
405
+
406
+ console.print()
407
+ console.print(table)
408
+ console.print()
409
+ console.print(f"Total Inventories: {len(inventories)}")
410
+ console.print()
411
+
412
+ except Exception as e:
413
+ console.print(f"✗ Error listing inventories: {e}", style="bold red")
414
+ raise typer.Exit(code=2)
415
+
416
+
417
+ @inventory_app.command("show")
418
+ def inventory_show(
419
+ name: str = typer.Argument(
420
+ ..., help="Inventory name to display", envvar="AWSINV_INVENTORY_ID"
421
+ ),
422
+ profile: Optional[str] = typer.Option(
423
+ None, "--profile", "-p", help="AWS profile name to use", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
424
+ ),
425
+ ):
426
+ """Show detailed information for a specific inventory.
427
+
428
+ Displays full details including filters, snapshots, and timestamps.
429
+ """
430
+ try:
431
+ from ..aws.credentials import get_account_id
432
+ from ..snapshot.inventory_storage import InventoryNotFoundError, InventoryStorage
433
+
434
+ # Use profile parameter if provided, otherwise use config
435
+ aws_profile = profile if profile else config.aws_profile
436
+
437
+ # Get account ID
438
+ account_id = get_account_id(aws_profile)
439
+
440
+ # Load inventory
441
+ storage = InventoryStorage(config.storage_path)
442
+ try:
443
+ inventory = storage.get_by_name(name, account_id)
444
+ except InventoryNotFoundError:
445
+ console.print(f"✗ Error: Inventory '{name}' not found for account {account_id}", style="bold red")
446
+ console.print("\nList available inventories with: aws-baseline inventory list")
447
+ raise typer.Exit(code=1)
448
+
449
+ # Display inventory details
450
+ console.print()
451
+ console.print(f"[bold]Inventory: {inventory.name}[/bold]")
452
+ console.print(f"Account: {inventory.account_id}")
453
+ console.print(f"Description: {inventory.description or '(none)'}")
454
+ console.print(f"Created: {inventory.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
455
+ console.print(f"Last Updated: {inventory.last_updated.strftime('%Y-%m-%d %H:%M:%S UTC')}")
456
+ console.print()
457
+
458
+ # Display filters
459
+ if inventory.include_tags or inventory.exclude_tags:
460
+ console.print("[bold]Filters:[/bold]")
461
+ if inventory.include_tags:
462
+ console.print(" Include Tags (must have ALL):")
463
+ for key, value in inventory.include_tags.items():
464
+ console.print(f" • {key} = {value}")
465
+ if inventory.exclude_tags:
466
+ console.print(" Exclude Tags (must NOT have ANY):")
467
+ for key, value in inventory.exclude_tags.items():
468
+ console.print(f" • {key} = {value}")
469
+ console.print()
470
+
471
+ # Display snapshots
472
+ console.print(f"[bold]Snapshots: {len(inventory.snapshots)}[/bold]")
473
+ if inventory.snapshots:
474
+ for snapshot_file in inventory.snapshots:
475
+ active_marker = " [green](active)[/green]" if snapshot_file == inventory.active_snapshot else ""
476
+ console.print(f" • {snapshot_file}{active_marker}")
477
+ else:
478
+ console.print(" (No snapshots taken yet)")
479
+ console.print()
480
+
481
+ # Display active snapshot
482
+ if inventory.active_snapshot:
483
+ console.print(f"[bold]Active Baseline:[/bold] {inventory.active_snapshot}")
484
+ else:
485
+ console.print("[bold]Active Baseline:[/bold] None")
486
+ console.print()
487
+
488
+ except typer.Exit:
489
+ raise
490
+ except Exception as e:
491
+ console.print(f"✗ Error showing inventory: {e}", style="bold red")
492
+ raise typer.Exit(code=2)
493
+
494
+
495
+ @inventory_app.command("migrate")
496
+ def inventory_migrate(
497
+ profile: Optional[str] = typer.Option(
498
+ None, "--profile", "-p", help="AWS profile name to use", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
499
+ ),
500
+ ):
501
+ """Migrate legacy snapshots to inventory structure.
502
+
503
+ Scans for snapshots without inventory assignment and adds them to the 'default' inventory.
504
+ """
505
+ try:
506
+ # Use profile parameter if provided, otherwise use config
507
+ aws_profile = profile if profile else config.aws_profile
508
+
509
+ # Validate credentials
510
+ identity = validate_credentials(aws_profile)
511
+
512
+ console.print("🔄 Scanning for legacy snapshots...\n")
513
+
514
+ # T035: Scan .snapshots/ directory for snapshot files
515
+ storage = SnapshotStorage(config.storage_path)
516
+ from pathlib import Path
517
+ from typing import List
518
+
519
+ snapshots_dir = storage.storage_dir
520
+ snapshot_files: List[Path] = []
521
+
522
+ # Find all .yaml and .yaml.gz files
523
+ for pattern in ["*.yaml", "*.yaml.gz"]:
524
+ snapshot_files.extend(snapshots_dir.glob(pattern))
525
+
526
+ if not snapshot_files:
527
+ # T037: No snapshots found
528
+ console.print("✓ No legacy snapshots found. Nothing to migrate.", style="green")
529
+ raise typer.Exit(code=0)
530
+
531
+ # Load inventory storage
532
+ from ..snapshot.inventory_storage import InventoryStorage
533
+
534
+ inventory_storage = InventoryStorage(config.storage_path)
535
+
536
+ # Get or create default inventory
537
+ default_inventory = inventory_storage.get_or_create_default(identity["account_id"])
538
+
539
+ # T035: Check each snapshot for inventory assignment
540
+ legacy_count = 0
541
+ added_count = 0
542
+
543
+ for snapshot_file in snapshot_files:
544
+ snapshot_filename = snapshot_file.name
545
+ snapshot_name = snapshot_filename.replace(".yaml.gz", "").replace(".yaml", "")
546
+
547
+ # Skip if already in default inventory
548
+ if snapshot_filename in default_inventory.snapshots:
549
+ continue
550
+
551
+ try:
552
+ # Load snapshot to check if it has inventory_name
553
+ snapshot = storage.load_snapshot(snapshot_name)
554
+
555
+ # Check if snapshot belongs to this account
556
+ if snapshot.account_id != identity["account_id"]:
557
+ continue
558
+
559
+ # If inventory_name is 'default', it's a legacy snapshot
560
+ if snapshot.inventory_name == "default":
561
+ legacy_count += 1
562
+
563
+ # Add to default inventory
564
+ default_inventory.add_snapshot(snapshot_filename, set_active=False)
565
+ added_count += 1
566
+
567
+ except Exception as e:
568
+ # T037: Handle corrupted snapshot files
569
+ console.print(f"⚠️ Skipping {snapshot_filename}: {e}", style="yellow")
570
+ continue
571
+
572
+ # T035: Save updated default inventory
573
+ if added_count > 0:
574
+ inventory_storage.save(default_inventory)
575
+
576
+ # T036: Display progress feedback
577
+ console.print(f"✓ Found {legacy_count} snapshot(s) without inventory assignment", style="green")
578
+ if added_count > 0:
579
+ console.print(f"✓ Added {added_count} snapshot(s) to 'default' inventory", style="green")
580
+ console.print("\n✓ Migration complete!", style="bold green")
581
+ else:
582
+ console.print("\n✓ All snapshots already assigned to inventories", style="green")
583
+
584
+ except typer.Exit:
585
+ raise
586
+ except Exception as e:
587
+ console.print(f"✗ Error during migration: {e}", style="bold red")
588
+ logger.exception("Error in inventory migrate command")
589
+ raise typer.Exit(code=2)
590
+
591
+
592
+ @inventory_app.command("delete")
593
+ def inventory_delete(
594
+ name: str = typer.Argument(..., help="Inventory name to delete", envvar="AWSINV_INVENTORY_ID"),
595
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompts"),
596
+ profile: Optional[str] = typer.Option(
597
+ None, "--profile", "-p", help="AWS profile name to use", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
598
+ ),
599
+ ):
600
+ """Delete an inventory, optionally deleting its snapshot files.
601
+
602
+ WARNING: This will remove the inventory metadata. Snapshot files can be preserved or deleted.
603
+ """
604
+ try:
605
+ # Use profile parameter if provided, otherwise use config
606
+ aws_profile = profile if profile else config.aws_profile
607
+
608
+ # Validate credentials
609
+ identity = validate_credentials(aws_profile)
610
+
611
+ # Load inventory storage
612
+ from ..snapshot.inventory_storage import InventoryNotFoundError, InventoryStorage
613
+
614
+ storage = InventoryStorage(config.storage_path)
615
+
616
+ # T027, T032: Load inventory or error if doesn't exist
617
+ try:
618
+ inventory = storage.get_by_name(name, identity["account_id"])
619
+ except InventoryNotFoundError:
620
+ console.print(f"✗ Inventory '{name}' not found for account {identity['account_id']}", style="bold red")
621
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
622
+ raise typer.Exit(code=1)
623
+
624
+ # T032: Check if this would leave account with zero inventories
625
+ all_inventories = storage.load_by_account(identity["account_id"])
626
+ if len(all_inventories) == 1:
627
+ console.print(f"✗ Cannot delete '{name}' - it is the only inventory for this account", style="bold red")
628
+ console.print(" At least one inventory must exist per account", style="yellow")
629
+ raise typer.Exit(code=1)
630
+
631
+ # T028: Display inventory details for confirmation
632
+ console.print(f"\n📦 Inventory: [bold]{inventory.name}[/bold]")
633
+ if inventory.description:
634
+ console.print(f" {inventory.description}")
635
+ console.print(f" Snapshots: {len(inventory.snapshots)}")
636
+
637
+ # T029: Warn if this is the active snapshot
638
+ if inventory.active_snapshot:
639
+ console.print("\n⚠️ Warning: This inventory has an active snapshot snapshot!", style="bold yellow")
640
+ console.print(" Deleting it will prevent cost/delta analysis for this inventory.", style="yellow")
641
+
642
+ # T028: Confirmation prompt
643
+ if not force:
644
+ console.print()
645
+ confirm = typer.confirm(f"Delete inventory '{name}'?", default=False)
646
+ if not confirm:
647
+ console.print("Cancelled.")
648
+ raise typer.Exit(code=0)
649
+
650
+ # T030: Ask about snapshot file deletion
651
+ delete_snapshots = False
652
+ if inventory.snapshots and not force:
653
+ console.print()
654
+ delete_snapshots = typer.confirm(f"Delete {len(inventory.snapshots)} snapshot file(s) too?", default=False)
655
+ elif inventory.snapshots and force:
656
+ # With --force, don't delete snapshots by default (safer)
657
+ delete_snapshots = False
658
+
659
+ # T031, T032: Delete inventory (already implemented in InventoryStorage)
660
+ try:
661
+ deleted_count = storage.delete(name, identity["account_id"], delete_snapshots=delete_snapshots)
662
+ except Exception as e:
663
+ console.print(f"✗ Error deleting inventory: {e}", style="bold red")
664
+ raise typer.Exit(code=2)
665
+
666
+ # T042: Audit logging for delete operation
667
+ logger.info(
668
+ f"Deleted inventory '{name}' for account {identity['account_id']}, "
669
+ f"deleted {deleted_count} snapshot files, snapshots_deleted={delete_snapshots}"
670
+ )
671
+
672
+ # T033: Display completion messages
673
+ console.print(f"\n✓ Inventory '[bold]{name}[/bold]' deleted", style="green")
674
+ if delete_snapshots and deleted_count > 0:
675
+ console.print(f"✓ {deleted_count} snapshot file(s) deleted", style="green")
676
+ elif inventory.snapshots and not delete_snapshots:
677
+ console.print(f" {len(inventory.snapshots)} snapshot file(s) preserved", style="cyan")
678
+
679
+ except typer.Exit:
680
+ raise
681
+ except Exception as e:
682
+ console.print(f"✗ Error deleting inventory: {e}", style="bold red")
683
+ logger.exception("Error in inventory delete command")
684
+ raise typer.Exit(code=2)
685
+
686
+
687
+ # Snapshot commands group
688
+ snapshot_app = typer.Typer(help="Snapshot management commands")
689
+ app.add_typer(snapshot_app, name="snapshot")
690
+
691
+ # Config commands group
692
+ config_app = typer.Typer(help="AWS Config integration commands")
693
+ app.add_typer(config_app, name="config")
694
+
695
+
696
+ @config_app.command("check")
697
+ def config_check(
698
+ regions: Optional[str] = typer.Option(
699
+ None, "--regions", help="Comma-separated list of regions (default: us-east-1)", envvar=["AWSINV_REGION", "AWS_REGION"]
700
+ ),
701
+ profile: Optional[str] = typer.Option(
702
+ None, "--profile", help="AWS profile name", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
703
+ ),
704
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed resource type support"),
705
+ ):
706
+ """Check AWS Config availability and status.
707
+
708
+ Shows whether AWS Config is enabled in each region and what resource types
709
+ are being recorded. This helps understand which collection method will be used.
710
+
711
+ Examples:
712
+ awsinv config check
713
+ awsinv config check --regions us-east-1,us-west-2
714
+ awsinv config check --verbose
715
+ """
716
+ from ..config_service.detector import detect_config_availability
717
+ from ..config_service.resource_type_mapping import CONFIG_SUPPORTED_TYPES, COLLECTOR_TO_CONFIG_TYPES
718
+
719
+ import boto3
720
+
721
+ region_list = (regions or "us-east-1").split(",")
722
+
723
+ # Create session
724
+ session_kwargs = {}
725
+ if profile:
726
+ session_kwargs["profile_name"] = profile
727
+ session = boto3.Session(**session_kwargs)
728
+
729
+ # Header
730
+ console.print()
731
+ console.print("[bold]AWS Config Status Check[/bold]")
732
+ console.print()
733
+
734
+ # Check each region
735
+ for region in region_list:
736
+ availability = detect_config_availability(session, region, profile)
737
+
738
+ if availability.is_enabled:
739
+ status = "[green]✓ ENABLED[/green]"
740
+ recorder_info = f"Recorder: {availability.recorder_name}"
741
+ if availability.recording_group_all_supported:
742
+ types_info = f"Recording: [cyan]All supported types[/cyan] ({len(CONFIG_SUPPORTED_TYPES)} types)"
743
+ else:
744
+ types_info = f"Recording: [yellow]{len(availability.resource_types_recorded)} specific types[/yellow]"
745
+ else:
746
+ status = "[red]✗ NOT ENABLED[/red]"
747
+ recorder_info = f"Reason: {availability.error_message or 'Unknown'}"
748
+ types_info = ""
749
+
750
+ console.print(f"[bold]{region}[/bold]: {status}")
751
+ console.print(f" {recorder_info}")
752
+ if types_info:
753
+ console.print(f" {types_info}")
754
+
755
+ if verbose and availability.is_enabled:
756
+ # Show which services will use Config vs Direct API
757
+ console.print()
758
+ console.print(" [dim]Collection method by service:[/dim]")
759
+
760
+ service_table = Table(show_header=True, header_style="dim", box=None, padding=(0, 2))
761
+ service_table.add_column("Service", style="cyan")
762
+ service_table.add_column("Method", style="white")
763
+ service_table.add_column("Resource Types", style="dim")
764
+
765
+ for service, config_types in sorted(COLLECTOR_TO_CONFIG_TYPES.items()):
766
+ supported_types = [t for t in config_types if availability.supports_resource_type(t)]
767
+ if supported_types:
768
+ method = "[green]Config[/green]"
769
+ types_str = ", ".join(t.split("::")[-1] for t in supported_types[:3])
770
+ if len(supported_types) > 3:
771
+ types_str += f" (+{len(supported_types) - 3} more)"
772
+ else:
773
+ method = "[yellow]Direct API[/yellow]"
774
+ types_str = "Config not recording these types"
775
+
776
+ service_table.add_row(service.upper(), method, types_str)
777
+
778
+ console.print(service_table)
779
+
780
+ console.print()
781
+
782
+ # Summary
783
+ enabled_regions = [r for r in region_list if detect_config_availability(session, r, profile).is_enabled]
784
+ if enabled_regions:
785
+ console.print(f"[green]Config enabled in {len(enabled_regions)}/{len(region_list)} regions[/green]")
786
+ console.print("[dim]Snapshots will use Config for faster collection where available.[/dim]")
787
+ else:
788
+ console.print("[yellow]Config not enabled in any checked regions[/yellow]")
789
+ console.print("[dim]Snapshots will use direct API calls (slower).[/dim]")
790
+ console.print()
791
+ console.print("[dim]To enable AWS Config: https://docs.aws.amazon.com/config/latest/developerguide/gs-console.html[/dim]")
792
+
793
+
794
+ @snapshot_app.command("create")
795
+ def snapshot_create(
796
+ name: Optional[str] = typer.Argument(
797
+ None, help="Snapshot name (auto-generated if not provided)", envvar="AWSINV_SNAPSHOT_ID"
798
+ ),
799
+ regions: Optional[str] = typer.Option(
800
+ None, "--regions", help="Comma-separated list of regions (default: us-east-1)", envvar=["AWSINV_REGION", "AWS_REGION"]
801
+ ),
802
+ profile: Optional[str] = typer.Option(
803
+ None, "--profile", help="AWS profile name to use", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
804
+ ),
805
+ inventory: Optional[str] = typer.Option(
806
+ None,
807
+ "--inventory",
808
+ help="Inventory name to use for filters (conflicts with --include-tags/--exclude-tags)",
809
+ envvar="AWSINV_INVENTORY_ID",
810
+ ),
811
+ set_active: bool = typer.Option(True, "--set-active/--no-set-active", help="Set as active snapshot"),
812
+ compress: bool = typer.Option(False, "--compress", help="Compress snapshot with gzip"),
813
+ before_date: Optional[str] = typer.Option(
814
+ None, "--before-date", help="Include only resources created before date (YYYY-MM-DD)"
815
+ ),
816
+ after_date: Optional[str] = typer.Option(
817
+ None, "--after-date", help="Include only resources created on/after date (YYYY-MM-DD)"
818
+ ),
819
+ filter_tags: Optional[str] = typer.Option(None, "--filter-tags", help="DEPRECATED: use --include-tags instead"),
820
+ include_tags: Optional[str] = typer.Option(
821
+ None, "--include-tags", help="Include only resources with ALL these tags (Key=Value,Key2=Value2)"
822
+ ),
823
+ exclude_tags: Optional[str] = typer.Option(
824
+ None, "--exclude-tags", help="Exclude resources with ANY of these tags (Key=Value,Key2=Value2)"
825
+ ),
826
+ created_by_role: Optional[str] = typer.Option(
827
+ None, "--created-by-role", help="Tag resources created by this IAM role with _created_by_role (queries CloudTrail, 90-day limit)"
828
+ ),
829
+ track_creators: bool = typer.Option(
830
+ False, "--track-creators", help="Query CloudTrail to tag ALL resources with their creator (_created_by, _created_by_type)"
831
+ ),
832
+ use_config: bool = typer.Option(
833
+ False, "--config", help="Use AWS Config for collection when available (default: disabled, use direct API)"
834
+ ),
835
+ config_aggregator: Optional[str] = typer.Option(
836
+ None, "--config-aggregator", help="AWS Config Aggregator name for multi-account collection"
837
+ ),
838
+ verbose: bool = typer.Option(
839
+ False, "--verbose", "-v", help="Show detailed collection method breakdown"
840
+ ),
841
+ ):
842
+ """Create a new snapshot of AWS resources.
843
+
844
+ Captures resources from 26 AWS services:
845
+ - IAM: Roles, Users, Groups, Policies
846
+ - Lambda: Functions, Layers
847
+ - S3: Buckets
848
+ - EC2: Instances, Volumes, VPCs, Security Groups, Subnets, VPC Endpoints
849
+ - RDS: DB Instances, DB Clusters (including Aurora)
850
+ - CloudWatch: Alarms, Log Groups
851
+ - SNS: Topics
852
+ - SQS: Queues
853
+ - DynamoDB: Tables
854
+ - ELB: Load Balancers (Classic, ALB, NLB, GWLB)
855
+ - CloudFormation: Stacks
856
+ - API Gateway: REST APIs, HTTP APIs, WebSocket APIs
857
+ - EventBridge: Event Buses, Rules
858
+ - Secrets Manager: Secrets
859
+ - KMS: Customer-Managed Keys
860
+ - Systems Manager: Parameters, Documents
861
+ - Route53: Hosted Zones
862
+ - ECS: Clusters, Services, Task Definitions
863
+ - EKS: Clusters, Node Groups, Fargate Profiles
864
+ - Step Functions: State Machines
865
+ - WAF: Web ACLs (Regional & CloudFront)
866
+ - CodePipeline: Pipelines
867
+ - CodeBuild: Projects
868
+ - Backup: Backup Plans, Backup Vaults
869
+ - Glue: Databases, Tables, Crawlers, Jobs, Connections
870
+
871
+ Historical Baselines & Filtering:
872
+ Use --before-date, --after-date, --include-tags, and/or --exclude-tags to create
873
+ snapshots representing resources as they existed at specific points in time or with
874
+ specific characteristics.
875
+
876
+ Examples:
877
+ - Production only: --include-tags Environment=production
878
+ - Exclude test/dev: --exclude-tags Environment=test,Environment=dev
879
+ - Multiple filters: --include-tags Team=platform,Environment=prod --exclude-tags Status=archived
880
+ """
881
+ try:
882
+ # Use profile parameter if provided, otherwise use config
883
+ aws_profile = profile if profile else config.aws_profile
884
+
885
+ # Validate credentials
886
+ console.print("🔐 Validating AWS credentials...")
887
+ identity = validate_credentials(aws_profile)
888
+ console.print(f"✓ Authenticated as: {identity['arn']}\n", style="green")
889
+
890
+ # T012: Validate filter conflict - inventory vs inline tags
891
+ if inventory and (include_tags or exclude_tags):
892
+ console.print(
893
+ "✗ Error: Cannot use --inventory with --include-tags or --exclude-tags\n"
894
+ " Filters are defined in the inventory. Either:\n"
895
+ " 1. Use --inventory to apply inventory's filters, OR\n"
896
+ " 2. Use --include-tags/--exclude-tags for ad-hoc filtering",
897
+ style="bold red",
898
+ )
899
+ raise typer.Exit(code=1)
900
+
901
+ # T013: Load inventory and apply its filters
902
+ from ..snapshot.inventory_storage import InventoryStorage
903
+
904
+ inventory_storage = InventoryStorage(config.storage_path)
905
+ active_inventory = None
906
+ inventory_name = "default"
907
+
908
+ if inventory:
909
+ # Load specified inventory
910
+ try:
911
+ active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
912
+ inventory_name = inventory
913
+ console.print(f"📦 Using inventory: [bold]{inventory}[/bold]", style="cyan")
914
+ if active_inventory.description:
915
+ console.print(f" {active_inventory.description}")
916
+ except Exception:
917
+ # T018: Handle nonexistent inventory
918
+ console.print(
919
+ f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
920
+ )
921
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
922
+ raise typer.Exit(code=1)
923
+ else:
924
+ # Get or create default inventory (lazy creation)
925
+ active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
926
+ inventory_name = "default"
927
+
928
+ # Generate snapshot name if not provided (T014: use inventory in naming)
929
+ if not name:
930
+ timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
931
+ name = f"{identity['account_id']}-{inventory_name}-{timestamp}"
932
+
933
+ # Parse regions - default to us-east-1
934
+ region_list = []
935
+ if regions:
936
+ region_list = [r.strip() for r in regions.split(",")]
937
+ elif config.regions:
938
+ region_list = config.regions
939
+ else:
940
+ # Default to us-east-1
941
+ region_list = ["us-east-1"]
942
+
943
+ console.print(f"📸 Creating snapshot: [bold]{name}[/bold]")
944
+ console.print(f"Regions: {', '.join(region_list)}\n")
945
+
946
+ # Parse filters - use inventory filters if inventory specified, else inline filters
947
+ resource_filter = None
948
+
949
+ # T013: Determine which filters to use
950
+ if inventory:
951
+ # Use inventory's filters
952
+ include_tags_dict = active_inventory.include_tags if active_inventory.include_tags else None
953
+ exclude_tags_dict = active_inventory.exclude_tags if active_inventory.exclude_tags else None
954
+ else:
955
+ # Use inline filters from command-line
956
+ include_tags_dict = {}
957
+ exclude_tags_dict = {}
958
+
959
+ # Parse include tags (supports both --filter-tags and --include-tags)
960
+ if filter_tags:
961
+ console.print("⚠️ Note: --filter-tags is deprecated, use --include-tags", style="yellow")
962
+ try:
963
+ include_tags_dict = parse_tags(filter_tags)
964
+ except Exception as e:
965
+ console.print(f"✗ Error parsing filter-tags: {e}", style="bold red")
966
+ raise typer.Exit(code=1)
967
+
968
+ if include_tags:
969
+ try:
970
+ include_tags_dict.update(parse_tags(include_tags))
971
+ except Exception as e:
972
+ console.print(f"✗ Error parsing include-tags: {e}", style="bold red")
973
+ raise typer.Exit(code=1)
974
+
975
+ # Parse exclude tags
976
+ if exclude_tags:
977
+ try:
978
+ exclude_tags_dict = parse_tags(exclude_tags)
979
+ except Exception as e:
980
+ console.print(f"✗ Error parsing exclude-tags: {e}", style="bold red")
981
+ raise typer.Exit(code=1)
982
+
983
+ # Convert to None if empty
984
+ include_tags_dict = include_tags_dict if include_tags_dict else None
985
+ exclude_tags_dict = exclude_tags_dict if exclude_tags_dict else None
986
+
987
+ # Create filter if any filters or dates are specified
988
+ if before_date or after_date or include_tags_dict or exclude_tags_dict:
989
+ from datetime import datetime as dt
990
+
991
+ from ..snapshot.filter import ResourceFilter
992
+
993
+ # Parse dates
994
+ before_dt = None
995
+ after_dt = None
996
+
997
+ if before_date:
998
+ try:
999
+ # Parse as UTC timezone-aware
1000
+ from datetime import timezone
1001
+
1002
+ before_dt = dt.strptime(before_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
1003
+ except ValueError:
1004
+ console.print("✗ Invalid --before-date format. Use YYYY-MM-DD (UTC)", style="bold red")
1005
+ raise typer.Exit(code=1)
1006
+
1007
+ if after_date:
1008
+ try:
1009
+ # Parse as UTC timezone-aware
1010
+ from datetime import timezone
1011
+
1012
+ after_dt = dt.strptime(after_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
1013
+ except ValueError:
1014
+ console.print("✗ Invalid --after-date format. Use YYYY-MM-DD (UTC)", style="bold red")
1015
+ raise typer.Exit(code=1)
1016
+
1017
+ # Create filter
1018
+ resource_filter = ResourceFilter(
1019
+ before_date=before_dt,
1020
+ after_date=after_dt,
1021
+ include_tags=include_tags_dict,
1022
+ exclude_tags=exclude_tags_dict,
1023
+ )
1024
+
1025
+ console.print(f"{resource_filter.get_filter_summary()}\n")
1026
+
1027
+ # Import snapshot creation
1028
+ from ..snapshot.capturer import create_snapshot
1029
+
1030
+ # T015: Pass inventory_name to create_snapshot
1031
+ # Show Config status
1032
+ if use_config:
1033
+ console.print("🔧 AWS Config collection: [bold green]enabled[/bold green] (fallback to direct API if unavailable)")
1034
+ if config_aggregator:
1035
+ console.print(f" Using aggregator: {config_aggregator}")
1036
+ else:
1037
+ console.print("🔧 AWS Config collection: [bold yellow]disabled[/bold yellow] (using direct API)")
1038
+
1039
+ snapshot = create_snapshot(
1040
+ name=name,
1041
+ regions=region_list,
1042
+ account_id=identity["account_id"],
1043
+ profile_name=aws_profile,
1044
+ set_active=set_active,
1045
+ resource_filter=resource_filter,
1046
+ inventory_name=inventory_name,
1047
+ use_config=use_config,
1048
+ config_aggregator=config_aggregator,
1049
+ )
1050
+
1051
+ # Tag resources created by role if specified (uses CloudTrail)
1052
+ if created_by_role:
1053
+ from ..cloudtrail import CloudTrailQuery
1054
+
1055
+ console.print(f"\n🔍 Checking creator role: [bold]{created_by_role}[/bold]")
1056
+ console.print(" Querying CloudTrail (this may take a moment)...")
1057
+
1058
+ ct_query = CloudTrailQuery(profile_name=aws_profile, regions=region_list)
1059
+ created_resources = ct_query.get_created_resource_names(
1060
+ role_arn=created_by_role,
1061
+ days_back=90,
1062
+ regions=region_list,
1063
+ )
1064
+
1065
+ # Build a set of (name, type) tuples for matching
1066
+ created_set = set()
1067
+ for resource_type, names in created_resources.items():
1068
+ for name in names:
1069
+ created_set.add((name, resource_type))
1070
+
1071
+ # Tag resources that were created by this role (add to tags dict)
1072
+ matched_count = 0
1073
+ for resource in snapshot.resources:
1074
+ is_match = False
1075
+ # Match by name and type
1076
+ if (resource.name, resource.resource_type) in created_set:
1077
+ is_match = True
1078
+ # Also try matching by ARN components
1079
+ elif resource.arn:
1080
+ arn_name = resource.arn.split("/")[-1].split(":")[-1]
1081
+ if (arn_name, resource.resource_type) in created_set:
1082
+ is_match = True
1083
+
1084
+ if is_match:
1085
+ matched_count += 1
1086
+ # Add created-by tag to resource (internal tracking, not AWS tag)
1087
+ if resource.tags is None:
1088
+ resource.tags = {}
1089
+ resource.tags["_created_by_role"] = created_by_role
1090
+
1091
+ console.print(f" Found {len(created_resources)} resource types in CloudTrail")
1092
+ console.print(f" Tagged {matched_count}/{len(snapshot.resources)} resources as created by this role")
1093
+ console.print(f" [dim](Resources older than 90 days won't appear in CloudTrail)[/dim]")
1094
+
1095
+ # Track creators for ALL resources if --track-creators specified
1096
+ if track_creators:
1097
+ from ..cloudtrail import CloudTrailQuery
1098
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
1099
+ from ..cloudtrail.query import EVENT_TO_RESOURCE_TYPE
1100
+
1101
+ console.print("\n🔍 Tracking resource creators from CloudTrail...")
1102
+
1103
+ ct_query = CloudTrailQuery(profile_name=aws_profile, regions=region_list)
1104
+
1105
+ # Count total event types to query
1106
+ event_types = list(EVENT_TO_RESOURCE_TYPE.keys())
1107
+ total_queries = len(event_types) * len(region_list)
1108
+ completed_queries = 0
1109
+ total_events_found = 0
1110
+
1111
+ with Progress(
1112
+ SpinnerColumn(),
1113
+ TextColumn("[progress.description]{task.description}"),
1114
+ BarColumn(),
1115
+ TaskProgressColumn(),
1116
+ console=console,
1117
+ ) as progress:
1118
+ task = progress.add_task(f"[cyan]Querying {len(event_types)} event types...", total=total_queries)
1119
+
1120
+ def progress_callback(event_name: str, events_found: int):
1121
+ nonlocal completed_queries, total_events_found
1122
+ completed_queries += 1
1123
+ total_events_found += events_found
1124
+ progress.update(task, advance=1, description=f"[cyan]{event_name}: {events_found} events")
1125
+
1126
+ creators = ct_query.get_resource_creators(
1127
+ days_back=90,
1128
+ regions=region_list,
1129
+ progress_callback=progress_callback,
1130
+ )
1131
+
1132
+ # Match resources to their creators
1133
+ matched_count = 0
1134
+ for resource in snapshot.resources:
1135
+ # Try to find creator by resource type and name
1136
+ key = f"{resource.resource_type}:{resource.name}"
1137
+ creator_info = creators.get(key)
1138
+
1139
+ # Also try matching by ARN name components
1140
+ if not creator_info and resource.arn:
1141
+ arn_name = resource.arn.split("/")[-1].split(":")[-1]
1142
+ key = f"{resource.resource_type}:{arn_name}"
1143
+ creator_info = creators.get(key)
1144
+
1145
+ if creator_info:
1146
+ matched_count += 1
1147
+ if resource.tags is None:
1148
+ resource.tags = {}
1149
+ resource.tags["_created_by"] = creator_info["created_by"]
1150
+ resource.tags["_created_by_type"] = creator_info["created_by_type"]
1151
+ resource.tags["_created_at"] = creator_info["created_at"]
1152
+
1153
+ console.print(f" Found {len(creators)} creation events in CloudTrail")
1154
+ console.print(f" Tagged {matched_count}/{len(snapshot.resources)} resources with creator info")
1155
+ console.print(f" [dim](Resources older than 90 days won't appear in CloudTrail)[/dim]")
1156
+
1157
+ # T018: Check for zero resources after filtering
1158
+ if snapshot.resource_count == 0:
1159
+ console.print("⚠️ Warning: Snapshot contains 0 resources after filtering", style="bold yellow")
1160
+ if resource_filter:
1161
+ console.print(
1162
+ " Your filters may be too restrictive. Consider:\n"
1163
+ " - Adjusting tag filters\n"
1164
+ " - Checking date ranges\n"
1165
+ " - Verifying resources exist in the specified regions",
1166
+ style="yellow",
1167
+ )
1168
+ console.print("\nSnapshot was not saved.\n")
1169
+ raise typer.Exit(code=0)
1170
+
1171
+ # Save snapshot
1172
+ storage = SnapshotStorage(config.storage_path)
1173
+ filepath = storage.save_snapshot(snapshot, compress=compress)
1174
+
1175
+ # T016: Register snapshot with inventory
1176
+ snapshot_filename = filepath.name
1177
+ active_inventory.add_snapshot(snapshot_filename, set_active=set_active)
1178
+ inventory_storage.save(active_inventory)
1179
+
1180
+ # T017: User feedback about inventory
1181
+ console.print(f"\n✓ Added to inventory '[bold]{inventory_name}[/bold]'", style="green")
1182
+ if set_active:
1183
+ console.print(" Marked as active snapshot for this inventory", style="green")
1184
+
1185
+ # Display summary
1186
+ console.print("\n✓ Snapshot complete!", style="bold green")
1187
+ console.print("\nSummary:")
1188
+ console.print(f" Name: {snapshot.name}")
1189
+ console.print(f" Resources: {snapshot.resource_count}")
1190
+ console.print(f" File: {filepath}")
1191
+ console.print(f" Active: {'Yes' if snapshot.is_active else 'No'}")
1192
+
1193
+ # Show collection errors if any
1194
+ collection_errors = snapshot.metadata.get("collection_errors", [])
1195
+ if collection_errors:
1196
+ console.print(f"\n⚠️ Note: {len(collection_errors)} service(s) were unavailable", style="yellow")
1197
+
1198
+ # Show filtering stats if filters were applied
1199
+ if snapshot.filters_applied:
1200
+ stats = snapshot.filters_applied.get("statistics", {})
1201
+ console.print("\nFiltering:")
1202
+ console.print(f" Collected: {stats.get('total_collected', 0)}")
1203
+ console.print(f" Matched filters: {stats.get('final_count', 0)}")
1204
+ console.print(f" Filtered out: {stats.get('total_collected', 0) - stats.get('final_count', 0)}")
1205
+
1206
+ # Show service breakdown
1207
+ if snapshot.service_counts:
1208
+ console.print("\nResources by service:")
1209
+ table = Table(show_header=True)
1210
+ table.add_column("Service", style="cyan")
1211
+ table.add_column("Count", justify="right", style="green")
1212
+
1213
+ for service, count in sorted(snapshot.service_counts.items()):
1214
+ table.add_row(service, str(count))
1215
+
1216
+ console.print(table)
1217
+
1218
+ # Show collection method summary (Config vs Direct API)
1219
+ collection_sources = snapshot.metadata.get("collection_sources", {})
1220
+ config_enabled_regions = snapshot.metadata.get("config_enabled_regions", [])
1221
+
1222
+ if collection_sources:
1223
+ # Count unique sources by method
1224
+ config_types = [t for t, s in collection_sources.items() if s == "config"]
1225
+ direct_types = [t for t, s in collection_sources.items() if s == "direct_api"]
1226
+
1227
+ console.print("\nCollection Method:")
1228
+ if config_enabled_regions:
1229
+ console.print(f" AWS Config: [green]Enabled[/green] in {', '.join(config_enabled_regions)}")
1230
+ console.print(f" [green]Config[/green]: {len(config_types)} resource type(s)")
1231
+ console.print(f" [yellow]Direct API[/yellow]: {len(direct_types)} resource type(s)")
1232
+ else:
1233
+ console.print(" AWS Config: [yellow]Not enabled[/yellow] (using direct API)")
1234
+ console.print(f" Direct API: {len(direct_types)} resource type(s)")
1235
+
1236
+ # Show detailed table only with --verbose
1237
+ if verbose and (config_types or direct_types):
1238
+ console.print()
1239
+ method_table = Table(show_header=True, title="Collection Sources by Resource Type")
1240
+ method_table.add_column("Resource Type", style="cyan")
1241
+ method_table.add_column("Method", style="green")
1242
+ method_table.add_column("Reason", style="dim")
1243
+
1244
+ for resource_type in sorted(collection_sources.keys()):
1245
+ method = collection_sources[resource_type]
1246
+ if method == "config":
1247
+ reason = "Config enabled & type recorded"
1248
+ method_display = "[green]Config[/green]"
1249
+ else:
1250
+ # Determine reason for direct API
1251
+ if not config_enabled_regions:
1252
+ reason = "Config not enabled"
1253
+ else:
1254
+ reason = "Type not recorded by Config"
1255
+ method_display = "[yellow]Direct API[/yellow]"
1256
+ method_table.add_row(resource_type, method_display, reason)
1257
+
1258
+ console.print(method_table)
1259
+ elif not verbose and (config_types or direct_types):
1260
+ console.print("\n [dim]Use --verbose to see detailed breakdown by resource type[/dim]")
1261
+ elif not use_config:
1262
+ console.print("\nCollection Method:")
1263
+ console.print(" All resources collected via Direct API (use --config to enable AWS Config)")
1264
+
1265
+ except typer.Exit:
1266
+ # Re-raise Exit exceptions (normal exit codes)
1267
+ raise
1268
+ except CredentialValidationError as e:
1269
+ console.print(f"✗ Error: {e}", style="bold red")
1270
+ raise typer.Exit(code=3)
1271
+ except Exception as e:
1272
+ console.print(f"✗ Error creating snapshot: {e}", style="bold red")
1273
+ logger.exception("Error in snapshot create command")
1274
+ raise typer.Exit(code=2)
1275
+
1276
+
1277
+ @snapshot_app.command("list")
1278
+ def snapshot_list(profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name")):
1279
+ """List all available snapshots."""
1280
+ try:
1281
+ storage = SnapshotStorage(config.storage_path)
1282
+ snapshots = storage.list_snapshots()
1283
+
1284
+ if not snapshots:
1285
+ console.print("No snapshots found.", style="yellow")
1286
+ return
1287
+
1288
+ # Create table
1289
+ table = Table(show_header=True, title="Available Snapshots")
1290
+ table.add_column("Name", style="cyan")
1291
+ table.add_column("Created", style="green")
1292
+ table.add_column("Size (MB)", justify="right")
1293
+ table.add_column("Active", justify="center")
1294
+
1295
+ for snap in snapshots:
1296
+ active_marker = "✓" if snap["is_active"] else ""
1297
+ table.add_row(
1298
+ snap["name"],
1299
+ snap["modified"].strftime("%Y-%m-%d %H:%M"),
1300
+ f"{snap['size_mb']:.2f}",
1301
+ active_marker,
1302
+ )
1303
+
1304
+ console.print(table)
1305
+ console.print(f"\nTotal snapshots: {len(snapshots)}")
1306
+
1307
+ except Exception as e:
1308
+ console.print(f"✗ Error listing snapshots: {e}", style="bold red")
1309
+ raise typer.Exit(code=1)
1310
+
1311
+
1312
+ @snapshot_app.command("show")
1313
+ def snapshot_show(
1314
+ name: str = typer.Argument(..., help="Snapshot name to display"),
1315
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
1316
+ ):
1317
+ """Display detailed information about a snapshot."""
1318
+ try:
1319
+ storage = SnapshotStorage(config.storage_path)
1320
+ snapshot = storage.load_snapshot(name)
1321
+
1322
+ console.print(f"\n[bold]Snapshot: {snapshot.name}[/bold]")
1323
+ console.print(f"Created: {snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
1324
+ console.print(f"Account: {snapshot.account_id}")
1325
+ console.print(f"Regions: {', '.join(snapshot.regions)}")
1326
+ console.print(f"Status: {'Active baseline' if snapshot.is_active else 'Inactive'}")
1327
+ console.print(f"Total resources: {snapshot.resource_count}\n")
1328
+
1329
+ # Show filters if applied
1330
+ if snapshot.filters_applied:
1331
+ console.print("Filters applied:")
1332
+ date_filters = snapshot.filters_applied.get("date_filters", {})
1333
+ if date_filters.get("before_date"):
1334
+ console.print(f" Before: {date_filters['before_date']}")
1335
+ if date_filters.get("after_date"):
1336
+ console.print(f" After: {date_filters['after_date']}")
1337
+ tag_filters = snapshot.filters_applied.get("tag_filters", {})
1338
+ if tag_filters:
1339
+ console.print(f" Tags: {tag_filters}")
1340
+ console.print()
1341
+
1342
+ # Service breakdown
1343
+ if snapshot.service_counts:
1344
+ console.print("Resources by service:")
1345
+ table = Table(show_header=True)
1346
+ table.add_column("Service", style="cyan")
1347
+ table.add_column("Count", justify="right", style="green")
1348
+ table.add_column("Percent", justify="right")
1349
+
1350
+ for service, count in sorted(snapshot.service_counts.items(), key=lambda x: x[1], reverse=True):
1351
+ percent = (count / snapshot.resource_count * 100) if snapshot.resource_count > 0 else 0
1352
+ table.add_row(service, str(count), f"{percent:.1f}%")
1353
+
1354
+ console.print(table)
1355
+
1356
+ except FileNotFoundError:
1357
+ console.print(f"✗ Snapshot '{name}' not found", style="bold red")
1358
+ raise typer.Exit(code=1)
1359
+ except Exception as e:
1360
+ console.print(f"✗ Error loading snapshot: {e}", style="bold red")
1361
+ raise typer.Exit(code=1)
1362
+
1363
+
1364
+ @snapshot_app.command("set-active")
1365
+ def snapshot_set_active(
1366
+ name: str = typer.Argument(..., help="Snapshot name to set as active"),
1367
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
1368
+ ):
1369
+ """Set a snapshot as the active snapshot.
1370
+
1371
+ The active snapshot is used by default for delta and cost analysis.
1372
+ """
1373
+ try:
1374
+ storage = SnapshotStorage(config.storage_path)
1375
+ storage.set_active_snapshot(name)
1376
+
1377
+ console.print(f"✓ Set [bold]{name}[/bold] as active snapshot", style="green")
1378
+
1379
+ except FileNotFoundError:
1380
+ console.print(f"✗ Snapshot '{name}' not found", style="bold red")
1381
+ raise typer.Exit(code=1)
1382
+ except Exception as e:
1383
+ console.print(f"✗ Error setting active snapshot: {e}", style="bold red")
1384
+ raise typer.Exit(code=1)
1385
+
1386
+
1387
+ @snapshot_app.command("delete")
1388
+ def snapshot_delete(
1389
+ name: str = typer.Argument(..., help="Snapshot name to delete"),
1390
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
1391
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
1392
+ ):
1393
+ """Delete a snapshot.
1394
+
1395
+ Cannot delete the active snapshot - set another snapshot as active first.
1396
+ """
1397
+ try:
1398
+ storage = SnapshotStorage(config.storage_path)
1399
+
1400
+ # Load snapshot to show info
1401
+ snapshot = storage.load_snapshot(name)
1402
+
1403
+ # Confirm deletion
1404
+ if not yes:
1405
+ console.print("\n[yellow]⚠️ About to delete snapshot:[/yellow]")
1406
+ console.print(f" Name: {snapshot.name}")
1407
+ console.print(f" Created: {snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
1408
+ console.print(f" Resources: {snapshot.resource_count}")
1409
+ console.print(f" Active: {'Yes' if snapshot.is_active else 'No'}\n")
1410
+
1411
+ confirm = typer.confirm("Are you sure you want to delete this snapshot?")
1412
+ if not confirm:
1413
+ console.print("Cancelled")
1414
+ raise typer.Exit(code=0)
1415
+
1416
+ # Delete snapshot
1417
+ storage.delete_snapshot(name)
1418
+
1419
+ console.print(f"✓ Deleted snapshot [bold]{name}[/bold]", style="green")
1420
+
1421
+ except FileNotFoundError:
1422
+ console.print(f"✗ Snapshot '{name}' not found", style="bold red")
1423
+ raise typer.Exit(code=1)
1424
+ except ValueError as e:
1425
+ console.print(f"✗ {e}", style="bold red")
1426
+ console.print("\nTip: Set another snapshot as active first:")
1427
+ console.print(" aws-snapshot set-active <other-snapshot-name>")
1428
+ raise typer.Exit(code=1)
1429
+ except Exception as e:
1430
+ console.print(f"✗ Error deleting snapshot: {e}", style="bold red")
1431
+ raise typer.Exit(code=1)
1432
+
1433
+
1434
+ @snapshot_app.command("enrich-creators")
1435
+ def snapshot_enrich_creators(
1436
+ name: Optional[str] = typer.Argument(None, help="Snapshot name (defaults to active snapshot)"),
1437
+ regions: Optional[str] = typer.Option(
1438
+ None, "--regions", help="Comma-separated list of regions to query CloudTrail"
1439
+ ),
1440
+ profile: Optional[str] = typer.Option(
1441
+ None, "--profile", "-p", help="AWS profile name", envvar=["AWSINV_PROFILE", "AWS_PROFILE"]
1442
+ ),
1443
+ days_back: int = typer.Option(
1444
+ 90, "--days", "-d", help="Days to look back in CloudTrail (max 90)"
1445
+ ),
1446
+ ):
1447
+ """Enrich an existing snapshot with creator information from CloudTrail.
1448
+
1449
+ Queries CloudTrail for resource creation events and tags resources with:
1450
+ - _created_by: ARN of the creator (role/user)
1451
+ - _created_by_type: Type of creator (AssumedRole, IAMUser, etc.)
1452
+ - _created_at: When the resource was created
1453
+
1454
+ Example:
1455
+ awsinv snapshot enrich-creators my-snapshot --regions us-east-1,us-west-2
1456
+ awsinv snapshot enrich-creators # uses active snapshot
1457
+ """
1458
+ try:
1459
+ from ..cloudtrail import CloudTrailQuery
1460
+
1461
+ # Validate credentials
1462
+ aws_profile = profile if profile else config.aws_profile
1463
+ console.print("🔐 Validating AWS credentials...")
1464
+ identity = validate_credentials(aws_profile)
1465
+ console.print(f"✓ Authenticated as: {identity['arn']}\n", style="green")
1466
+
1467
+ # Load snapshot
1468
+ storage = SnapshotStorage(config.storage_path)
1469
+
1470
+ if name:
1471
+ snapshot = storage.load_snapshot(name)
1472
+ else:
1473
+ # Get active snapshot
1474
+ active = storage.get_active_snapshot()
1475
+ if not active:
1476
+ console.print("✗ No active snapshot found. Specify a snapshot name.", style="bold red")
1477
+ raise typer.Exit(code=1)
1478
+ snapshot = active
1479
+ name = snapshot.name
1480
+
1481
+ console.print(f"📸 Enriching snapshot: [bold]{name}[/bold]")
1482
+ console.print(f" Resources: {snapshot.resource_count}")
1483
+
1484
+ # Parse regions
1485
+ if regions:
1486
+ region_list = [r.strip() for r in regions.split(",")]
1487
+ else:
1488
+ # Use regions from snapshot metadata if available
1489
+ region_list = snapshot.metadata.get("regions", ["us-east-1"])
1490
+
1491
+ console.print(f" Regions: {', '.join(region_list)}\n")
1492
+
1493
+ # Query CloudTrail for creators with progress
1494
+ from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn
1495
+ from ..cloudtrail.query import EVENT_TO_RESOURCE_TYPE
1496
+
1497
+ console.print("🔍 Querying CloudTrail for resource creators...")
1498
+ console.print(f" Looking back {days_back} days...")
1499
+
1500
+ ct_query = CloudTrailQuery(profile_name=aws_profile, regions=region_list)
1501
+
1502
+ # Count total event types to query
1503
+ event_types = list(EVENT_TO_RESOURCE_TYPE.keys())
1504
+ total_queries = len(event_types) * len(region_list)
1505
+ completed_queries = 0
1506
+ total_events_found = 0
1507
+
1508
+ with Progress(
1509
+ SpinnerColumn(),
1510
+ TextColumn("[progress.description]{task.description}"),
1511
+ BarColumn(),
1512
+ TaskProgressColumn(),
1513
+ console=console,
1514
+ ) as progress:
1515
+ task = progress.add_task(f"[cyan]Querying {len(event_types)} event types...", total=total_queries)
1516
+
1517
+ def progress_callback(event_name: str, events_found: int):
1518
+ nonlocal completed_queries, total_events_found
1519
+ completed_queries += 1
1520
+ total_events_found += events_found
1521
+ progress.update(task, advance=1, description=f"[cyan]{event_name}: {events_found} events")
1522
+
1523
+ creators = ct_query.get_resource_creators(
1524
+ days_back=min(days_back, 90),
1525
+ regions=region_list,
1526
+ progress_callback=progress_callback,
1527
+ )
1528
+
1529
+ console.print(f" Found {len(creators)} unique resource creators\n")
1530
+
1531
+ # Match resources to their creators
1532
+ matched_count = 0
1533
+ for resource in snapshot.resources:
1534
+ # Try to find creator by resource type and name
1535
+ key = f"{resource.resource_type}:{resource.name}"
1536
+ creator_info = creators.get(key)
1537
+
1538
+ # Also try matching by ARN name components
1539
+ if not creator_info and resource.arn:
1540
+ arn_name = resource.arn.split("/")[-1].split(":")[-1]
1541
+ key = f"{resource.resource_type}:{arn_name}"
1542
+ creator_info = creators.get(key)
1543
+
1544
+ if creator_info:
1545
+ matched_count += 1
1546
+ if resource.tags is None:
1547
+ resource.tags = {}
1548
+ resource.tags["_created_by"] = creator_info["created_by"]
1549
+ resource.tags["_created_by_type"] = creator_info["created_by_type"]
1550
+ resource.tags["_created_at"] = creator_info["created_at"]
1551
+
1552
+ # Save updated snapshot by deleting old and re-saving
1553
+ # Need to use snapshot store directly since save_snapshot creates new
1554
+ from ..storage import SnapshotStore
1555
+ snapshot_store = SnapshotStore(storage.db)
1556
+
1557
+ # Delete old snapshot and save updated one
1558
+ snapshot_store.delete(name)
1559
+ snapshot_store.save(snapshot)
1560
+
1561
+ console.print("✓ Enrichment complete!", style="bold green")
1562
+ console.print(f"\n Tagged {matched_count}/{snapshot.resource_count} resources with creator info")
1563
+ console.print(f"\n [dim](Resources older than {days_back} days won't appear in CloudTrail)[/dim]")
1564
+
1565
+ # Show sample of creators found
1566
+ if matched_count > 0:
1567
+ console.print("\n📋 Sample of creators found:")
1568
+ shown = 0
1569
+ for resource in snapshot.resources:
1570
+ if resource.tags and "_created_by" in resource.tags:
1571
+ creator = resource.tags["_created_by"]
1572
+ # Shorten long ARNs
1573
+ if len(creator) > 60:
1574
+ creator = "..." + creator[-57:]
1575
+ console.print(f" {resource.name}: {creator}")
1576
+ shown += 1
1577
+ if shown >= 5:
1578
+ if matched_count > 5:
1579
+ console.print(f" ... and {matched_count - 5} more")
1580
+ break
1581
+
1582
+ except FileNotFoundError:
1583
+ console.print(f"✗ Snapshot '{name}' not found", style="bold red")
1584
+ raise typer.Exit(code=1)
1585
+ except Exception as e:
1586
+ console.print(f"✗ Error enriching snapshot: {e}", style="bold red")
1587
+ raise typer.Exit(code=1)
1588
+
1589
+
1590
+ @snapshot_app.command("rename")
1591
+ def snapshot_rename(
1592
+ old_name: str = typer.Argument(..., help="Current snapshot name"),
1593
+ new_name: str = typer.Argument(..., help="New snapshot name"),
1594
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
1595
+ ):
1596
+ """Rename a snapshot.
1597
+
1598
+ Example:
1599
+ awsinv snapshot rename old-snapshot-name new-snapshot-name
1600
+ """
1601
+ try:
1602
+ storage = SnapshotStorage(config.storage_path)
1603
+
1604
+ # Check if source exists
1605
+ if not storage.exists(old_name):
1606
+ console.print(f"✗ Snapshot '{old_name}' not found", style="bold red")
1607
+ raise typer.Exit(code=1)
1608
+
1609
+ # Check if target already exists
1610
+ if storage.exists(new_name):
1611
+ console.print(f"✗ Snapshot '{new_name}' already exists", style="bold red")
1612
+ raise typer.Exit(code=1)
1613
+
1614
+ # Rename
1615
+ storage.rename_snapshot(old_name, new_name)
1616
+
1617
+ console.print(f"✓ Renamed snapshot [bold]{old_name}[/bold] to [bold]{new_name}[/bold]", style="green")
1618
+
1619
+ except Exception as e:
1620
+ console.print(f"✗ Error renaming snapshot: {e}", style="bold red")
1621
+ raise typer.Exit(code=1)
1622
+
1623
+
1624
+ @snapshot_app.command("report")
1625
+ def snapshot_report(
1626
+ snapshot_name: Optional[str] = typer.Argument(None, help="Snapshot name (default: active snapshot)"),
1627
+ inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (required if multiple exist)"),
1628
+ profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
1629
+ storage_path: Optional[str] = typer.Option(None, "--storage-path", help="Override storage location"),
1630
+ resource_type: Optional[List[str]] = typer.Option(
1631
+ None, "--resource-type", help="Filter by resource type (can specify multiple)"
1632
+ ),
1633
+ region: Optional[List[str]] = typer.Option(None, "--region", help="Filter by region (can specify multiple)"),
1634
+ detailed: bool = typer.Option(
1635
+ False, "--detailed", help="Show detailed resource information (ARN, tags, creation date)"
1636
+ ),
1637
+ page_size: int = typer.Option(100, "--page-size", help="Resources per page in detailed view (default: 100)"),
1638
+ export: Optional[str] = typer.Option(
1639
+ None, "--export", help="Export report to file (format detected from extension: .json, .csv, .txt)"
1640
+ ),
1641
+ ):
1642
+ """Display resource summary report for a snapshot.
1643
+
1644
+ Shows aggregated resource counts by service, region, and type with
1645
+ visual progress bars and formatted output. Can export to JSON, CSV, or TXT formats.
1646
+
1647
+ Snapshot Selection (in order of precedence):
1648
+ 1. Explicit snapshot name argument
1649
+ 2. Most recent snapshot from specified --inventory
1650
+ 3. Active snapshot (set via 'awsinv snapshot set-active')
1651
+
1652
+ Examples:
1653
+ awsinv snapshot report # Report on active snapshot
1654
+ awsinv snapshot report baseline-2025-01 # Report on specific snapshot
1655
+ awsinv snapshot report --inventory prod # Most recent snapshot from 'prod' inventory
1656
+ awsinv snapshot report --resource-type ec2 # Filter by resource type
1657
+ awsinv snapshot report --region us-east-1 # Filter by region
1658
+ awsinv snapshot report --resource-type ec2 --resource-type lambda # Multiple filters
1659
+ awsinv snapshot report --export report.json # Export full report to JSON
1660
+ awsinv snapshot report --export resources.csv # Export resources to CSV
1661
+ awsinv snapshot report --export summary.txt # Export summary to TXT
1662
+ awsinv snapshot report --detailed --export details.json # Export detailed view
1663
+ """
1664
+ from ..models.report import FilterCriteria
1665
+ from ..snapshot.report_formatter import ReportFormatter
1666
+ from ..snapshot.reporter import SnapshotReporter
1667
+ from ..utils.export import detect_format, export_report_csv, export_report_json, export_report_txt
1668
+
1669
+ try:
1670
+ # Use provided storage path or default from config
1671
+ storage = SnapshotStorage(storage_path or config.storage_path)
1672
+
1673
+ # Determine which snapshot to load
1674
+ target_snapshot_name: str
1675
+ if snapshot_name:
1676
+ # Explicit snapshot name provided
1677
+ target_snapshot_name = snapshot_name
1678
+ elif inventory:
1679
+ # Inventory specified - find most recent snapshot from that inventory
1680
+ from datetime import datetime as dt
1681
+ from typing import TypedDict
1682
+
1683
+ class InventorySnapshot(TypedDict):
1684
+ name: str
1685
+ created_at: dt
1686
+
1687
+ all_snapshots = storage.list_snapshots()
1688
+ inventory_snapshots: List[InventorySnapshot] = []
1689
+
1690
+ for snap_meta in all_snapshots:
1691
+ try:
1692
+ snap = storage.load_snapshot(snap_meta["name"])
1693
+ if snap.inventory_name == inventory:
1694
+ inventory_snapshots.append(
1695
+ InventorySnapshot(
1696
+ name=snap.name,
1697
+ created_at=snap.created_at,
1698
+ )
1699
+ )
1700
+ except Exception:
1701
+ continue
1702
+
1703
+ if not inventory_snapshots:
1704
+ console.print(f"✗ No snapshots found for inventory '{inventory}'", style="bold red")
1705
+ console.print("\nCreate a snapshot first:")
1706
+ console.print(f" awsinv snapshot create --inventory {inventory}")
1707
+ raise typer.Exit(code=1)
1708
+
1709
+ # Sort by created_at and pick most recent
1710
+ inventory_snapshots.sort(key=lambda x: x["created_at"], reverse=True)
1711
+ target_snapshot_name = inventory_snapshots[0]["name"]
1712
+ console.print(
1713
+ f"ℹ Using most recent snapshot from inventory '{inventory}': {target_snapshot_name}", style="dim"
1714
+ )
1715
+ else:
1716
+ # Try to get active snapshot
1717
+ active_name = storage.get_active_snapshot_name()
1718
+ if not active_name:
1719
+ console.print("✗ No active snapshot found", style="bold red")
1720
+ console.print("\nSet an active snapshot with:")
1721
+ console.print(" awsinv snapshot set-active <name>")
1722
+ console.print("\nOr specify a snapshot explicitly:")
1723
+ console.print(" awsinv snapshot report <snapshot-name>")
1724
+ console.print("\nOr specify an inventory to use the most recent snapshot:")
1725
+ console.print(" awsinv snapshot report --inventory <inventory-name>")
1726
+ raise typer.Exit(code=1)
1727
+ target_snapshot_name = active_name
1728
+
1729
+ # Load the snapshot
1730
+ try:
1731
+ snapshot = storage.load_snapshot(target_snapshot_name)
1732
+ except FileNotFoundError:
1733
+ console.print(f"✗ Snapshot '{target_snapshot_name}' not found", style="bold red")
1734
+
1735
+ # Show available snapshots
1736
+ try:
1737
+ all_snapshots = storage.list_snapshots()
1738
+ if all_snapshots:
1739
+ console.print("\nAvailable snapshots:")
1740
+ for snap_name in all_snapshots[:5]:
1741
+ console.print(f" • {snap_name}")
1742
+ if len(all_snapshots) > 5:
1743
+ console.print(f" ... and {len(all_snapshots) - 5} more")
1744
+ console.print("\nRun 'awsinv snapshot list' to see all snapshots.")
1745
+ except Exception:
1746
+ pass
1747
+
1748
+ raise typer.Exit(code=1)
1749
+
1750
+ # Handle empty snapshot
1751
+ if snapshot.resource_count == 0:
1752
+ console.print(f"⚠️ Warning: Snapshot '{snapshot.name}' contains 0 resources", style="yellow")
1753
+ console.print("\nNo report to generate.")
1754
+ raise typer.Exit(code=0)
1755
+
1756
+ # Create filter criteria if filters provided
1757
+ has_filters = bool(resource_type or region)
1758
+ criteria = None
1759
+ if has_filters:
1760
+ criteria = FilterCriteria(
1761
+ resource_types=resource_type if resource_type else None,
1762
+ regions=region if region else None,
1763
+ )
1764
+
1765
+ # Generate report
1766
+ reporter = SnapshotReporter(snapshot)
1767
+ metadata = reporter._extract_metadata()
1768
+
1769
+ # Detailed view vs Summary view
1770
+ if detailed:
1771
+ # Get detailed resources (with optional filtering)
1772
+ detailed_resources = list(reporter.get_detailed_resources(criteria))
1773
+
1774
+ # Export mode
1775
+ if export:
1776
+ try:
1777
+ # Detect format from file extension
1778
+ export_format = detect_format(export)
1779
+
1780
+ # Export based on format
1781
+ if export_format == "json":
1782
+ # For JSON, export full report structure with detailed resources
1783
+ summary = (
1784
+ reporter.generate_filtered_summary(criteria) if criteria else reporter.generate_summary()
1785
+ )
1786
+ export_path = export_report_json(export, metadata, summary, detailed_resources)
1787
+ console.print(
1788
+ f"✓ Exported {len(detailed_resources):,} resources to JSON: {export_path}",
1789
+ style="bold green",
1790
+ )
1791
+ elif export_format == "csv":
1792
+ # For CSV, export detailed resources
1793
+ export_path = export_report_csv(export, detailed_resources)
1794
+ console.print(
1795
+ f"✓ Exported {len(detailed_resources):,} resources to CSV: {export_path}",
1796
+ style="bold green",
1797
+ )
1798
+ elif export_format == "txt":
1799
+ # For TXT, export summary (detailed view doesn't make sense for plain text)
1800
+ summary = (
1801
+ reporter.generate_filtered_summary(criteria) if criteria else reporter.generate_summary()
1802
+ )
1803
+ export_path = export_report_txt(export, metadata, summary)
1804
+ console.print(f"✓ Exported summary to TXT: {export_path}", style="bold green")
1805
+ except FileExistsError as e:
1806
+ console.print(f"✗ {e}", style="bold red")
1807
+ console.print("\nUse a different filename or delete the existing file.", style="yellow")
1808
+ raise typer.Exit(code=1)
1809
+ except FileNotFoundError as e:
1810
+ console.print(f"✗ {e}", style="bold red")
1811
+ raise typer.Exit(code=1)
1812
+ except ValueError as e:
1813
+ console.print(f"✗ {e}", style="bold red")
1814
+ raise typer.Exit(code=1)
1815
+ else:
1816
+ # Display mode - show filter information if applied
1817
+ if criteria:
1818
+ console.print("\n[bold cyan]Filters Applied:[/bold cyan]")
1819
+ if resource_type:
1820
+ console.print(f" • Resource Types: {', '.join(resource_type)}")
1821
+ if region:
1822
+ console.print(f" • Regions: {', '.join(region)}")
1823
+ console.print(
1824
+ f" • Matching Resources: {len(detailed_resources):,} (of {snapshot.resource_count:,} total)\n"
1825
+ )
1826
+
1827
+ # Format and display detailed view
1828
+ formatter = ReportFormatter(console)
1829
+ formatter.format_detailed(metadata, detailed_resources, page_size=page_size)
1830
+ else:
1831
+ # Generate summary (filtered or full)
1832
+ if criteria:
1833
+ summary = reporter.generate_filtered_summary(criteria)
1834
+ else:
1835
+ summary = reporter.generate_summary()
1836
+
1837
+ # Export mode
1838
+ if export:
1839
+ try:
1840
+ # Detect format from file extension
1841
+ export_format = detect_format(export)
1842
+
1843
+ # Export based on format
1844
+ if export_format == "json":
1845
+ # For JSON, export full report structure
1846
+ # Get all resources for complete export
1847
+ all_resources = list(reporter.get_detailed_resources(criteria))
1848
+ export_path = export_report_json(export, metadata, summary, all_resources)
1849
+ console.print(
1850
+ f"✓ Exported {summary.total_count:,} resources to JSON: {export_path}", style="bold green"
1851
+ )
1852
+ elif export_format == "csv":
1853
+ # For CSV, export resources
1854
+ all_resources = list(reporter.get_detailed_resources(criteria))
1855
+ export_path = export_report_csv(export, all_resources)
1856
+ console.print(
1857
+ f"✓ Exported {len(all_resources):,} resources to CSV: {export_path}", style="bold green"
1858
+ )
1859
+ elif export_format == "txt":
1860
+ # For TXT, export summary only
1861
+ export_path = export_report_txt(export, metadata, summary)
1862
+ console.print(f"✓ Exported summary to TXT: {export_path}", style="bold green")
1863
+ except FileExistsError as e:
1864
+ console.print(f"✗ {e}", style="bold red")
1865
+ console.print("\nUse a different filename or delete the existing file.", style="yellow")
1866
+ raise typer.Exit(code=1)
1867
+ except FileNotFoundError as e:
1868
+ console.print(f"✗ {e}", style="bold red")
1869
+ raise typer.Exit(code=1)
1870
+ except ValueError as e:
1871
+ console.print(f"✗ {e}", style="bold red")
1872
+ raise typer.Exit(code=1)
1873
+ else:
1874
+ # Display mode - show filter information
1875
+ if criteria:
1876
+ console.print("\n[bold cyan]Filters Applied:[/bold cyan]")
1877
+ if resource_type:
1878
+ console.print(f" • Resource Types: {', '.join(resource_type)}")
1879
+ if region:
1880
+ console.print(f" • Regions: {', '.join(region)}")
1881
+ console.print(
1882
+ f" • Matching Resources: {summary.total_count:,} (of {snapshot.resource_count:,} total)\n"
1883
+ )
1884
+
1885
+ # Format and display summary report
1886
+ formatter = ReportFormatter(console)
1887
+ formatter.format_summary(metadata, summary, has_filters=has_filters)
1888
+
1889
+ except typer.Exit:
1890
+ raise
1891
+ except Exception as e:
1892
+ console.print(f"✗ Error generating report: {e}", style="bold red")
1893
+ logger.exception("Error in snapshot report command")
1894
+ raise typer.Exit(code=2)
1895
+
1896
+
1897
+ @app.command()
1898
+ def delta(
1899
+ snapshot: Optional[str] = typer.Option(
1900
+ None, "--snapshot", help="Baseline snapshot name (default: active from inventory)"
1901
+ ),
1902
+ inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (default: 'default')"),
1903
+ resource_type: Optional[str] = typer.Option(None, "--resource-type", help="Filter by resource type"),
1904
+ region: Optional[str] = typer.Option(None, "--region", help="Filter by region"),
1905
+ show_details: bool = typer.Option(False, "--show-details", help="Show detailed resource information"),
1906
+ show_diff: bool = typer.Option(False, "--show-diff", help="Show field-level configuration differences"),
1907
+ export: Optional[str] = typer.Option(None, "--export", help="Export to file (JSON or CSV based on extension)"),
1908
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
1909
+ ):
1910
+ """View resource changes since snapshot.
1911
+
1912
+ Compares current AWS state to the snapshot and shows added, deleted,
1913
+ and modified resources. Use --show-diff to see field-level configuration changes.
1914
+ """
1915
+ try:
1916
+ # T021: Get inventory and use its active snapshot
1917
+ from ..aws.credentials import validate_credentials
1918
+ from ..snapshot.inventory_storage import InventoryStorage
1919
+
1920
+ # Use profile parameter if provided, otherwise use config
1921
+ aws_profile = profile if profile else config.aws_profile
1922
+
1923
+ # Validate credentials to get account ID
1924
+ identity = validate_credentials(aws_profile)
1925
+
1926
+ # Load inventory
1927
+ inventory_storage = InventoryStorage(config.storage_path)
1928
+ inventory_name = inventory if inventory else "default"
1929
+
1930
+ if inventory:
1931
+ try:
1932
+ active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
1933
+ except Exception:
1934
+ # T024: Inventory doesn't exist
1935
+ console.print(
1936
+ f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
1937
+ )
1938
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
1939
+ raise typer.Exit(code=1)
1940
+ else:
1941
+ # Get or create default inventory
1942
+ active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
1943
+ inventory_name = "default"
1944
+
1945
+ # T026: User feedback about inventory
1946
+ console.print(f"📦 Using inventory: [bold]{inventory_name}[/bold]", style="cyan")
1947
+
1948
+ # T024, T025: Validate inventory has snapshots and active snapshot
1949
+ if not active_inventory.snapshots:
1950
+ console.print(f"✗ No snapshots exist in inventory '{inventory_name}'", style="bold red")
1951
+ console.print(f" Take a snapshot first: aws-snapshot create --inventory {inventory_name}", style="yellow")
1952
+ raise typer.Exit(code=1)
1953
+
1954
+ # Load snapshot
1955
+ storage = SnapshotStorage(config.storage_path)
1956
+
1957
+ if snapshot:
1958
+ # User specified a snapshot explicitly
1959
+ reference_snapshot = storage.load_snapshot(snapshot)
1960
+ else:
1961
+ # Use inventory's active snapshot
1962
+ if not active_inventory.active_snapshot:
1963
+ console.print(f"✗ No active snapshot in inventory '{inventory_name}'", style="bold red")
1964
+ console.print(
1965
+ f" Take a snapshot or set one as active: " f"aws-snapshot create --inventory {inventory_name}",
1966
+ style="yellow",
1967
+ )
1968
+ raise typer.Exit(code=1)
1969
+
1970
+ # Load the active snapshot (strip .yaml extension if present)
1971
+ snapshot_name = active_inventory.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
1972
+ reference_snapshot = storage.load_snapshot(snapshot_name)
1973
+
1974
+ console.print(f"🔍 Comparing to baseline: [bold]{reference_snapshot.name}[/bold]")
1975
+ console.print(f" Created: {reference_snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
1976
+
1977
+ # Prepare filters
1978
+ resource_type_filter = [resource_type] if resource_type else None
1979
+ region_filter = [region] if region else None
1980
+
1981
+ # Use profile parameter if provided, otherwise use config
1982
+ aws_profile = profile if profile else config.aws_profile
1983
+
1984
+ # Calculate delta
1985
+ from ..delta.calculator import compare_to_current_state
1986
+
1987
+ delta_report = compare_to_current_state(
1988
+ reference_snapshot,
1989
+ profile_name=aws_profile,
1990
+ regions=None, # Use reference snapshot regions
1991
+ resource_type_filter=resource_type_filter,
1992
+ region_filter=region_filter,
1993
+ include_drift_details=show_diff,
1994
+ )
1995
+
1996
+ # Display delta
1997
+ from ..delta.reporter import DeltaReporter
1998
+
1999
+ reporter = DeltaReporter(console)
2000
+ reporter.display(delta_report, show_details=show_details)
2001
+
2002
+ # Export if requested
2003
+ if export:
2004
+ if export.endswith(".json"):
2005
+ reporter.export_json(delta_report, export)
2006
+ elif export.endswith(".csv"):
2007
+ reporter.export_csv(delta_report, export)
2008
+ else:
2009
+ console.print("✗ Unsupported export format. Use .json or .csv", style="bold red")
2010
+ raise typer.Exit(code=1)
2011
+
2012
+ # Exit with code 0 if no changes (for scripting)
2013
+ if not delta_report.has_changes:
2014
+ raise typer.Exit(code=0)
2015
+
2016
+ except typer.Exit:
2017
+ # Re-raise Exit exceptions (normal exit codes)
2018
+ raise
2019
+ except FileNotFoundError as e:
2020
+ console.print(f"✗ Snapshot not found: {e}", style="bold red")
2021
+ raise typer.Exit(code=1)
2022
+ except Exception as e:
2023
+ console.print(f"✗ Error calculating delta: {e}", style="bold red")
2024
+ logger.exception("Error in delta command")
2025
+ raise typer.Exit(code=2)
2026
+
2027
+
2028
+ @app.command()
2029
+ def cost(
2030
+ snapshot: Optional[str] = typer.Option(
2031
+ None, "--snapshot", help="Baseline snapshot name (default: active from inventory)"
2032
+ ),
2033
+ inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (default: 'default')"),
2034
+ start_date: Optional[str] = typer.Option(
2035
+ None, "--start-date", help="Start date (YYYY-MM-DD, default: snapshot date)"
2036
+ ),
2037
+ end_date: Optional[str] = typer.Option(None, "--end-date", help="End date (YYYY-MM-DD, default: today)"),
2038
+ granularity: str = typer.Option("MONTHLY", "--granularity", help="Cost granularity: DAILY or MONTHLY"),
2039
+ show_services: bool = typer.Option(True, "--show-services/--no-services", help="Show service breakdown"),
2040
+ export: Optional[str] = typer.Option(None, "--export", help="Export to file (JSON or CSV based on extension)"),
2041
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
2042
+ ):
2043
+ """Analyze costs for resources in a specific inventory.
2044
+
2045
+ Shows costs for resources captured in the inventory's active snapshot,
2046
+ enabling per-team, per-environment, or per-project cost tracking.
2047
+ """
2048
+ try:
2049
+ # T020: Get inventory and use its active snapshot
2050
+ from ..aws.credentials import validate_credentials
2051
+ from ..snapshot.inventory_storage import InventoryStorage
2052
+
2053
+ # Use profile parameter if provided, otherwise use config
2054
+ aws_profile = profile if profile else config.aws_profile
2055
+
2056
+ # Validate credentials to get account ID
2057
+ identity = validate_credentials(aws_profile)
2058
+
2059
+ # Load inventory
2060
+ inventory_storage = InventoryStorage(config.storage_path)
2061
+ inventory_name = inventory if inventory else "default"
2062
+
2063
+ if inventory:
2064
+ try:
2065
+ active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
2066
+ except Exception:
2067
+ # T022: Inventory doesn't exist
2068
+ console.print(
2069
+ f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
2070
+ )
2071
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
2072
+ raise typer.Exit(code=1)
2073
+ else:
2074
+ # Get or create default inventory
2075
+ active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
2076
+ inventory_name = "default"
2077
+
2078
+ # T026: User feedback about inventory
2079
+ console.print(f"📦 Using inventory: [bold]{inventory_name}[/bold]", style="cyan")
2080
+
2081
+ # T022, T023: Validate inventory has snapshots and active snapshot
2082
+ if not active_inventory.snapshots:
2083
+ console.print(f"✗ No snapshots exist in inventory '{inventory_name}'", style="bold red")
2084
+ console.print(f" Take a snapshot first: aws-snapshot create --inventory {inventory_name}", style="yellow")
2085
+ raise typer.Exit(code=1)
2086
+
2087
+ # Load snapshot
2088
+ storage = SnapshotStorage(config.storage_path)
2089
+
2090
+ if snapshot:
2091
+ # User specified a snapshot explicitly
2092
+ reference_snapshot = storage.load_snapshot(snapshot)
2093
+ else:
2094
+ # Use inventory's active snapshot
2095
+ if not active_inventory.active_snapshot:
2096
+ console.print(f"✗ No active snapshot in inventory '{inventory_name}'", style="bold red")
2097
+ console.print(
2098
+ f" Take a snapshot or set one as active: " f"aws-snapshot create --inventory {inventory_name}",
2099
+ style="yellow",
2100
+ )
2101
+ raise typer.Exit(code=1)
2102
+
2103
+ # Load the active snapshot (strip .yaml extension if present)
2104
+ snapshot_name = active_inventory.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
2105
+ reference_snapshot = storage.load_snapshot(snapshot_name)
2106
+
2107
+ console.print(f"💰 Analyzing costs for snapshot: [bold]{reference_snapshot.name}[/bold]\n")
2108
+
2109
+ # Parse dates
2110
+ from datetime import datetime as dt
2111
+
2112
+ start_dt = None
2113
+ end_dt = None
2114
+
2115
+ if start_date:
2116
+ try:
2117
+ # Parse as UTC timezone-aware
2118
+ from datetime import timezone
2119
+
2120
+ start_dt = dt.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
2121
+ except ValueError:
2122
+ console.print("✗ Invalid start date format. Use YYYY-MM-DD (UTC)", style="bold red")
2123
+ raise typer.Exit(code=1)
2124
+
2125
+ if end_date:
2126
+ try:
2127
+ # Parse as UTC timezone-aware
2128
+ from datetime import timezone
2129
+
2130
+ end_dt = dt.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
2131
+ except ValueError:
2132
+ console.print("✗ Invalid end date format. Use YYYY-MM-DD (UTC)", style="bold red")
2133
+ raise typer.Exit(code=1)
2134
+
2135
+ # Validate granularity
2136
+ if granularity not in ["DAILY", "MONTHLY"]:
2137
+ console.print("✗ Invalid granularity. Use DAILY or MONTHLY", style="bold red")
2138
+ raise typer.Exit(code=1)
2139
+
2140
+ # Use profile parameter if provided, otherwise use config
2141
+ aws_profile = profile if profile else config.aws_profile
2142
+
2143
+ # First, check if there are any deltas (new resources)
2144
+ console.print("🔍 Checking for resource changes since snapshot...\n")
2145
+ from ..delta.calculator import compare_to_current_state
2146
+
2147
+ delta_report = compare_to_current_state(
2148
+ reference_snapshot,
2149
+ profile_name=aws_profile,
2150
+ regions=None,
2151
+ )
2152
+
2153
+ # Analyze costs
2154
+ from ..cost.analyzer import CostAnalyzer
2155
+ from ..cost.explorer import CostExplorerClient, CostExplorerError
2156
+
2157
+ try:
2158
+ cost_explorer = CostExplorerClient(profile_name=aws_profile)
2159
+ analyzer = CostAnalyzer(cost_explorer)
2160
+
2161
+ # If no changes, only show baseline costs (no splitting)
2162
+ has_deltas = delta_report.has_changes
2163
+
2164
+ cost_report = analyzer.analyze(
2165
+ reference_snapshot,
2166
+ start_date=start_dt,
2167
+ end_date=end_dt,
2168
+ granularity=granularity,
2169
+ has_deltas=has_deltas,
2170
+ delta_report=delta_report,
2171
+ )
2172
+
2173
+ # Display cost report
2174
+ from ..cost.reporter import CostReporter
2175
+
2176
+ reporter = CostReporter(console)
2177
+ reporter.display(cost_report, show_services=show_services, has_deltas=has_deltas)
2178
+
2179
+ # Export if requested
2180
+ if export:
2181
+ if export.endswith(".json"):
2182
+ reporter.export_json(cost_report, export)
2183
+ elif export.endswith(".csv"):
2184
+ reporter.export_csv(cost_report, export)
2185
+ else:
2186
+ console.print("✗ Unsupported export format. Use .json or .csv", style="bold red")
2187
+ raise typer.Exit(code=1)
2188
+
2189
+ except CostExplorerError as e:
2190
+ console.print(f"✗ Cost Explorer error: {e}", style="bold red")
2191
+ console.print("\nTroubleshooting:")
2192
+ console.print(" 1. Ensure Cost Explorer is enabled in your AWS account")
2193
+ console.print(" 2. Check IAM permissions: ce:GetCostAndUsage")
2194
+ console.print(" 3. Cost data typically has a 24-48 hour lag")
2195
+ raise typer.Exit(code=3)
2196
+
2197
+ except typer.Exit:
2198
+ # Re-raise Exit exceptions (normal exit codes)
2199
+ raise
2200
+ except FileNotFoundError as e:
2201
+ console.print(f"✗ Snapshot not found: {e}", style="bold red")
2202
+ raise typer.Exit(code=1)
2203
+ except Exception as e:
2204
+ console.print(f"✗ Error analyzing costs: {e}", style="bold red")
2205
+ logger.exception("Error in cost command")
2206
+ raise typer.Exit(code=2)
2207
+
2208
+
2209
+ # ============================================================================
2210
+ # Security Commands
2211
+ # ============================================================================
2212
+
2213
+ security_app = typer.Typer(help="Security scanning and compliance checking commands")
2214
+
2215
+
2216
+ @security_app.command(name="scan")
2217
+ def security_scan(
2218
+ snapshot: Optional[str] = typer.Option(None, "--snapshot", "-s", help="Snapshot name to scan"),
2219
+ inventory: Optional[str] = typer.Option(None, "--inventory", "-i", help="Inventory name (uses active snapshot)"),
2220
+ storage_dir: Optional[str] = typer.Option(None, "--storage-dir", help="Snapshot storage directory"),
2221
+ severity: Optional[str] = typer.Option(None, "--severity", help="Filter by severity: critical, high, medium, low"),
2222
+ export: Optional[str] = typer.Option(None, "--export", help="Export findings to file"),
2223
+ format: str = typer.Option("json", "--format", "-f", help="Export format: json or csv"),
2224
+ cis_only: bool = typer.Option(False, "--cis-only", help="Show only findings with CIS Benchmark mappings"),
2225
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
2226
+ ):
2227
+ """Scan a snapshot for security misconfigurations and compliance issues.
2228
+
2229
+ Performs comprehensive security checks including:
2230
+ - Public S3 buckets
2231
+ - Open security groups (SSH, RDP, databases)
2232
+ - Publicly accessible RDS instances
2233
+ - EC2 instances with IMDSv1 enabled
2234
+ - IAM credentials older than 90 days
2235
+ - Secrets Manager secrets not rotated in 90+ days
2236
+
2237
+ Examples:
2238
+ # Scan a specific snapshot
2239
+ awsinv security scan --snapshot my-snapshot
2240
+
2241
+ # Scan with severity filter
2242
+ awsinv security scan --snapshot my-snapshot --severity critical
2243
+
2244
+ # Export findings to JSON
2245
+ awsinv security scan --snapshot my-snapshot --export findings.json
2246
+
2247
+ # Export to CSV
2248
+ awsinv security scan --snapshot my-snapshot --export findings.csv --format csv
2249
+
2250
+ # Show only CIS-mapped findings
2251
+ awsinv security scan --snapshot my-snapshot --cis-only
2252
+ """
2253
+ from ..security.cis_mapper import CISMapper
2254
+ from ..security.reporter import SecurityReporter
2255
+ from ..security.scanner import SecurityScanner
2256
+ from ..snapshot.inventory_storage import InventoryStorage
2257
+
2258
+ try:
2259
+ # Determine which snapshot to scan
2260
+ if not snapshot and not inventory:
2261
+ console.print("✗ Error: Must specify either --snapshot or --inventory", style="bold red")
2262
+ raise typer.Exit(code=1)
2263
+
2264
+ # Use profile parameter if provided, otherwise use config
2265
+ aws_profile = profile if profile else config.aws_profile
2266
+
2267
+ # Load snapshot
2268
+ storage = SnapshotStorage(storage_dir or config.storage_path)
2269
+
2270
+ if inventory:
2271
+ # Load active snapshot from inventory
2272
+ # Need AWS credentials to get account ID
2273
+ identity = validate_credentials(aws_profile)
2274
+ inv_storage = InventoryStorage(storage_dir or config.storage_path)
2275
+ inv = inv_storage.get_by_name(inventory, identity["account_id"])
2276
+ if not inv.active_snapshot:
2277
+ console.print(
2278
+ f"✗ Error: Inventory '{inventory}' has no active snapshot. "
2279
+ f"Use 'awsinv snapshot set-active' to set one.",
2280
+ style="bold red",
2281
+ )
2282
+ raise typer.Exit(code=1)
2283
+ # Strip .yaml or .yaml.gz extension if present
2284
+ snapshot_name = inv.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
2285
+ snapshot_obj = storage.load_snapshot(snapshot_name)
2286
+ else:
2287
+ snapshot_obj = storage.load_snapshot(snapshot) # type: ignore
2288
+
2289
+ console.print(f"\n🔍 Scanning snapshot: [bold cyan]{snapshot_obj.name}[/bold cyan]\n")
2290
+
2291
+ # Parse severity filter
2292
+ severity_filter = None
2293
+ if severity:
2294
+ from ..models.security_finding import Severity
2295
+
2296
+ severity_map = {
2297
+ "critical": Severity.CRITICAL,
2298
+ "high": Severity.HIGH,
2299
+ "medium": Severity.MEDIUM,
2300
+ "low": Severity.LOW,
2301
+ }
2302
+ severity_filter = severity_map.get(severity.lower())
2303
+ if not severity_filter:
2304
+ console.print(f"✗ Invalid severity: {severity}. Must be: critical, high, medium, low", style="bold red")
2305
+ raise typer.Exit(code=1)
2306
+
2307
+ # Run security scan
2308
+ scanner = SecurityScanner()
2309
+ result = scanner.scan(snapshot_obj, severity_filter=severity_filter)
2310
+
2311
+ # Filter CIS-only if requested
2312
+ findings_to_report = result.findings
2313
+ if cis_only:
2314
+ findings_to_report = [f for f in result.findings if f.cis_control is not None]
2315
+
2316
+ # Display results
2317
+ reporter = SecurityReporter()
2318
+
2319
+ if len(findings_to_report) == 0:
2320
+ console.print("✓ [bold green]No security issues found![/bold green]\n")
2321
+ if severity_filter:
2322
+ console.print(f" (Filtered by severity: {severity})")
2323
+ if cis_only:
2324
+ console.print(" (Showing only CIS-mapped findings)")
2325
+ else:
2326
+ # Generate summary
2327
+ summary = reporter.generate_summary(findings_to_report)
2328
+
2329
+ console.print(f"[bold red]✗ Found {summary['total_findings']} security issue(s)[/bold red]\n")
2330
+ console.print(
2331
+ f" Critical: {summary['critical_count']} "
2332
+ f"High: {summary['high_count']} "
2333
+ f"Medium: {summary['medium_count']} "
2334
+ f"Low: {summary['low_count']}\n"
2335
+ )
2336
+
2337
+ # Display findings
2338
+ output = reporter.format_terminal(findings_to_report)
2339
+ console.print(output)
2340
+
2341
+ # Show CIS summary
2342
+ cis_mapper = CISMapper()
2343
+ cis_summary = cis_mapper.get_summary(findings_to_report)
2344
+
2345
+ if cis_summary["total_controls_checked"] > 0:
2346
+ console.print("\n[bold]CIS Benchmark Summary:[/bold]")
2347
+ console.print(
2348
+ f" Controls checked: {cis_summary['total_controls_checked']} "
2349
+ f"Failed: {cis_summary['controls_failed']} "
2350
+ f"Passed: {cis_summary['controls_passed']}"
2351
+ )
2352
+
2353
+ # Export if requested
2354
+ if export:
2355
+ if format.lower() == "json":
2356
+ reporter.export_json(findings_to_report, export)
2357
+ console.print(f"\n✓ Exported findings to: [cyan]{export}[/cyan] (JSON)")
2358
+ elif format.lower() == "csv":
2359
+ reporter.export_csv(findings_to_report, export)
2360
+ console.print(f"\n✓ Exported findings to: [cyan]{export}[/cyan] (CSV)")
2361
+ else:
2362
+ console.print(f"✗ Invalid format: {format}. Must be 'json' or 'csv'", style="bold red")
2363
+ raise typer.Exit(code=1)
2364
+
2365
+ except typer.Exit:
2366
+ # Re-raise Typer exit codes (for early returns like missing params)
2367
+ raise
2368
+ except FileNotFoundError as e:
2369
+ console.print(f"✗ Snapshot not found: {e}", style="bold red")
2370
+ raise typer.Exit(code=1)
2371
+ except Exception as e:
2372
+ console.print(f"✗ Error during security scan: {e}", style="bold red")
2373
+ logger.exception("Error in security scan command")
2374
+ raise typer.Exit(code=2)
2375
+
2376
+
2377
+ app.add_typer(security_app, name="security")
2378
+
2379
+
2380
+ # Cleanup commands (destructive operations)
2381
+ cleanup_app = typer.Typer(help="Delete resources - returns environment to baseline or removes unprotected resources")
2382
+
2383
+
2384
+ @cleanup_app.command("preview")
2385
+ def cleanup_preview(
2386
+ baseline_snapshot: str = typer.Argument(..., help="Baseline snapshot - resources created after this will be deleted"),
2387
+ account_id: str = typer.Option(None, "--account-id", help="AWS account ID (auto-detected if not provided)"),
2388
+ profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
2389
+ resource_types: Optional[List[str]] = typer.Option(
2390
+ None, "--type", help="Filter by resource types (e.g., AWS::EC2::Instance)"
2391
+ ),
2392
+ regions: Optional[List[str]] = typer.Option(None, "--region", help="Filter by AWS regions"),
2393
+ protect_tags: Optional[List[str]] = typer.Option(
2394
+ None, "--protect-tag", help="Protect resources with tag (format: key=value, can repeat)"
2395
+ ),
2396
+ config_file: Optional[str] = typer.Option(
2397
+ None, "--config", help="Path to protection rules config file"
2398
+ ),
2399
+ output_format: str = typer.Option("table", "--format", help="Output format: table, json, yaml"),
2400
+ ):
2401
+ """Preview resources that would be DELETED to return to a baseline snapshot.
2402
+
2403
+ Shows what resources have been created since the snapshot without
2404
+ performing any deletions. This is a safe dry-run operation.
2405
+
2406
+ Examples:
2407
+ # Preview resources created since a baseline snapshot
2408
+ awsinv cleanup preview prod-baseline
2409
+
2410
+ # Preview with tag-based protection
2411
+ awsinv cleanup preview my-snapshot --protect-tag "project=baseline"
2412
+
2413
+ # Preview with multiple protection tags
2414
+ awsinv cleanup preview my-snapshot --protect-tag "project=baseline" --protect-tag "env=prod"
2415
+
2416
+ # Preview with config file
2417
+ awsinv cleanup preview my-snapshot --config .awsinv-cleanup.yaml
2418
+
2419
+ # Preview only EC2 instances in us-east-1
2420
+ awsinv cleanup preview my-snapshot --type AWS::EC2::Instance --region us-east-1
2421
+ """
2422
+ from ..aws.credentials import get_account_id
2423
+ from ..restore.audit import AuditStorage
2424
+ from ..restore.cleaner import ResourceCleaner
2425
+ from ..restore.config import build_protection_rules, load_config_file
2426
+ from ..restore.safety import SafetyChecker
2427
+
2428
+ try:
2429
+ console.print("\n[bold cyan]🔍 Previewing Resource Cleanup[/bold cyan]\n")
2430
+
2431
+ # Auto-detect account ID if not provided
2432
+ if not account_id:
2433
+ try:
2434
+ account_id = get_account_id(profile_name=profile)
2435
+ console.print(f"[dim]Detected account ID: {account_id}[/dim]")
2436
+ except Exception as e:
2437
+ console.print(f"[red]Error detecting account ID: {e}[/red]")
2438
+ console.print("[yellow]Please provide --account-id explicitly[/yellow]")
2439
+ raise typer.Exit(code=1)
2440
+
2441
+ # Load config and build protection rules
2442
+ config = load_config_file(config_file)
2443
+ protection_rules = build_protection_rules(config, protect_tags)
2444
+
2445
+ if protection_rules:
2446
+ console.print(f"[dim]Loaded {len(protection_rules)} protection rule(s)[/dim]")
2447
+
2448
+ # Initialize components
2449
+ snapshot_storage = SnapshotStorage()
2450
+ safety_checker = SafetyChecker(rules=protection_rules)
2451
+ audit_storage = AuditStorage()
2452
+
2453
+ cleaner = ResourceCleaner(
2454
+ snapshot_storage=snapshot_storage,
2455
+ safety_checker=safety_checker,
2456
+ audit_storage=audit_storage,
2457
+ )
2458
+
2459
+ # Run preview
2460
+ with console.status("[bold green]Analyzing resources..."):
2461
+ operation = cleaner.preview(
2462
+ baseline_snapshot=baseline_snapshot,
2463
+ account_id=account_id,
2464
+ aws_profile=profile,
2465
+ resource_types=resource_types,
2466
+ regions=regions,
2467
+ )
2468
+
2469
+ # Display results
2470
+ console.print("\n[bold green]✓ Preview Complete[/bold green]\n")
2471
+
2472
+ # Summary panel
2473
+ summary_text = f"""
2474
+ [bold]Operation ID:[/bold] {operation.operation_id}
2475
+ [bold]Baseline Snapshot:[/bold] {operation.baseline_snapshot}
2476
+ [bold]Account ID:[/bold] {operation.account_id}
2477
+ [bold]Mode:[/bold] DRY-RUN (preview only)
2478
+ [bold]Status:[/bold] {operation.status.value.upper()}
2479
+
2480
+ [bold cyan]Resources Identified:[/bold cyan]
2481
+ • Total: {operation.total_resources}
2482
+ • Would be deleted: {operation.total_resources - operation.skipped_count}
2483
+ • Protected (skipped): {operation.skipped_count}
2484
+ """
2485
+
2486
+ if operation.filters:
2487
+ filter_text = "\n[bold]Filters Applied:[/bold]"
2488
+ if operation.filters.get("resource_types"):
2489
+ filter_text += f"\n• Types: {', '.join(operation.filters['resource_types'])}"
2490
+ if operation.filters.get("regions"):
2491
+ filter_text += f"\n• Regions: {', '.join(operation.filters['regions'])}"
2492
+ summary_text += filter_text
2493
+
2494
+ console.print(Panel(summary_text.strip(), title="[bold]Preview Summary[/bold]", border_style="cyan"))
2495
+
2496
+ # Warning if resources would be deleted
2497
+ if operation.total_resources > operation.skipped_count:
2498
+ deletable_count = operation.total_resources - operation.skipped_count
2499
+ console.print(
2500
+ f"\n[yellow]⚠️ {deletable_count} resource(s) would be DELETED if you run 'cleanup execute'[/yellow]"
2501
+ )
2502
+ console.print("[dim]Use 'awsinv cleanup execute' with --confirm to actually delete resources[/dim]\n")
2503
+ else:
2504
+ console.print("\n[green]✓ No resources would be deleted - environment matches baseline[/green]\n")
2505
+
2506
+ except ValueError as e:
2507
+ console.print(f"\n[red]Error: {e}[/red]\n")
2508
+ raise typer.Exit(code=1)
2509
+ except Exception as e:
2510
+ console.print(f"\n[red]Unexpected error: {e}[/red]\n")
2511
+ logger.exception("Error in cleanup preview command")
2512
+ raise typer.Exit(code=2)
2513
+
2514
+
2515
+ @cleanup_app.command("execute")
2516
+ def cleanup_execute(
2517
+ baseline_snapshot: str = typer.Argument(..., help="Baseline snapshot - resources created after this will be deleted"),
2518
+ account_id: str = typer.Option(None, "--account-id", help="AWS account ID (auto-detected if not provided)"),
2519
+ profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
2520
+ resource_types: Optional[List[str]] = typer.Option(None, "--type", help="Filter by resource types"),
2521
+ regions: Optional[List[str]] = typer.Option(None, "--region", help="Filter by AWS regions"),
2522
+ protect_tags: Optional[List[str]] = typer.Option(
2523
+ None, "--protect-tag", help="Protect resources with tag (format: key=value, can repeat)"
2524
+ ),
2525
+ config_file: Optional[str] = typer.Option(
2526
+ None, "--config", help="Path to protection rules config file"
2527
+ ),
2528
+ confirm: bool = typer.Option(False, "--confirm", help="Confirm deletion (REQUIRED for execution)"),
2529
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirmation prompt"),
2530
+ ):
2531
+ """DELETE resources created after a baseline snapshot.
2532
+
2533
+ ⚠️ DESTRUCTIVE OPERATION: This will permanently delete AWS resources!
2534
+
2535
+ Deletes resources that were created after the snapshot, returning
2536
+ your AWS environment to that baseline state. Protected resources are skipped.
2537
+
2538
+ Examples:
2539
+ # Delete resources created after baseline, protecting tagged resources
2540
+ awsinv cleanup execute my-snapshot --protect-tag "project=baseline" --confirm
2541
+
2542
+ # Use config file for protection rules
2543
+ awsinv cleanup execute my-snapshot --config .awsinv-cleanup.yaml --confirm
2544
+
2545
+ # Delete only EC2 instances, skip prompt
2546
+ awsinv cleanup execute my-snapshot --confirm --yes --type AWS::EC2::Instance
2547
+
2548
+ # Delete in specific region with profile
2549
+ awsinv cleanup execute my-snapshot --confirm --region us-east-1 --profile prod
2550
+ """
2551
+ from ..aws.credentials import get_account_id
2552
+ from ..restore.audit import AuditStorage
2553
+ from ..restore.cleaner import ResourceCleaner
2554
+ from ..restore.config import build_protection_rules, load_config_file
2555
+ from ..restore.safety import SafetyChecker
2556
+
2557
+ try:
2558
+ # Require --confirm flag
2559
+ if not confirm:
2560
+ console.print("\n[red]ERROR: --confirm flag is required for deletion operations[/red]")
2561
+ console.print("[yellow]This is a safety measure to prevent accidental deletions[/yellow]")
2562
+ console.print("\n[dim]Run with: awsinv cleanup execute <snapshot> --confirm[/dim]\n")
2563
+ raise typer.Exit(code=1)
2564
+
2565
+ console.print("\n[bold red]⚠️ DESTRUCTIVE OPERATION[/bold red]\n")
2566
+
2567
+ # Auto-detect account ID if not provided
2568
+ if not account_id:
2569
+ try:
2570
+ account_id = get_account_id(profile_name=profile)
2571
+ console.print(f"[dim]Detected account ID: {account_id}[/dim]")
2572
+ except Exception as e:
2573
+ console.print(f"[red]Error detecting account ID: {e}[/red]")
2574
+ console.print("[yellow]Please provide --account-id explicitly[/yellow]")
2575
+ raise typer.Exit(code=1)
2576
+
2577
+ # Load config and build protection rules
2578
+ config = load_config_file(config_file)
2579
+ protection_rules = build_protection_rules(config, protect_tags)
2580
+
2581
+ if protection_rules:
2582
+ console.print(f"[dim]Loaded {len(protection_rules)} protection rule(s)[/dim]")
2583
+
2584
+ # Initialize components
2585
+ snapshot_storage = SnapshotStorage()
2586
+ safety_checker = SafetyChecker(rules=protection_rules)
2587
+ audit_storage = AuditStorage()
2588
+
2589
+ cleaner = ResourceCleaner(
2590
+ snapshot_storage=snapshot_storage,
2591
+ safety_checker=safety_checker,
2592
+ audit_storage=audit_storage,
2593
+ )
2594
+
2595
+ # First, run preview to show what will be deleted
2596
+ console.print("[bold]Preview - Analyzing resources...[/bold]")
2597
+ with console.status("[bold green]Analyzing..."):
2598
+ preview_op = cleaner.preview(
2599
+ baseline_snapshot=baseline_snapshot,
2600
+ account_id=account_id,
2601
+ aws_profile=profile,
2602
+ resource_types=resource_types,
2603
+ regions=regions,
2604
+ )
2605
+
2606
+ deletable_count = preview_op.total_resources - preview_op.skipped_count
2607
+
2608
+ if deletable_count == 0:
2609
+ console.print("\n[green]✓ No resources to delete - environment matches baseline[/green]\n")
2610
+ raise typer.Exit(code=0)
2611
+
2612
+ # Show what will be deleted
2613
+ console.print("\n[bold yellow]The following will be PERMANENTLY DELETED:[/bold yellow]")
2614
+ console.print(f"• {deletable_count} resource(s) will be deleted")
2615
+ console.print(f"• {preview_op.skipped_count} resource(s) will be skipped (protected)")
2616
+ console.print(f"• Account: {account_id}")
2617
+ console.print(f"• Baseline: {baseline_snapshot}")
2618
+
2619
+ if preview_op.filters:
2620
+ if preview_op.filters.get("resource_types"):
2621
+ console.print(f"• Types: {', '.join(preview_op.filters['resource_types'])}")
2622
+ if preview_op.filters.get("regions"):
2623
+ console.print(f"• Regions: {', '.join(preview_op.filters['regions'])}")
2624
+
2625
+ # Interactive confirmation (unless --yes flag)
2626
+ if not yes:
2627
+ console.print()
2628
+ proceed = typer.confirm(
2629
+ "⚠️ Are you absolutely sure you want to DELETE these resources?",
2630
+ default=False,
2631
+ )
2632
+ if not proceed:
2633
+ console.print("\n[yellow]Aborted - no resources were deleted[/yellow]\n")
2634
+ raise typer.Exit(code=0)
2635
+
2636
+ # Execute deletion
2637
+ console.print("\n[bold red]Executing deletion...[/bold red]")
2638
+ with console.status("[bold red]Deleting resources..."):
2639
+ operation = cleaner.execute(
2640
+ baseline_snapshot=baseline_snapshot,
2641
+ account_id=account_id,
2642
+ confirmed=True,
2643
+ aws_profile=profile,
2644
+ resource_types=resource_types,
2645
+ regions=regions,
2646
+ )
2647
+
2648
+ # Display results
2649
+ console.print("\n[bold]Deletion Complete[/bold]\n")
2650
+
2651
+ # Results summary
2652
+ status_color = (
2653
+ "green"
2654
+ if operation.status.value == "completed"
2655
+ else "yellow" if operation.status.value == "partial" else "red"
2656
+ )
2657
+
2658
+ summary_text = f"""
2659
+ [bold]Operation ID:[/bold] {operation.operation_id}
2660
+ [bold]Status:[/bold] [{status_color}]{operation.status.value.upper()}[/{status_color}]
2661
+
2662
+ [bold]Results:[/bold]
2663
+ • Succeeded: {operation.succeeded_count}
2664
+ • Failed: {operation.failed_count}
2665
+ • Skipped: {operation.skipped_count}
2666
+ • Total: {operation.total_resources}
2667
+ """
2668
+
2669
+ console.print(Panel(summary_text.strip(), title="[bold]Execution Summary[/bold]", border_style=status_color))
2670
+
2671
+ # Show audit log location
2672
+ console.print("\n[dim]📝 Full audit log saved to: ~/.snapshots/audit-logs/[/dim]\n")
2673
+
2674
+ # Exit with appropriate code
2675
+ if operation.failed_count > 0:
2676
+ raise typer.Exit(code=1)
2677
+
2678
+ except ValueError as e:
2679
+ console.print(f"\n[red]Error: {e}[/red]\n")
2680
+ raise typer.Exit(code=1)
2681
+ except Exception as e:
2682
+ console.print(f"\n[red]Unexpected error: {e}[/red]\n")
2683
+ logger.exception("Error in cleanup execute command")
2684
+ raise typer.Exit(code=2)
2685
+
2686
+
2687
+ @cleanup_app.command("purge")
2688
+ def cleanup_purge(
2689
+ account_id: str = typer.Option(None, "--account-id", help="AWS account ID (auto-detected if not provided)"),
2690
+ profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
2691
+ resource_types: Optional[List[str]] = typer.Option(None, "--type", help="Filter by resource types"),
2692
+ regions: Optional[List[str]] = typer.Option(None, "--region", help="Filter by AWS regions"),
2693
+ protect_tags: Optional[List[str]] = typer.Option(
2694
+ None, "--protect-tag", help="Protect resources with tag (format: key=value, can repeat)"
2695
+ ),
2696
+ config_file: Optional[str] = typer.Option(
2697
+ None, "--config", help="Path to protection rules config file"
2698
+ ),
2699
+ preview: bool = typer.Option(False, "--preview", help="Preview mode - show what would be deleted without deleting"),
2700
+ confirm: bool = typer.Option(False, "--confirm", help="Confirm deletion (REQUIRED for execution)"),
2701
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirmation prompt"),
2702
+ ):
2703
+ """DELETE all resources EXCEPT those matching protection rules.
2704
+
2705
+ ⚠️ DESTRUCTIVE OPERATION: This will permanently delete AWS resources!
2706
+
2707
+ Unlike 'cleanup execute', this does NOT compare to a snapshot. It deletes
2708
+ ALL resources that don't match protection rules (tags, types, etc.).
2709
+
2710
+ Use this for lab/sandbox cleanup where baseline resources are tagged.
2711
+
2712
+ Examples:
2713
+ # Preview what would be deleted (safe)
2714
+ awsinv cleanup purge --protect-tag "project=baseline" --preview
2715
+
2716
+ # Delete everything except baseline-tagged resources
2717
+ awsinv cleanup purge --protect-tag "project=baseline" --confirm
2718
+
2719
+ # Multiple protection tags (OR logic - protected if ANY match)
2720
+ awsinv cleanup purge --protect-tag "project=baseline" --protect-tag "env=prod" --confirm
2721
+
2722
+ # Use config file for protection rules
2723
+ awsinv cleanup purge --config .awsinv-cleanup.yaml --confirm
2724
+
2725
+ # Purge only specific resource types
2726
+ awsinv cleanup purge --protect-tag "project=baseline" --type AWS::EC2::Instance --confirm
2727
+
2728
+ # Purge in specific region
2729
+ awsinv cleanup purge --protect-tag "project=baseline" --region us-east-1 --confirm
2730
+ """
2731
+ from ..aws.credentials import get_account_id
2732
+ from ..restore.audit import AuditStorage
2733
+ from ..restore.config import build_protection_rules, load_config_file
2734
+ from ..restore.deleter import ResourceDeleter
2735
+ from ..restore.safety import SafetyChecker
2736
+ from ..snapshot.capturer import SnapshotCapturer
2737
+
2738
+ try:
2739
+ # Load config and build protection rules
2740
+ config = load_config_file(config_file)
2741
+ protection_rules = build_protection_rules(config, protect_tags)
2742
+
2743
+ # Require at least one protection rule for purge
2744
+ if not protection_rules:
2745
+ console.print("\n[red]ERROR: At least one protection rule is required for purge[/red]")
2746
+ console.print("[yellow]Use --protect-tag or --config to specify what to keep[/yellow]")
2747
+ console.print("\n[dim]Example: awsinv cleanup purge --protect-tag \"project=baseline\" --preview[/dim]\n")
2748
+ raise typer.Exit(code=1)
2749
+
2750
+ if preview:
2751
+ console.print("\n[bold cyan]🔍 Purge Preview (dry-run)[/bold cyan]\n")
2752
+ else:
2753
+ if not confirm:
2754
+ console.print("\n[red]ERROR: --confirm flag is required for purge operations[/red]")
2755
+ console.print("[yellow]This is a safety measure to prevent accidental deletions[/yellow]")
2756
+ console.print("\n[dim]Run with: awsinv cleanup purge --protect-tag \"key=value\" --confirm[/dim]\n")
2757
+ raise typer.Exit(code=1)
2758
+ console.print("\n[bold red]⚠️ PURGE OPERATION - DESTRUCTIVE[/bold red]\n")
2759
+
2760
+ # Auto-detect account ID if not provided
2761
+ if not account_id:
2762
+ try:
2763
+ account_id = get_account_id(profile_name=profile)
2764
+ console.print(f"[dim]Detected account ID: {account_id}[/dim]")
2765
+ except Exception as e:
2766
+ console.print(f"[red]Error detecting account ID: {e}[/red]")
2767
+ console.print("[yellow]Please provide --account-id explicitly[/yellow]")
2768
+ raise typer.Exit(code=1)
2769
+
2770
+ console.print(f"[dim]Loaded {len(protection_rules)} protection rule(s)[/dim]")
2771
+ for rule in protection_rules:
2772
+ console.print(f"[dim] • {rule.description}[/dim]")
2773
+
2774
+ # Initialize safety checker
2775
+ safety_checker = SafetyChecker(rules=protection_rules)
2776
+
2777
+ # Collect current resources
2778
+ console.print("\n[bold]Scanning resources...[/bold]")
2779
+ capturer = SnapshotCapturer(profile_name=profile)
2780
+ target_regions = regions if regions else ["us-east-1"] # Default to us-east-1 if not specified
2781
+
2782
+ with console.status("[bold green]Collecting resources..."):
2783
+ all_resources = []
2784
+ for region in target_regions:
2785
+ try:
2786
+ resources = capturer.collect_resources(
2787
+ regions=[region],
2788
+ resource_types=resource_types,
2789
+ )
2790
+ all_resources.extend(resources)
2791
+ except Exception as e:
2792
+ logger.warning(f"Error collecting resources in {region}: {e}")
2793
+
2794
+ console.print(f"[dim]Found {len(all_resources)} total resources[/dim]")
2795
+
2796
+ # Apply protection rules
2797
+ to_delete = []
2798
+ protected = []
2799
+
2800
+ for resource in all_resources:
2801
+ # Convert Resource object to dict for safety checker
2802
+ resource_dict = {
2803
+ "resource_id": resource.name,
2804
+ "resource_type": resource.resource_type,
2805
+ "region": resource.region,
2806
+ "arn": resource.arn,
2807
+ "tags": resource.tags or {},
2808
+ }
2809
+
2810
+ is_protected, reason = safety_checker.is_protected(resource_dict)
2811
+
2812
+ if is_protected:
2813
+ protected.append((resource, reason))
2814
+ else:
2815
+ to_delete.append(resource)
2816
+
2817
+ # Display summary
2818
+ console.print(f"\n[bold]Summary:[/bold]")
2819
+ console.print(f" • Total resources: {len(all_resources)}")
2820
+ console.print(f" • Protected (will keep): [green]{len(protected)}[/green]")
2821
+ console.print(f" • Unprotected (will delete): [red]{len(to_delete)}[/red]")
2822
+
2823
+ if preview:
2824
+ # Show what would be deleted
2825
+ if to_delete:
2826
+ console.print("\n[bold yellow]Resources that would be DELETED:[/bold yellow]")
2827
+ for resource in to_delete[:20]: # Show first 20
2828
+ console.print(f" [red]✗[/red] {resource.resource_type}: {resource.name} ({resource.region})")
2829
+ if len(to_delete) > 20:
2830
+ console.print(f" ... and {len(to_delete) - 20} more")
2831
+
2832
+ if protected:
2833
+ console.print("\n[bold green]Resources that would be PROTECTED:[/bold green]")
2834
+ for resource, reason in protected[:10]: # Show first 10
2835
+ console.print(f" [green]✓[/green] {resource.resource_type}: {resource.name} - {reason}")
2836
+ if len(protected) > 10:
2837
+ console.print(f" ... and {len(protected) - 10} more")
2838
+
2839
+ console.print("\n[dim]This was a preview. Use --confirm to actually delete resources.[/dim]\n")
2840
+ raise typer.Exit(code=0)
2841
+
2842
+ # Execution mode
2843
+ if len(to_delete) == 0:
2844
+ console.print("\n[green]✓ No unprotected resources to delete[/green]\n")
2845
+ raise typer.Exit(code=0)
2846
+
2847
+ # Interactive confirmation
2848
+ if not yes:
2849
+ console.print(f"\n[bold red]About to DELETE {len(to_delete)} resources![/bold red]")
2850
+ confirm_prompt = typer.confirm("Are you sure you want to proceed?")
2851
+ if not confirm_prompt:
2852
+ console.print("\n[yellow]Aborted - no resources were deleted[/yellow]\n")
2853
+ raise typer.Exit(code=0)
2854
+
2855
+ # Execute deletion
2856
+ console.print("\n[bold red]Executing deletion...[/bold red]")
2857
+ deleter = ResourceDeleter(aws_profile=profile)
2858
+ audit_storage = AuditStorage()
2859
+
2860
+ succeeded = 0
2861
+ failed = 0
2862
+
2863
+ with console.status("[bold red]Deleting resources..."):
2864
+ for resource in to_delete:
2865
+ success, error = deleter.delete_resource(
2866
+ resource_type=resource.resource_type,
2867
+ resource_id=resource.name,
2868
+ region=resource.region,
2869
+ arn=resource.arn,
2870
+ )
2871
+ if success:
2872
+ succeeded += 1
2873
+ logger.info(f"Deleted {resource.resource_type}: {resource.name}")
2874
+ else:
2875
+ failed += 1
2876
+ logger.warning(f"Failed to delete {resource.resource_type}: {resource.name} - {error}")
2877
+
2878
+ # Display results
2879
+ console.print("\n[bold]Purge Complete[/bold]\n")
2880
+
2881
+ status_color = "green" if failed == 0 else "yellow" if succeeded > 0 else "red"
2882
+
2883
+ summary_text = f"""
2884
+ [bold]Results:[/bold]
2885
+ • Succeeded: [green]{succeeded}[/green]
2886
+ • Failed: [red]{failed}[/red]
2887
+ • Protected (skipped): {len(protected)}
2888
+ • Total scanned: {len(all_resources)}
2889
+ """
2890
+
2891
+ console.print(Panel(summary_text.strip(), title="[bold]Purge Summary[/bold]", border_style=status_color))
2892
+
2893
+ if failed > 0:
2894
+ raise typer.Exit(code=1)
2895
+
2896
+ except typer.Exit:
2897
+ raise
2898
+ except ValueError as e:
2899
+ console.print(f"\n[red]Error: {e}[/red]\n")
2900
+ raise typer.Exit(code=1)
2901
+ except Exception as e:
2902
+ console.print(f"\n[red]Unexpected error: {e}[/red]\n")
2903
+ logger.exception("Error in purge command")
2904
+ raise typer.Exit(code=2)
2905
+
2906
+
2907
+ app.add_typer(cleanup_app, name="cleanup")
2908
+
2909
+
2910
+ # ============================================================================
2911
+ # QUERY COMMANDS - SQL queries across snapshots
2912
+ # ============================================================================
2913
+
2914
+ query_app = typer.Typer(help="Query resources across snapshots using SQL")
2915
+
2916
+
2917
+ @query_app.command("sql")
2918
+ def query_sql(
2919
+ query: str = typer.Argument(..., help="SQL query to execute (SELECT only)"),
2920
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
2921
+ limit: int = typer.Option(100, "--limit", "-l", help="Maximum results to return"),
2922
+ snapshot: Optional[str] = typer.Option(
2923
+ None, "--snapshot", "-s", help="Filter by snapshot name", envvar="AWSINV_SNAPSHOT_ID"
2924
+ ),
2925
+ ):
2926
+ """Execute raw SQL query against the resource database.
2927
+
2928
+ Only SELECT queries are allowed for safety. The database contains tables:
2929
+ - snapshots: Snapshot metadata
2930
+ - resources: Resource details (arn, type, name, region, config_hash)
2931
+ - resource_tags: Tags for each resource (resource_id, key, value)
2932
+ - inventories: Inventory definitions
2933
+ - audit_operations: Audit operation logs
2934
+ - audit_records: Individual resource audit records
2935
+
2936
+ Examples:
2937
+ awsinv query sql "SELECT resource_type, COUNT(*) as count FROM resources GROUP BY resource_type"
2938
+ awsinv query sql "SELECT r.arn, t.key, t.value FROM resources r JOIN resource_tags t ON r.id = t.resource_id WHERE t.key = 'Environment'"
2939
+ # Use --snapshot to automatically filter by snapshot_id
2940
+ awsinv query sql "SELECT * FROM resources" --snapshot my-snapshot
2941
+ """
2942
+ from ..storage import Database, ResourceStore
2943
+ import json
2944
+ import csv
2945
+ import sys
2946
+ import re
2947
+
2948
+ setup_logging()
2949
+
2950
+ try:
2951
+ db = Database()
2952
+ db.ensure_schema()
2953
+ store = ResourceStore(db)
2954
+
2955
+ # Apply snapshot filter if provided
2956
+ if snapshot:
2957
+ # Look up snapshot ID
2958
+ rows = db.fetchall("SELECT id FROM snapshots WHERE name = ?", (snapshot,))
2959
+ if not rows:
2960
+ console.print(f"[red]Error: Snapshot '{snapshot}' not found[/red]")
2961
+ raise typer.Exit(code=1)
2962
+
2963
+ snapshot_id = rows[0]["id"]
2964
+
2965
+ # Inject WHERE clause logic
2966
+ # 1. Check for existing WHERE
2967
+ match_where = re.search(r'(?i)\bwhere\b', query)
2968
+ if match_where:
2969
+ # Insert AND after WHERE
2970
+ start, end = match_where.span()
2971
+ query = query[:end] + f" snapshot_id = {snapshot_id} AND" + query[end:]
2972
+ else:
2973
+ # 2. Check for clauses that must come AFTER WHERE (GROUP BY, HAVING, ORDER BY, LIMIT)
2974
+ match_clause = re.search(r'(?i)\b(group\s+by|having|order\s+by|limit)\b', query)
2975
+ if match_clause:
2976
+ start, end = match_clause.span()
2977
+ query = query[:start] + f" WHERE snapshot_id = {snapshot_id} " + query[start:]
2978
+ else:
2979
+ # 3. Simple append
2980
+ query = query.rstrip(";") + f" WHERE snapshot_id = {snapshot_id}"
2981
+
2982
+ logger.debug(f"Modified query with snapshot filter: {query}")
2983
+
2984
+ # Add LIMIT if not present
2985
+ query_upper = query.strip().upper()
2986
+ if "LIMIT" not in query_upper:
2987
+ query = f"{query.rstrip(';')} LIMIT {limit}"
2988
+
2989
+ results = store.query_raw(query)
2990
+
2991
+ if not results:
2992
+ console.print("[yellow]No results found[/yellow]")
2993
+ return
2994
+
2995
+ if format == "json":
2996
+ console.print(json.dumps(results, indent=2, default=str))
2997
+ elif format == "csv":
2998
+ if results:
2999
+ writer = csv.DictWriter(sys.stdout, fieldnames=results[0].keys())
3000
+ writer.writeheader()
3001
+ writer.writerows(results)
3002
+ else: # table
3003
+ table = Table(show_header=True, header_style="bold cyan")
3004
+ for key in results[0].keys():
3005
+ table.add_column(key)
3006
+ for row in results:
3007
+ table.add_row(*[str(v) if v is not None else "" for v in row.values()])
3008
+ console.print(table)
3009
+
3010
+ console.print(f"\n[dim]{len(results)} row(s) returned[/dim]")
3011
+
3012
+ except ValueError as e:
3013
+ console.print(f"[red]Query error: {e}[/red]")
3014
+ raise typer.Exit(code=1)
3015
+ except Exception as e:
3016
+ console.print(f"[red]Error: {e}[/red]")
3017
+ logger.exception("Query failed")
3018
+ raise typer.Exit(code=1)
3019
+
3020
+
3021
+ @query_app.command("resources")
3022
+ def query_resources(
3023
+ type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by resource type (e.g., 's3:bucket', 'ec2')"),
3024
+ region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by region"),
3025
+ tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag (Key=Value)"),
3026
+ arn: Optional[str] = typer.Option(None, "--arn", help="Filter by ARN pattern (supports wildcards)"),
3027
+ snapshot: Optional[str] = typer.Option(None, "--snapshot", "-s", help="Limit to specific snapshot"),
3028
+ limit: int = typer.Option(100, "--limit", "-l", help="Maximum results to return"),
3029
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
3030
+ ):
3031
+ """Search resources with filters across all snapshots.
3032
+
3033
+ Examples:
3034
+ awsinv query resources --type s3:bucket
3035
+ awsinv query resources --region us-east-1 --type ec2
3036
+ awsinv query resources --tag Environment=production
3037
+ awsinv query resources --arn "arn:aws:s3:::my-bucket*"
3038
+ awsinv query resources --snapshot baseline-2024 --type lambda
3039
+ """
3040
+ from ..storage import Database, ResourceStore
3041
+ import json
3042
+
3043
+ setup_logging()
3044
+
3045
+ try:
3046
+ db = Database()
3047
+ db.ensure_schema()
3048
+ store = ResourceStore(db)
3049
+
3050
+ # Parse tag filter
3051
+ tag_key = None
3052
+ tag_value = None
3053
+ if tag:
3054
+ if "=" in tag:
3055
+ tag_key, tag_value = tag.split("=", 1)
3056
+ else:
3057
+ tag_key = tag
3058
+
3059
+ results = store.search(
3060
+ arn_pattern=arn,
3061
+ resource_type=type,
3062
+ region=region,
3063
+ tag_key=tag_key,
3064
+ tag_value=tag_value,
3065
+ snapshot_name=snapshot,
3066
+ limit=limit,
3067
+ )
3068
+
3069
+ if not results:
3070
+ console.print("[yellow]No resources found matching filters[/yellow]")
3071
+ return
3072
+
3073
+ if format == "json":
3074
+ console.print(json.dumps(results, indent=2, default=str))
3075
+ else:
3076
+ table = Table(show_header=True, header_style="bold cyan")
3077
+ table.add_column("ARN", style="cyan", no_wrap=True)
3078
+ table.add_column("Type")
3079
+ table.add_column("Name")
3080
+ table.add_column("Region")
3081
+ table.add_column("Snapshot")
3082
+
3083
+ for r in results:
3084
+ # Truncate ARN for display
3085
+ arn_display = r["arn"]
3086
+ if len(arn_display) > 60:
3087
+ arn_display = "..." + arn_display[-57:]
3088
+ table.add_row(
3089
+ arn_display,
3090
+ r["resource_type"],
3091
+ r["name"],
3092
+ r["region"],
3093
+ r["snapshot_name"],
3094
+ )
3095
+ console.print(table)
3096
+
3097
+ console.print(f"\n[dim]{len(results)} resource(s) found[/dim]")
3098
+
3099
+ except Exception as e:
3100
+ console.print(f"[red]Error: {e}[/red]")
3101
+ logger.exception("Query failed")
3102
+ raise typer.Exit(code=1)
3103
+
3104
+
3105
+ @query_app.command("history")
3106
+ def query_history(
3107
+ arn: str = typer.Argument(..., help="Resource ARN to track across snapshots"),
3108
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
3109
+ ):
3110
+ """Show snapshot history for a specific resource.
3111
+
3112
+ Tracks when a resource appeared in snapshots and whether its configuration changed.
3113
+
3114
+ Example:
3115
+ awsinv query history "arn:aws:s3:::my-bucket"
3116
+ """
3117
+ from ..storage import Database, ResourceStore
3118
+ import json
3119
+
3120
+ setup_logging()
3121
+
3122
+ try:
3123
+ db = Database()
3124
+ db.ensure_schema()
3125
+ store = ResourceStore(db)
3126
+
3127
+ results = store.get_history(arn)
3128
+
3129
+ if not results:
3130
+ console.print(f"[yellow]No history found for ARN: {arn}[/yellow]")
3131
+ return
3132
+
3133
+ if format == "json":
3134
+ console.print(json.dumps(results, indent=2, default=str))
3135
+ else:
3136
+ console.print(f"\n[bold]History for:[/bold] {arn}\n")
3137
+ table = Table(show_header=True, header_style="bold cyan")
3138
+ table.add_column("Snapshot")
3139
+ table.add_column("Snapshot Date")
3140
+ table.add_column("Config Hash")
3141
+ table.add_column("Source")
3142
+
3143
+ prev_hash = None
3144
+ for r in results:
3145
+ config_hash = r["config_hash"][:12] if r["config_hash"] else "N/A"
3146
+ # Mark config changes
3147
+ if prev_hash and prev_hash != r["config_hash"]:
3148
+ config_hash = f"[yellow]{config_hash}[/yellow] (changed)"
3149
+ prev_hash = r["config_hash"]
3150
+
3151
+ table.add_row(
3152
+ r["snapshot_name"],
3153
+ str(r["snapshot_created_at"])[:19],
3154
+ config_hash,
3155
+ r["source"] or "direct_api",
3156
+ )
3157
+ console.print(table)
3158
+
3159
+ console.print(f"\n[dim]Found in {len(results)} snapshot(s)[/dim]")
3160
+
3161
+ except Exception as e:
3162
+ console.print(f"[red]Error: {e}[/red]")
3163
+ logger.exception("Query failed")
3164
+ raise typer.Exit(code=1)
3165
+
3166
+
3167
+ @query_app.command("stats")
3168
+ def query_stats(
3169
+ snapshot: Optional[str] = typer.Option(None, "--snapshot", "-s", help="Specific snapshot (default: all)"),
3170
+ group_by: str = typer.Option("type", "--group-by", "-g", help="Group by: type, region, service, snapshot"),
3171
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
3172
+ ):
3173
+ """Show resource statistics and counts.
3174
+
3175
+ Examples:
3176
+ awsinv query stats
3177
+ awsinv query stats --group-by region
3178
+ awsinv query stats --snapshot baseline-2024 --group-by service
3179
+ """
3180
+ from ..storage import Database, ResourceStore, SnapshotStore
3181
+ import json
3182
+
3183
+ setup_logging()
3184
+
3185
+ try:
3186
+ db = Database()
3187
+ db.ensure_schema()
3188
+ resource_store = ResourceStore(db)
3189
+ snapshot_store = SnapshotStore(db)
3190
+
3191
+ # Get overall stats
3192
+ total_snapshots = snapshot_store.get_snapshot_count()
3193
+ total_resources = snapshot_store.get_resource_count()
3194
+
3195
+ console.print(f"\n[bold]Database Statistics[/bold]")
3196
+ console.print(f"Total snapshots: [cyan]{total_snapshots}[/cyan]")
3197
+ console.print(f"Total resources: [cyan]{total_resources}[/cyan]")
3198
+
3199
+ if snapshot:
3200
+ console.print(f"Filtering by snapshot: [cyan]{snapshot}[/cyan]")
3201
+ console.print()
3202
+
3203
+ results = resource_store.get_stats(snapshot_name=snapshot, group_by=group_by)
3204
+
3205
+ if not results:
3206
+ console.print("[yellow]No statistics available[/yellow]")
3207
+ return
3208
+
3209
+ if format == "json":
3210
+ console.print(json.dumps(results, indent=2, default=str))
3211
+ else:
3212
+ group_label = {
3213
+ "type": "Resource Type",
3214
+ "region": "Region",
3215
+ "service": "Service",
3216
+ "snapshot": "Snapshot",
3217
+ }.get(group_by, "Group")
3218
+
3219
+ table = Table(show_header=True, header_style="bold cyan")
3220
+ table.add_column(group_label)
3221
+ table.add_column("Count", justify="right")
3222
+
3223
+ for r in results:
3224
+ table.add_row(r["group_key"] or "Unknown", str(r["count"]))
3225
+ console.print(table)
3226
+
3227
+ except Exception as e:
3228
+ console.print(f"[red]Error: {e}[/red]")
3229
+ logger.exception("Query failed")
3230
+ raise typer.Exit(code=1)
3231
+
3232
+
3233
+ @query_app.command("diff")
3234
+ def query_diff(
3235
+ snapshot1: str = typer.Argument(..., help="First (older) snapshot name"),
3236
+ snapshot2: str = typer.Argument(..., help="Second (newer) snapshot name"),
3237
+ type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by resource type"),
3238
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, summary"),
3239
+ ):
3240
+ """Compare resources between two snapshots.
3241
+
3242
+ Shows resources that were added, removed, or modified between snapshots.
3243
+
3244
+ Example:
3245
+ awsinv query diff baseline-2024 current-2024
3246
+ awsinv query diff snap1 snap2 --type s3:bucket
3247
+ """
3248
+ from ..storage import Database, ResourceStore
3249
+ import json
3250
+
3251
+ setup_logging()
3252
+
3253
+ try:
3254
+ db = Database()
3255
+ db.ensure_schema()
3256
+ store = ResourceStore(db)
3257
+
3258
+ result = store.compare_snapshots(snapshot1, snapshot2)
3259
+
3260
+ # Filter by type if specified
3261
+ if type:
3262
+ result["added"] = [r for r in result["added"] if type.lower() in r["resource_type"].lower()]
3263
+ result["removed"] = [r for r in result["removed"] if type.lower() in r["resource_type"].lower()]
3264
+ result["modified"] = [r for r in result["modified"] if type.lower() in r["resource_type"].lower()]
3265
+ # Update counts
3266
+ result["summary"]["added_count"] = len(result["added"])
3267
+ result["summary"]["removed_count"] = len(result["removed"])
3268
+ result["summary"]["modified_count"] = len(result["modified"])
3269
+
3270
+ summary = result["summary"]
3271
+
3272
+ if format == "json":
3273
+ console.print(json.dumps(result, indent=2, default=str))
3274
+ return
3275
+
3276
+ # Print summary
3277
+ console.print(f"\n[bold]Comparing Snapshots[/bold]")
3278
+ console.print(f" {snapshot1} ({summary['snapshot1_count']} resources)")
3279
+ console.print(f" {snapshot2} ({summary['snapshot2_count']} resources)")
3280
+ console.print()
3281
+
3282
+ if format == "summary":
3283
+ console.print(f"[green]+ Added:[/green] {summary['added_count']}")
3284
+ console.print(f"[red]- Removed:[/red] {summary['removed_count']}")
3285
+ console.print(f"[yellow]~ Modified:[/yellow] {summary['modified_count']}")
3286
+ return
3287
+
3288
+ # Show details
3289
+ if result["added"]:
3290
+ console.print(f"\n[green][bold]Added ({len(result['added'])})[/bold][/green]")
3291
+ table = Table(show_header=True, header_style="green")
3292
+ table.add_column("ARN")
3293
+ table.add_column("Type")
3294
+ table.add_column("Region")
3295
+ for r in result["added"][:20]:
3296
+ table.add_row(r["arn"][-60:], r["resource_type"], r["region"])
3297
+ console.print(table)
3298
+ if len(result["added"]) > 20:
3299
+ console.print(f"[dim]...and {len(result['added']) - 20} more[/dim]")
3300
+
3301
+ if result["removed"]:
3302
+ console.print(f"\n[red][bold]Removed ({len(result['removed'])})[/bold][/red]")
3303
+ table = Table(show_header=True, header_style="red")
3304
+ table.add_column("ARN")
3305
+ table.add_column("Type")
3306
+ table.add_column("Region")
3307
+ for r in result["removed"][:20]:
3308
+ table.add_row(r["arn"][-60:], r["resource_type"], r["region"])
3309
+ console.print(table)
3310
+ if len(result["removed"]) > 20:
3311
+ console.print(f"[dim]...and {len(result['removed']) - 20} more[/dim]")
3312
+
3313
+ if result["modified"]:
3314
+ console.print(f"\n[yellow][bold]Modified ({len(result['modified'])})[/bold][/yellow]")
3315
+ table = Table(show_header=True, header_style="yellow")
3316
+ table.add_column("ARN")
3317
+ table.add_column("Type")
3318
+ table.add_column("Old Hash")
3319
+ table.add_column("New Hash")
3320
+ for r in result["modified"][:20]:
3321
+ table.add_row(
3322
+ r["arn"][-50:],
3323
+ r["resource_type"],
3324
+ r["old_hash"][:12],
3325
+ r["new_hash"][:12],
3326
+ )
3327
+ console.print(table)
3328
+ if len(result["modified"]) > 20:
3329
+ console.print(f"[dim]...and {len(result['modified']) - 20} more[/dim]")
3330
+
3331
+ if not result["added"] and not result["removed"] and not result["modified"]:
3332
+ console.print("[green]No differences found between snapshots[/green]")
3333
+
3334
+ except Exception as e:
3335
+ console.print(f"[red]Error: {e}[/red]")
3336
+ logger.exception("Query failed")
3337
+ raise typer.Exit(code=1)
3338
+
3339
+
3340
+ app.add_typer(query_app, name="query")
3341
+
3342
+
3343
+ # =============================================================================
3344
+ # Group Commands
3345
+ # =============================================================================
3346
+
3347
+ group_app = typer.Typer(help="Manage resource groups for baseline comparison")
3348
+
3349
+
3350
+ @group_app.command("create")
3351
+ def group_create(
3352
+ name: str = typer.Argument(..., help="Name for the new group"),
3353
+ from_snapshot: Optional[str] = typer.Option(
3354
+ None, "--from-snapshot", "-s", help="Create group from resources in this snapshot"
3355
+ ),
3356
+ description: str = typer.Option("", "--description", "-d", help="Group description"),
3357
+ type_filter: Optional[str] = typer.Option(
3358
+ None, "--type", "-t", help="Filter by resource type when creating from snapshot"
3359
+ ),
3360
+ region_filter: Optional[str] = typer.Option(
3361
+ None, "--region", "-r", help="Filter by region when creating from snapshot"
3362
+ ),
3363
+ ):
3364
+ """Create a new resource group.
3365
+
3366
+ Groups define a set of resources (by name + type) that should exist in every account.
3367
+ Use --from-snapshot to populate the group from an existing snapshot.
3368
+
3369
+ Examples:
3370
+ # Create empty group
3371
+ awsinv group create baseline --description "Production baseline resources"
3372
+
3373
+ # Create from snapshot
3374
+ awsinv group create baseline --from-snapshot "empty-account-2026-01"
3375
+
3376
+ # Create with filters
3377
+ awsinv group create iam-baseline --from-snapshot snap1 --type iam
3378
+ """
3379
+ from ..storage import Database, GroupStore
3380
+
3381
+ setup_logging()
3382
+
3383
+ try:
3384
+ db = Database()
3385
+ db.ensure_schema()
3386
+ store = GroupStore(db)
3387
+
3388
+ if store.exists(name):
3389
+ console.print(f"[red]Error: Group '{name}' already exists[/red]")
3390
+ raise typer.Exit(code=1)
3391
+
3392
+ if from_snapshot:
3393
+ # Create from snapshot
3394
+ count = store.create_from_snapshot(
3395
+ group_name=name,
3396
+ snapshot_name=from_snapshot,
3397
+ description=description,
3398
+ type_filter=type_filter,
3399
+ region_filter=region_filter,
3400
+ )
3401
+ console.print(f"[green]✓ Created group '{name}' with {count} resources from snapshot '{from_snapshot}'[/green]")
3402
+ else:
3403
+ # Create empty group
3404
+ from ..models.group import ResourceGroup
3405
+
3406
+ group = ResourceGroup(name=name, description=description)
3407
+ store.save(group)
3408
+ console.print(f"[green]✓ Created empty group '{name}'[/green]")
3409
+
3410
+ except ValueError as e:
3411
+ console.print(f"[red]Error: {e}[/red]")
3412
+ raise typer.Exit(code=1)
3413
+ except Exception as e:
3414
+ console.print(f"[red]Error: {e}[/red]")
3415
+ logger.exception("Group creation failed")
3416
+ raise typer.Exit(code=1)
3417
+
3418
+
3419
+ @group_app.command("list")
3420
+ def group_list(
3421
+ format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
3422
+ ):
3423
+ """List all resource groups.
3424
+
3425
+ Examples:
3426
+ awsinv group list
3427
+ awsinv group list --format json
3428
+ """
3429
+ from ..storage import Database, GroupStore
3430
+ import json
3431
+
3432
+ setup_logging()
3433
+
3434
+ try:
3435
+ db = Database()
3436
+ db.ensure_schema()
3437
+ store = GroupStore(db)
3438
+
3439
+ groups = store.list_all()
3440
+
3441
+ if not groups:
3442
+ console.print("[yellow]No groups found. Create one with 'awsinv group create'[/yellow]")
3443
+ return
3444
+
3445
+ if format == "json":
3446
+ console.print(json.dumps(groups, indent=2, default=str))
3447
+ else:
3448
+ table = Table(show_header=True, header_style="bold cyan")
3449
+ table.add_column("Name", style="cyan")
3450
+ table.add_column("Description")
3451
+ table.add_column("Resources", justify="right")
3452
+ table.add_column("Source Snapshot")
3453
+ table.add_column("Favorite", justify="center")
3454
+
3455
+ for g in groups:
3456
+ table.add_row(
3457
+ g["name"],
3458
+ g["description"][:40] + "..." if len(g["description"]) > 40 else g["description"],
3459
+ str(g["resource_count"]),
3460
+ g["source_snapshot"] or "-",
3461
+ "★" if g["is_favorite"] else "",
3462
+ )
3463
+
3464
+ console.print(table)
3465
+
3466
+ except Exception as e:
3467
+ console.print(f"[red]Error: {e}[/red]")
3468
+ logger.exception("Failed to list groups")
3469
+ raise typer.Exit(code=1)
3470
+
3471
+
3472
+ @group_app.command("show")
3473
+ def group_show(
3474
+ name: str = typer.Argument(..., help="Group name"),
3475
+ limit: int = typer.Option(50, "--limit", "-l", help="Maximum members to display"),
3476
+ ):
3477
+ """Show details of a resource group including its members.
3478
+
3479
+ Examples:
3480
+ awsinv group show baseline
3481
+ awsinv group show baseline --limit 100
3482
+ """
3483
+ from ..storage import Database, GroupStore
3484
+
3485
+ setup_logging()
3486
+
3487
+ try:
3488
+ db = Database()
3489
+ db.ensure_schema()
3490
+ store = GroupStore(db)
3491
+
3492
+ group = store.load(name)
3493
+ if not group:
3494
+ console.print(f"[red]Error: Group '{name}' not found[/red]")
3495
+ raise typer.Exit(code=1)
3496
+
3497
+ # Show group info
3498
+ console.print(
3499
+ Panel(
3500
+ f"[bold]{group.name}[/bold]\n\n"
3501
+ f"[dim]Description:[/dim] {group.description or '(none)'}\n"
3502
+ f"[dim]Source Snapshot:[/dim] {group.source_snapshot or '(none)'}\n"
3503
+ f"[dim]Resource Count:[/dim] {group.resource_count}\n"
3504
+ f"[dim]Created:[/dim] {group.created_at}\n"
3505
+ f"[dim]Last Updated:[/dim] {group.last_updated}",
3506
+ title="Group Details",
3507
+ border_style="blue",
3508
+ )
3509
+ )
3510
+
3511
+ # Show members
3512
+ if group.members:
3513
+ console.print(f"\n[bold]Members[/bold] (showing first {min(limit, len(group.members))} of {len(group.members)}):")
3514
+ table = Table(show_header=True, header_style="bold cyan")
3515
+ table.add_column("Resource Name", style="cyan")
3516
+ table.add_column("Type")
3517
+ table.add_column("Original ARN", style="dim")
3518
+
3519
+ for member in group.members[:limit]:
3520
+ table.add_row(
3521
+ member.resource_name,
3522
+ member.resource_type,
3523
+ member.original_arn[:60] + "..." if member.original_arn and len(member.original_arn) > 60 else (member.original_arn or "-"),
3524
+ )
3525
+
3526
+ console.print(table)
3527
+ else:
3528
+ console.print("\n[yellow]Group has no members[/yellow]")
3529
+
3530
+ except Exception as e:
3531
+ console.print(f"[red]Error: {e}[/red]")
3532
+ logger.exception("Failed to show group")
3533
+ raise typer.Exit(code=1)
3534
+
3535
+
3536
+ @group_app.command("delete")
3537
+ def group_delete(
3538
+ name: str = typer.Argument(..., help="Group name to delete"),
3539
+ confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
3540
+ ):
3541
+ """Delete a resource group.
3542
+
3543
+ Examples:
3544
+ awsinv group delete baseline
3545
+ awsinv group delete baseline --yes
3546
+ """
3547
+ from ..storage import Database, GroupStore
3548
+
3549
+ setup_logging()
3550
+
3551
+ try:
3552
+ db = Database()
3553
+ db.ensure_schema()
3554
+ store = GroupStore(db)
3555
+
3556
+ if not store.exists(name):
3557
+ console.print(f"[red]Error: Group '{name}' not found[/red]")
3558
+ raise typer.Exit(code=1)
3559
+
3560
+ if not confirm:
3561
+ confirm_input = typer.confirm(f"Are you sure you want to delete group '{name}'?")
3562
+ if not confirm_input:
3563
+ console.print("[yellow]Cancelled[/yellow]")
3564
+ raise typer.Exit(code=0)
3565
+
3566
+ store.delete(name)
3567
+ console.print(f"[green]✓ Deleted group '{name}'[/green]")
3568
+
3569
+ except typer.Exit:
3570
+ raise
3571
+ except Exception as e:
3572
+ console.print(f"[red]Error: {e}[/red]")
3573
+ logger.exception("Failed to delete group")
3574
+ raise typer.Exit(code=1)
3575
+
3576
+
3577
+ @group_app.command("compare")
3578
+ def group_compare(
3579
+ name: str = typer.Argument(..., help="Group name"),
3580
+ snapshot: str = typer.Option(..., "--snapshot", "-s", help="Snapshot to compare against"),
3581
+ format: str = typer.Option("summary", "--format", "-f", help="Output format: summary, table, json"),
3582
+ show_details: bool = typer.Option(False, "--details", "-d", help="Show individual resource details"),
3583
+ ):
3584
+ """Compare a snapshot against a resource group.
3585
+
3586
+ Shows which resources from the group are present in the snapshot,
3587
+ which are missing, and which resources in the snapshot are not in the group.
3588
+
3589
+ Examples:
3590
+ awsinv group compare baseline --snapshot prod-account-2026-01
3591
+ awsinv group compare baseline -s prod-account --format json
3592
+ awsinv group compare baseline -s prod-account --details
3593
+ """
3594
+ from ..storage import Database, GroupStore
3595
+ import json
3596
+
3597
+ setup_logging()
3598
+
3599
+ try:
3600
+ db = Database()
3601
+ db.ensure_schema()
3602
+ store = GroupStore(db)
3603
+
3604
+ result = store.compare_snapshot(name, snapshot)
3605
+
3606
+ if format == "json":
3607
+ console.print(json.dumps(result, indent=2, default=str))
3608
+ return
3609
+
3610
+ # Summary output
3611
+ console.print(
3612
+ Panel(
3613
+ f"[bold]Comparing snapshot '{snapshot}' against group '{name}'[/bold]\n\n"
3614
+ f"[dim]Total in group:[/dim] {result['total_in_group']}\n"
3615
+ f"[dim]Total in snapshot:[/dim] {result['total_in_snapshot']}\n\n"
3616
+ f"[green]✓ Matched:[/green] {result['matched']}\n"
3617
+ f"[red]✗ Missing from snapshot:[/red] {result['missing_from_snapshot']}\n"
3618
+ f"[yellow]+ Not in group:[/yellow] {result['not_in_group']}",
3619
+ title="Comparison Results",
3620
+ border_style="blue",
3621
+ )
3622
+ )
3623
+
3624
+ if show_details or format == "table":
3625
+ # Show missing resources
3626
+ if result["resources"]["missing"]:
3627
+ console.print("\n[red bold]Missing from snapshot:[/red bold]")
3628
+ table = Table(show_header=True, header_style="bold red")
3629
+ table.add_column("Resource Name")
3630
+ table.add_column("Type")
3631
+ for r in result["resources"]["missing"][:25]:
3632
+ table.add_row(r["name"], r["resource_type"])
3633
+ console.print(table)
3634
+ if len(result["resources"]["missing"]) > 25:
3635
+ console.print(f"[dim]... and {len(result['resources']['missing']) - 25} more[/dim]")
3636
+
3637
+ # Show extra resources
3638
+ if result["resources"]["extra"]:
3639
+ console.print("\n[yellow bold]Not in group (extra):[/yellow bold]")
3640
+ table = Table(show_header=True, header_style="bold yellow")
3641
+ table.add_column("Resource Name")
3642
+ table.add_column("Type")
3643
+ table.add_column("ARN", style="dim")
3644
+ for r in result["resources"]["extra"][:25]:
3645
+ table.add_row(
3646
+ r["name"],
3647
+ r["resource_type"],
3648
+ r["arn"][:50] + "..." if len(r["arn"]) > 50 else r["arn"],
3649
+ )
3650
+ console.print(table)
3651
+ if len(result["resources"]["extra"]) > 25:
3652
+ console.print(f"[dim]... and {len(result['resources']['extra']) - 25} more[/dim]")
3653
+
3654
+ except ValueError as e:
3655
+ console.print(f"[red]Error: {e}[/red]")
3656
+ raise typer.Exit(code=1)
3657
+ except Exception as e:
3658
+ console.print(f"[red]Error: {e}[/red]")
3659
+ logger.exception("Comparison failed")
3660
+ raise typer.Exit(code=1)
3661
+
3662
+
3663
+ @group_app.command("add")
3664
+ def group_add(
3665
+ name: str = typer.Argument(..., help="Group name"),
3666
+ resource: str = typer.Option(..., "--resource", "-r", help="Resource to add as 'name:type' (e.g., 'my-bucket:s3:bucket')"),
3667
+ ):
3668
+ """Add a resource to a group manually.
3669
+
3670
+ Resources are specified as 'name:type' where type is the AWS resource type.
3671
+
3672
+ Examples:
3673
+ awsinv group add baseline --resource "my-bucket:s3:bucket"
3674
+ awsinv group add iam-baseline --resource "AdminRole:iam:role"
3675
+ """
3676
+ from ..storage import Database, GroupStore
3677
+ from ..models.group import GroupMember
3678
+
3679
+ setup_logging()
3680
+
3681
+ try:
3682
+ # Parse resource string
3683
+ parts = resource.split(":", 1)
3684
+ if len(parts) != 2:
3685
+ console.print("[red]Error: Resource must be specified as 'name:type' (e.g., 'my-bucket:s3:bucket')[/red]")
3686
+ raise typer.Exit(code=1)
3687
+
3688
+ resource_name, resource_type = parts
3689
+
3690
+ db = Database()
3691
+ db.ensure_schema()
3692
+ store = GroupStore(db)
3693
+
3694
+ if not store.exists(name):
3695
+ console.print(f"[red]Error: Group '{name}' not found[/red]")
3696
+ raise typer.Exit(code=1)
3697
+
3698
+ member = GroupMember(resource_name=resource_name, resource_type=resource_type)
3699
+ added = store.add_members(name, [member])
3700
+
3701
+ if added > 0:
3702
+ console.print(f"[green]✓ Added '{resource_name}' ({resource_type}) to group '{name}'[/green]")
3703
+ else:
3704
+ console.print(f"[yellow]Resource already exists in group[/yellow]")
3705
+
3706
+ except typer.Exit:
3707
+ raise
3708
+ except Exception as e:
3709
+ console.print(f"[red]Error: {e}[/red]")
3710
+ logger.exception("Failed to add resource to group")
3711
+ raise typer.Exit(code=1)
3712
+
3713
+
3714
+ @group_app.command("remove")
3715
+ def group_remove(
3716
+ name: str = typer.Argument(..., help="Group name"),
3717
+ resource: str = typer.Option(..., "--resource", "-r", help="Resource to remove as 'name:type'"),
3718
+ ):
3719
+ """Remove a resource from a group.
3720
+
3721
+ Examples:
3722
+ awsinv group remove baseline --resource "my-bucket:s3:bucket"
3723
+ """
3724
+ from ..storage import Database, GroupStore
3725
+
3726
+ setup_logging()
3727
+
3728
+ try:
3729
+ # Parse resource string
3730
+ parts = resource.split(":", 1)
3731
+ if len(parts) != 2:
3732
+ console.print("[red]Error: Resource must be specified as 'name:type'[/red]")
3733
+ raise typer.Exit(code=1)
3734
+
3735
+ resource_name, resource_type = parts
3736
+
3737
+ db = Database()
3738
+ db.ensure_schema()
3739
+ store = GroupStore(db)
3740
+
3741
+ if not store.exists(name):
3742
+ console.print(f"[red]Error: Group '{name}' not found[/red]")
3743
+ raise typer.Exit(code=1)
3744
+
3745
+ removed = store.remove_member(name, resource_name, resource_type)
3746
+
3747
+ if removed:
3748
+ console.print(f"[green]✓ Removed '{resource_name}' ({resource_type}) from group '{name}'[/green]")
3749
+ else:
3750
+ console.print(f"[yellow]Resource not found in group[/yellow]")
3751
+
3752
+ except typer.Exit:
3753
+ raise
3754
+ except Exception as e:
3755
+ console.print(f"[red]Error: {e}[/red]")
3756
+ logger.exception("Failed to remove resource from group")
3757
+ raise typer.Exit(code=1)
3758
+
3759
+
3760
+ @group_app.command("export")
3761
+ def group_export(
3762
+ name: str = typer.Argument(..., help="Group name"),
3763
+ format: str = typer.Option("yaml", "--format", "-f", help="Output format: yaml, csv, json"),
3764
+ output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file (stdout if not specified)"),
3765
+ ):
3766
+ """Export a group definition.
3767
+
3768
+ Examples:
3769
+ awsinv group export baseline --format yaml
3770
+ awsinv group export baseline --format csv --output baseline.csv
3771
+ """
3772
+ from ..storage import Database, GroupStore
3773
+ import json
3774
+ import yaml
3775
+ import csv
3776
+ import sys
3777
+
3778
+ setup_logging()
3779
+
3780
+ try:
3781
+ db = Database()
3782
+ db.ensure_schema()
3783
+ store = GroupStore(db)
3784
+
3785
+ group = store.load(name)
3786
+ if not group:
3787
+ console.print(f"[red]Error: Group '{name}' not found[/red]")
3788
+ raise typer.Exit(code=1)
3789
+
3790
+ # Prepare output
3791
+ if format == "json":
3792
+ content = json.dumps(group.to_dict(), indent=2, default=str)
3793
+ elif format == "csv":
3794
+ import io
3795
+
3796
+ buffer = io.StringIO()
3797
+ writer = csv.writer(buffer)
3798
+ writer.writerow(["resource_name", "resource_type", "original_arn"])
3799
+ for member in group.members:
3800
+ writer.writerow([member.resource_name, member.resource_type, member.original_arn or ""])
3801
+ content = buffer.getvalue()
3802
+ else: # yaml
3803
+ content = yaml.dump(group.to_dict(), default_flow_style=False, sort_keys=False)
3804
+
3805
+ if output:
3806
+ with open(output, "w") as f:
3807
+ f.write(content)
3808
+ console.print(f"[green]✓ Exported group '{name}' to {output}[/green]")
3809
+ else:
3810
+ console.print(content)
3811
+
3812
+ except typer.Exit:
3813
+ raise
3814
+ except Exception as e:
3815
+ console.print(f"[red]Error: {e}[/red]")
3816
+ logger.exception("Export failed")
3817
+ raise typer.Exit(code=1)
3818
+
3819
+
3820
+ app.add_typer(group_app, name="group")
3821
+
3822
+
3823
+ # =============================================================================
3824
+ # Serve Command (Web UI)
3825
+ # =============================================================================
3826
+
3827
+
3828
+ @app.command()
3829
+ def serve(
3830
+ host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
3831
+ port: int = typer.Option(8080, "--port", "-p", help="Port to bind to"),
3832
+ open_browser: bool = typer.Option(True, "--open/--no-open", help="Open browser on startup"),
3833
+ reload: bool = typer.Option(False, "--reload", help="Enable auto-reload for development"),
3834
+ ):
3835
+ """Launch web-based inventory browser.
3836
+
3837
+ Starts a local web server with a beautiful UI for browsing snapshots,
3838
+ exploring resources, running queries, and managing cleanup operations.
3839
+ """
3840
+ try:
3841
+ import uvicorn
3842
+ except ImportError:
3843
+ console.print(
3844
+ "[red]Web dependencies not installed.[/red]\n"
3845
+ "Install with: [cyan]pip install aws-inventory-manager[web][/cyan]"
3846
+ )
3847
+ raise typer.Exit(code=1)
3848
+
3849
+ from ..web.app import create_app
3850
+
3851
+ # Load config for storage path
3852
+ global config
3853
+ if config is None:
3854
+ config = Config.load()
3855
+
3856
+ console.print(
3857
+ Panel.fit(
3858
+ f"[cyan bold]AWS Inventory Browser[/cyan bold]\n\n"
3859
+ f"[green]Server:[/green] http://{host}:{port}\n"
3860
+ f"[dim]Press Ctrl+C to stop[/dim]",
3861
+ title="Starting Web Server",
3862
+ border_style="blue",
3863
+ )
3864
+ )
3865
+
3866
+ if open_browser:
3867
+ import threading
3868
+ import time
3869
+ import webbrowser
3870
+
3871
+ def open_delayed():
3872
+ time.sleep(1.5)
3873
+ webbrowser.open(f"http://{host}:{port}")
3874
+
3875
+ threading.Thread(target=open_delayed, daemon=True).start()
3876
+
3877
+ # Create app with storage path from config
3878
+ app_instance = create_app(config.storage_path)
3879
+
3880
+ uvicorn.run(
3881
+ app_instance,
3882
+ host=host,
3883
+ port=port,
3884
+ reload=reload,
3885
+ log_level="info",
3886
+ )
3887
+
3888
+
3889
+ # =============================================================================
3890
+ # Normalize Command (AI Normalization)
3891
+ # =============================================================================
3892
+
3893
+
3894
+ @app.command()
3895
+ def normalize(
3896
+ snapshot: str = typer.Option(..., "--snapshot", "-s", help="Snapshot name to normalize"),
3897
+ dry_run: bool = typer.Option(False, "--dry-run", help="Preview normalizations without saving"),
3898
+ use_ai: bool = typer.Option(True, "--ai/--no-ai", help="Use AI for ambiguous names (default: enabled)"),
3899
+ ):
3900
+ """Re-run AI normalization on an existing snapshot.
3901
+
3902
+ This command updates the normalized_name column for all resources
3903
+ in the specified snapshot using AI-based name normalization.
3904
+
3905
+ Use this to:
3906
+ - Backfill normalized names for snapshots created before AI normalization
3907
+ - Re-normalize with updated AI models or prompts
3908
+ - Preview normalizations with --dry-run before committing
3909
+
3910
+ Example:
3911
+ awsinv normalize --snapshot my-snapshot-20260113
3912
+ awsinv normalize --snapshot my-snapshot --dry-run
3913
+ awsinv normalize --snapshot my-snapshot --no-ai
3914
+ """
3915
+ global config
3916
+ if config is None:
3917
+ config = Config.load()
3918
+
3919
+ from ..storage import Database, SnapshotStore
3920
+
3921
+ try:
3922
+ from ..matching import NormalizerConfig, ResourceNormalizer
3923
+ except ImportError:
3924
+ console.print(
3925
+ "[red]AI dependencies not installed.[/red]\n"
3926
+ "Install with: [cyan]pip install aws-inventory-manager[ai][/cyan]"
3927
+ )
3928
+ raise typer.Exit(code=1)
3929
+
3930
+ # Initialize database
3931
+ db = Database(config.storage_path)
3932
+ snapshot_store = SnapshotStore(db)
3933
+
3934
+ # Check snapshot exists
3935
+ if not snapshot_store.exists(snapshot):
3936
+ console.print(f"[red]✗ Snapshot '{snapshot}' not found[/red]")
3937
+ raise typer.Exit(code=1)
3938
+
3939
+ # Load the snapshot
3940
+ console.print(f"[cyan]Loading snapshot '[bold]{snapshot}[/bold]'...[/cyan]")
3941
+ snapshot_obj = snapshot_store.load(snapshot)
3942
+
3943
+ if not snapshot_obj or not snapshot_obj.resources:
3944
+ console.print(f"[yellow]⚠ Snapshot '{snapshot}' has no resources to normalize[/yellow]")
3945
+ raise typer.Exit(code=0)
3946
+
3947
+ console.print(f" Found [bold]{len(snapshot_obj.resources)}[/bold] resources")
3948
+
3949
+ # Initialize normalizer
3950
+ normalizer_config = NormalizerConfig.from_env()
3951
+
3952
+ if use_ai and not normalizer_config.is_ai_enabled:
3953
+ console.print("[yellow]⚠ OPENAI_API_KEY not set - using rules-based normalization only[/yellow]")
3954
+ use_ai = False
3955
+
3956
+ normalizer = ResourceNormalizer(normalizer_config)
3957
+
3958
+ # Prepare resources for normalization
3959
+ resource_dicts = [
3960
+ {
3961
+ "arn": r.arn,
3962
+ "name": r.name,
3963
+ "resource_type": r.resource_type,
3964
+ "tags": r.tags,
3965
+ }
3966
+ for r in snapshot_obj.resources
3967
+ ]
3968
+
3969
+ # Run normalization
3970
+ if use_ai:
3971
+ console.print("[cyan]Running AI-assisted normalization...[/cyan]")
3972
+ else:
3973
+ console.print("[cyan]Running rules-based normalization...[/cyan]")
3974
+
3975
+ with console.status("[bold green]Normalizing resources..."):
3976
+ normalized_names = normalizer.normalize_resources(resource_dicts, use_ai=use_ai)
3977
+
3978
+ console.print(f" Normalized [bold]{len(normalized_names)}[/bold] resource names")
3979
+
3980
+ if normalizer.tokens_used > 0:
3981
+ console.print(f" AI tokens used: [dim]{normalizer.tokens_used}[/dim]")
3982
+
3983
+ # Show preview in dry-run mode
3984
+ if dry_run:
3985
+ console.print("\n[yellow]DRY RUN - No changes saved[/yellow]\n")
3986
+
3987
+ # Build a table of changes
3988
+ table = Table(title="Normalization Preview (first 20)")
3989
+ table.add_column("Resource Type", style="cyan")
3990
+ table.add_column("Original Name", style="white")
3991
+ table.add_column("Normalized Name", style="green")
3992
+
3993
+ count = 0
3994
+ for r in snapshot_obj.resources:
3995
+ if count >= 20:
3996
+ break
3997
+ norm_name = normalized_names.get(r.arn, r.name)
3998
+ # Only show if different or meaningful
3999
+ table.add_row(
4000
+ r.resource_type.split("::")[-1] if r.resource_type else "Unknown",
4001
+ r.name or "(no name)",
4002
+ norm_name,
4003
+ )
4004
+ count += 1
4005
+
4006
+ console.print(table)
4007
+
4008
+ if len(snapshot_obj.resources) > 20:
4009
+ console.print(f"\n[dim]... and {len(snapshot_obj.resources) - 20} more resources[/dim]")
4010
+
4011
+ console.print("\n[yellow]Run without --dry-run to save changes[/yellow]")
4012
+ raise typer.Exit(code=0)
4013
+
4014
+ # Update database with normalized names
4015
+ console.print("[cyan]Updating database...[/cyan]")
4016
+
4017
+ snapshot_id = snapshot_store.get_id(snapshot)
4018
+ if snapshot_id is None:
4019
+ console.print("[red]✗ Failed to get snapshot ID[/red]")
4020
+ raise typer.Exit(code=1)
4021
+
4022
+ updated_count = 0
4023
+ with db.transaction() as cursor:
4024
+ for r in snapshot_obj.resources:
4025
+ norm_name = normalized_names.get(r.arn)
4026
+ if norm_name:
4027
+ cursor.execute(
4028
+ """
4029
+ UPDATE resources
4030
+ SET normalized_name = ?
4031
+ WHERE snapshot_id = ? AND arn = ?
4032
+ """,
4033
+ (norm_name, snapshot_id, r.arn),
4034
+ )
4035
+ updated_count += cursor.rowcount
4036
+
4037
+ console.print(f"[green]✓ Updated {updated_count} resources with normalized names[/green]")
4038
+
4039
+
4040
+ def cli_main():
4041
+ """Entry point for console script."""
4042
+ app()
4043
+
4044
+
4045
+ if __name__ == "__main__":
4046
+ cli_main()