aws-inventory-manager 0.2.0__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.

Potentially problematic release.


This version of aws-inventory-manager might be problematic. Click here for more details.

Files changed (65) hide show
  1. aws_inventory_manager-0.2.0.dist-info/METADATA +508 -0
  2. aws_inventory_manager-0.2.0.dist-info/RECORD +65 -0
  3. aws_inventory_manager-0.2.0.dist-info/WHEEL +5 -0
  4. aws_inventory_manager-0.2.0.dist-info/entry_points.txt +2 -0
  5. aws_inventory_manager-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. aws_inventory_manager-0.2.0.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 +5 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +1450 -0
  15. src/cost/__init__.py +5 -0
  16. src/cost/analyzer.py +226 -0
  17. src/cost/explorer.py +209 -0
  18. src/cost/reporter.py +237 -0
  19. src/delta/__init__.py +5 -0
  20. src/delta/calculator.py +180 -0
  21. src/delta/reporter.py +225 -0
  22. src/models/__init__.py +17 -0
  23. src/models/cost_report.py +87 -0
  24. src/models/delta_report.py +111 -0
  25. src/models/inventory.py +124 -0
  26. src/models/resource.py +99 -0
  27. src/models/snapshot.py +108 -0
  28. src/snapshot/__init__.py +6 -0
  29. src/snapshot/capturer.py +347 -0
  30. src/snapshot/filter.py +245 -0
  31. src/snapshot/inventory_storage.py +264 -0
  32. src/snapshot/resource_collectors/__init__.py +5 -0
  33. src/snapshot/resource_collectors/apigateway.py +140 -0
  34. src/snapshot/resource_collectors/backup.py +136 -0
  35. src/snapshot/resource_collectors/base.py +81 -0
  36. src/snapshot/resource_collectors/cloudformation.py +55 -0
  37. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  38. src/snapshot/resource_collectors/codebuild.py +69 -0
  39. src/snapshot/resource_collectors/codepipeline.py +82 -0
  40. src/snapshot/resource_collectors/dynamodb.py +65 -0
  41. src/snapshot/resource_collectors/ec2.py +240 -0
  42. src/snapshot/resource_collectors/ecs.py +215 -0
  43. src/snapshot/resource_collectors/eks.py +200 -0
  44. src/snapshot/resource_collectors/elb.py +126 -0
  45. src/snapshot/resource_collectors/eventbridge.py +156 -0
  46. src/snapshot/resource_collectors/iam.py +188 -0
  47. src/snapshot/resource_collectors/kms.py +111 -0
  48. src/snapshot/resource_collectors/lambda_func.py +112 -0
  49. src/snapshot/resource_collectors/rds.py +109 -0
  50. src/snapshot/resource_collectors/route53.py +86 -0
  51. src/snapshot/resource_collectors/s3.py +105 -0
  52. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  53. src/snapshot/resource_collectors/sns.py +68 -0
  54. src/snapshot/resource_collectors/sqs.py +72 -0
  55. src/snapshot/resource_collectors/ssm.py +160 -0
  56. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  57. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  58. src/snapshot/resource_collectors/waf.py +159 -0
  59. src/snapshot/storage.py +259 -0
  60. src/utils/__init__.py +12 -0
  61. src/utils/export.py +87 -0
  62. src/utils/hash.py +60 -0
  63. src/utils/logging.py +63 -0
  64. src/utils/paths.py +51 -0
  65. src/utils/progress.py +41 -0
src/cli/main.py ADDED
@@ -0,0 +1,1450 @@
1
+ """Main CLI entry point using Typer."""
2
+
3
+ import logging
4
+ import sys
5
+ from datetime import datetime
6
+ from typing import 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(None, "--profile", "-p", help="AWS profile name"),
164
+ storage_path: Optional[str] = typer.Option(
165
+ None,
166
+ "--storage-path",
167
+ help="Custom path for snapshot storage (default: ~/.snapshots or $AWS_INVENTORY_STORAGE_PATH)",
168
+ ),
169
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose logging"),
170
+ quiet: bool = typer.Option(False, "--quiet", "-q", help="Suppress output except errors"),
171
+ no_color: bool = typer.Option(False, "--no-color", help="Disable colored output"),
172
+ ):
173
+ """AWS Inventory Manager - Resource Snapshot & Delta Tracking CLI tool."""
174
+ global config
175
+
176
+ # Load configuration
177
+ config = Config.load()
178
+
179
+ # Override with CLI options
180
+ if profile:
181
+ config.aws_profile = profile
182
+
183
+ # Store storage path in config for use by commands
184
+ if storage_path:
185
+ config.storage_path = storage_path
186
+ else:
187
+ config.storage_path = None
188
+
189
+ # Setup logging
190
+ log_level = "ERROR" if quiet else ("DEBUG" if verbose else config.log_level)
191
+ setup_logging(level=log_level, verbose=verbose)
192
+
193
+ # Disable colors if requested
194
+ if no_color:
195
+ console.no_color = True
196
+
197
+
198
+ @app.command()
199
+ def version():
200
+ """Show version information."""
201
+ import boto3
202
+
203
+ from .. import __version__
204
+
205
+ console.print(f"aws-inventory-manager version {__version__}")
206
+ console.print(f"Python {sys.version.split()[0]}")
207
+ console.print(f"boto3 {boto3.__version__}")
208
+
209
+
210
+ # Inventory commands group
211
+ inventory_app = typer.Typer(help="Inventory management commands")
212
+ app.add_typer(inventory_app, name="inventory")
213
+
214
+
215
+ # Helper function to parse tag strings (shared by snapshot and inventory commands)
216
+ def parse_tags(tag_string: str) -> dict:
217
+ """Parse comma-separated Key=Value pairs into dict."""
218
+ tags = {}
219
+ for tag_pair in tag_string.split(","):
220
+ if "=" not in tag_pair:
221
+ console.print("✗ Invalid tag format. Use Key=Value", style="bold red")
222
+ raise typer.Exit(code=1)
223
+ key, value = tag_pair.split("=", 1)
224
+ tags[key.strip()] = value.strip()
225
+ return tags
226
+
227
+
228
+ @inventory_app.command("create")
229
+ def inventory_create(
230
+ name: str = typer.Argument(..., help="Inventory name (alphanumeric, hyphens, underscores only)"),
231
+ description: Optional[str] = typer.Option(None, "--description", "-d", help="Human-readable description"),
232
+ include_tags: Optional[str] = typer.Option(
233
+ None, "--include-tags", help="Include only resources with ALL these tags (Key=Value,Key2=Value2)"
234
+ ),
235
+ exclude_tags: Optional[str] = typer.Option(
236
+ None, "--exclude-tags", help="Exclude resources with ANY of these tags (Key=Value,Key2=Value2)"
237
+ ),
238
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name to use"),
239
+ ):
240
+ """Create a new inventory for organizing snapshots.
241
+
242
+ Inventories allow you to organize snapshots by purpose (e.g., baseline, team-a-resources)
243
+ with optional tag-based filters that automatically apply to all snapshots in that inventory.
244
+
245
+ Examples:
246
+ # Create basic inventory with no filters
247
+ aws-baseline inventory create baseline --description "Production baseline resources"
248
+
249
+ # Create filtered inventory for team resources
250
+ aws-baseline inventory create team-a-resources \\
251
+ --description "Team Alpha project resources" \\
252
+ --include-tags "team=alpha,env=prod" \\
253
+ --exclude-tags "managed-by=terraform"
254
+ """
255
+ try:
256
+ from datetime import datetime, timezone
257
+
258
+ from ..aws.credentials import get_account_id
259
+ from ..models.inventory import Inventory
260
+ from ..snapshot.inventory_storage import InventoryStorage
261
+
262
+ # Use profile parameter if provided, otherwise use config
263
+ aws_profile = profile if profile else config.aws_profile
264
+
265
+ # Validate credentials and get account ID
266
+ console.print("🔐 Validating AWS credentials...")
267
+ account_id = get_account_id(aws_profile)
268
+ console.print(f"✓ Authenticated for account: {account_id}\n", style="green")
269
+
270
+ # Validate inventory name format
271
+ import re
272
+
273
+ if not re.match(r"^[a-zA-Z0-9_-]+$", name):
274
+ console.print("✗ Error: Invalid inventory name", style="bold red")
275
+ console.print("Name must contain only alphanumeric characters, hyphens, and underscores\n")
276
+ raise typer.Exit(code=1)
277
+
278
+ if len(name) > 50:
279
+ console.print("✗ Error: Inventory name too long", style="bold red")
280
+ console.print("Name must be 50 characters or less\n")
281
+ raise typer.Exit(code=1)
282
+
283
+ # Check for duplicate
284
+ storage = InventoryStorage(config.storage_path)
285
+ if storage.exists(name, account_id):
286
+ console.print(f"✗ Error: Inventory '{name}' already exists for account {account_id}", style="bold red")
287
+ console.print("\nUse a different name or delete the existing inventory first:")
288
+ console.print(f" aws-baseline inventory delete {name}\n")
289
+ raise typer.Exit(code=1)
290
+
291
+ # Parse tags if provided
292
+ include_tag_dict = {}
293
+ exclude_tag_dict = {}
294
+
295
+ if include_tags:
296
+ include_tag_dict = parse_tags(include_tags)
297
+
298
+ if exclude_tags:
299
+ exclude_tag_dict = parse_tags(exclude_tags)
300
+
301
+ # Create inventory
302
+ inventory = Inventory(
303
+ name=name,
304
+ account_id=account_id,
305
+ description=description or "",
306
+ include_tags=include_tag_dict,
307
+ exclude_tags=exclude_tag_dict,
308
+ snapshots=[],
309
+ active_snapshot=None,
310
+ created_at=datetime.now(timezone.utc),
311
+ last_updated=datetime.now(timezone.utc),
312
+ )
313
+
314
+ # Save inventory
315
+ storage.save(inventory)
316
+
317
+ # T042: Audit logging for create operation
318
+ logger.info(
319
+ f"Created inventory '{name}' for account {account_id} with "
320
+ f"{len(include_tag_dict)} include filters and {len(exclude_tag_dict)} exclude filters"
321
+ )
322
+
323
+ # Display success message
324
+ console.print(f"✓ Created inventory '[bold]{name}[/bold]' for account {account_id}", style="green")
325
+ console.print()
326
+ console.print("[bold]Inventory Details:[/bold]")
327
+ console.print(f" Name: {name}")
328
+ console.print(f" Account: {account_id}")
329
+ console.print(f" Description: {description or '(none)'}")
330
+
331
+ # Display filters
332
+ if include_tag_dict or exclude_tag_dict:
333
+ console.print(" Filters:")
334
+ if include_tag_dict:
335
+ tag_str = ", ".join(f"{k}={v}" for k, v in include_tag_dict.items())
336
+ console.print(f" Include Tags: {tag_str} (resources must have ALL)")
337
+ if exclude_tag_dict:
338
+ tag_str = ", ".join(f"{k}={v}" for k, v in exclude_tag_dict.items())
339
+ console.print(f" Exclude Tags: {tag_str} (resources must NOT have ANY)")
340
+ else:
341
+ console.print(" Filters: None")
342
+
343
+ console.print(" Snapshots: 0")
344
+ console.print()
345
+
346
+ except typer.Exit:
347
+ raise
348
+ except Exception as e:
349
+ console.print(f"✗ Error creating inventory: {e}", style="bold red")
350
+ raise typer.Exit(code=2)
351
+
352
+
353
+ @inventory_app.command("list")
354
+ def inventory_list(
355
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name to use"),
356
+ ):
357
+ """List all inventories for the current AWS account.
358
+
359
+ Displays a table showing all inventories with their snapshot counts,
360
+ filter settings, and descriptions.
361
+ """
362
+ try:
363
+ from ..aws.credentials import get_account_id
364
+ from ..snapshot.inventory_storage import InventoryStorage
365
+
366
+ # Use profile parameter if provided, otherwise use config
367
+ aws_profile = profile if profile else config.aws_profile
368
+
369
+ # Get account ID
370
+ account_id = get_account_id(aws_profile)
371
+
372
+ # Load inventories
373
+ storage = InventoryStorage(config.storage_path)
374
+ inventories = storage.load_by_account(account_id)
375
+
376
+ if not inventories:
377
+ console.print(f"No inventories found for account {account_id}", style="yellow")
378
+ console.print("\nCreate one with: aws-baseline inventory create <name>")
379
+ return
380
+
381
+ # Create table
382
+ table = Table(title=f"Inventories for Account {account_id}", show_header=True, header_style="bold magenta")
383
+ table.add_column("Name", style="cyan", width=25)
384
+ table.add_column("Snapshots", justify="center", width=12)
385
+ table.add_column("Filters", width=15)
386
+ table.add_column("Description", width=40)
387
+
388
+ for inv in inventories:
389
+ # Determine filter summary
390
+ if inv.include_tags or inv.exclude_tags:
391
+ inc_count = len(inv.include_tags)
392
+ exc_count = len(inv.exclude_tags)
393
+ filter_text = f"Yes ({inc_count}/{exc_count})"
394
+ else:
395
+ filter_text = "None"
396
+
397
+ table.add_row(inv.name, str(len(inv.snapshots)), filter_text, inv.description or "(no description)")
398
+
399
+ console.print()
400
+ console.print(table)
401
+ console.print()
402
+ console.print(f"Total Inventories: {len(inventories)}")
403
+ console.print()
404
+
405
+ except Exception as e:
406
+ console.print(f"✗ Error listing inventories: {e}", style="bold red")
407
+ raise typer.Exit(code=2)
408
+
409
+
410
+ @inventory_app.command("show")
411
+ def inventory_show(
412
+ name: str = typer.Argument(..., help="Inventory name to display"),
413
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name to use"),
414
+ ):
415
+ """Show detailed information for a specific inventory.
416
+
417
+ Displays full details including filters, snapshots, and timestamps.
418
+ """
419
+ try:
420
+ from ..aws.credentials import get_account_id
421
+ from ..snapshot.inventory_storage import InventoryNotFoundError, InventoryStorage
422
+
423
+ # Use profile parameter if provided, otherwise use config
424
+ aws_profile = profile if profile else config.aws_profile
425
+
426
+ # Get account ID
427
+ account_id = get_account_id(aws_profile)
428
+
429
+ # Load inventory
430
+ storage = InventoryStorage(config.storage_path)
431
+ try:
432
+ inventory = storage.get_by_name(name, account_id)
433
+ except InventoryNotFoundError:
434
+ console.print(f"✗ Error: Inventory '{name}' not found for account {account_id}", style="bold red")
435
+ console.print("\nList available inventories with: aws-baseline inventory list")
436
+ raise typer.Exit(code=1)
437
+
438
+ # Display inventory details
439
+ console.print()
440
+ console.print(f"[bold]Inventory: {inventory.name}[/bold]")
441
+ console.print(f"Account: {inventory.account_id}")
442
+ console.print(f"Description: {inventory.description or '(none)'}")
443
+ console.print(f"Created: {inventory.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
444
+ console.print(f"Last Updated: {inventory.last_updated.strftime('%Y-%m-%d %H:%M:%S UTC')}")
445
+ console.print()
446
+
447
+ # Display filters
448
+ if inventory.include_tags or inventory.exclude_tags:
449
+ console.print("[bold]Filters:[/bold]")
450
+ if inventory.include_tags:
451
+ console.print(" Include Tags (must have ALL):")
452
+ for key, value in inventory.include_tags.items():
453
+ console.print(f" • {key} = {value}")
454
+ if inventory.exclude_tags:
455
+ console.print(" Exclude Tags (must NOT have ANY):")
456
+ for key, value in inventory.exclude_tags.items():
457
+ console.print(f" • {key} = {value}")
458
+ console.print()
459
+
460
+ # Display snapshots
461
+ console.print(f"[bold]Snapshots: {len(inventory.snapshots)}[/bold]")
462
+ if inventory.snapshots:
463
+ for snapshot_file in inventory.snapshots:
464
+ active_marker = " [green](active)[/green]" if snapshot_file == inventory.active_snapshot else ""
465
+ console.print(f" • {snapshot_file}{active_marker}")
466
+ else:
467
+ console.print(" (No snapshots taken yet)")
468
+ console.print()
469
+
470
+ # Display active snapshot
471
+ if inventory.active_snapshot:
472
+ console.print(f"[bold]Active Baseline:[/bold] {inventory.active_snapshot}")
473
+ else:
474
+ console.print("[bold]Active Baseline:[/bold] None")
475
+ console.print()
476
+
477
+ except typer.Exit:
478
+ raise
479
+ except Exception as e:
480
+ console.print(f"✗ Error showing inventory: {e}", style="bold red")
481
+ raise typer.Exit(code=2)
482
+
483
+
484
+ @inventory_app.command("migrate")
485
+ def inventory_migrate(
486
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name to use"),
487
+ ):
488
+ """Migrate legacy snapshots to inventory structure.
489
+
490
+ Scans for snapshots without inventory assignment and adds them to the 'default' inventory.
491
+ """
492
+ try:
493
+ # Use profile parameter if provided, otherwise use config
494
+ aws_profile = profile if profile else config.aws_profile
495
+
496
+ # Validate credentials
497
+ identity = validate_credentials(aws_profile)
498
+
499
+ console.print("🔄 Scanning for legacy snapshots...\n")
500
+
501
+ # T035: Scan .snapshots/ directory for snapshot files
502
+ storage = SnapshotStorage(config.storage_path)
503
+ from pathlib import Path
504
+ from typing import List
505
+
506
+ snapshots_dir = storage.storage_dir
507
+ snapshot_files: List[Path] = []
508
+
509
+ # Find all .yaml and .yaml.gz files
510
+ for pattern in ["*.yaml", "*.yaml.gz"]:
511
+ snapshot_files.extend(snapshots_dir.glob(pattern))
512
+
513
+ if not snapshot_files:
514
+ # T037: No snapshots found
515
+ console.print("✓ No legacy snapshots found. Nothing to migrate.", style="green")
516
+ raise typer.Exit(code=0)
517
+
518
+ # Load inventory storage
519
+ from ..snapshot.inventory_storage import InventoryStorage
520
+
521
+ inventory_storage = InventoryStorage(config.storage_path)
522
+
523
+ # Get or create default inventory
524
+ default_inventory = inventory_storage.get_or_create_default(identity["account_id"])
525
+
526
+ # T035: Check each snapshot for inventory assignment
527
+ legacy_count = 0
528
+ added_count = 0
529
+
530
+ for snapshot_file in snapshot_files:
531
+ snapshot_filename = snapshot_file.name
532
+ snapshot_name = snapshot_filename.replace(".yaml.gz", "").replace(".yaml", "")
533
+
534
+ # Skip if already in default inventory
535
+ if snapshot_filename in default_inventory.snapshots:
536
+ continue
537
+
538
+ try:
539
+ # Load snapshot to check if it has inventory_name
540
+ snapshot = storage.load_snapshot(snapshot_name)
541
+
542
+ # Check if snapshot belongs to this account
543
+ if snapshot.account_id != identity["account_id"]:
544
+ continue
545
+
546
+ # If inventory_name is 'default', it's a legacy snapshot
547
+ if snapshot.inventory_name == "default":
548
+ legacy_count += 1
549
+
550
+ # Add to default inventory
551
+ default_inventory.add_snapshot(snapshot_filename, set_active=False)
552
+ added_count += 1
553
+
554
+ except Exception as e:
555
+ # T037: Handle corrupted snapshot files
556
+ console.print(f"⚠️ Skipping {snapshot_filename}: {e}", style="yellow")
557
+ continue
558
+
559
+ # T035: Save updated default inventory
560
+ if added_count > 0:
561
+ inventory_storage.save(default_inventory)
562
+
563
+ # T036: Display progress feedback
564
+ console.print(f"✓ Found {legacy_count} snapshot(s) without inventory assignment", style="green")
565
+ if added_count > 0:
566
+ console.print(f"✓ Added {added_count} snapshot(s) to 'default' inventory", style="green")
567
+ console.print("\n✓ Migration complete!", style="bold green")
568
+ else:
569
+ console.print("\n✓ All snapshots already assigned to inventories", style="green")
570
+
571
+ except typer.Exit:
572
+ raise
573
+ except Exception as e:
574
+ console.print(f"✗ Error during migration: {e}", style="bold red")
575
+ import traceback
576
+
577
+ traceback.print_exc()
578
+ raise typer.Exit(code=2)
579
+
580
+
581
+ @inventory_app.command("delete")
582
+ def inventory_delete(
583
+ name: str = typer.Argument(..., help="Inventory name to delete"),
584
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompts"),
585
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name to use"),
586
+ ):
587
+ """Delete an inventory, optionally deleting its snapshot files.
588
+
589
+ WARNING: This will remove the inventory metadata. Snapshot files can be preserved or deleted.
590
+ """
591
+ try:
592
+ # Use profile parameter if provided, otherwise use config
593
+ aws_profile = profile if profile else config.aws_profile
594
+
595
+ # Validate credentials
596
+ identity = validate_credentials(aws_profile)
597
+
598
+ # Load inventory storage
599
+ from ..snapshot.inventory_storage import InventoryNotFoundError, InventoryStorage
600
+
601
+ storage = InventoryStorage(config.storage_path)
602
+
603
+ # T027, T032: Load inventory or error if doesn't exist
604
+ try:
605
+ inventory = storage.get_by_name(name, identity["account_id"])
606
+ except InventoryNotFoundError:
607
+ console.print(f"✗ Inventory '{name}' not found for account {identity['account_id']}", style="bold red")
608
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
609
+ raise typer.Exit(code=1)
610
+
611
+ # T032: Check if this would leave account with zero inventories
612
+ all_inventories = storage.load_by_account(identity["account_id"])
613
+ if len(all_inventories) == 1:
614
+ console.print(f"✗ Cannot delete '{name}' - it is the only inventory for this account", style="bold red")
615
+ console.print(" At least one inventory must exist per account", style="yellow")
616
+ raise typer.Exit(code=1)
617
+
618
+ # T028: Display inventory details for confirmation
619
+ console.print(f"\n📦 Inventory: [bold]{inventory.name}[/bold]")
620
+ if inventory.description:
621
+ console.print(f" {inventory.description}")
622
+ console.print(f" Snapshots: {len(inventory.snapshots)}")
623
+
624
+ # T029: Warn if this is the active snapshot
625
+ if inventory.active_snapshot:
626
+ console.print("\n⚠️ Warning: This inventory has an active snapshot snapshot!", style="bold yellow")
627
+ console.print(" Deleting it will prevent cost/delta analysis for this inventory.", style="yellow")
628
+
629
+ # T028: Confirmation prompt
630
+ if not force:
631
+ console.print()
632
+ confirm = typer.confirm(f"Delete inventory '{name}'?", default=False)
633
+ if not confirm:
634
+ console.print("Cancelled.")
635
+ raise typer.Exit(code=0)
636
+
637
+ # T030: Ask about snapshot file deletion
638
+ delete_snapshots = False
639
+ if inventory.snapshots and not force:
640
+ console.print()
641
+ delete_snapshots = typer.confirm(f"Delete {len(inventory.snapshots)} snapshot file(s) too?", default=False)
642
+ elif inventory.snapshots and force:
643
+ # With --force, don't delete snapshots by default (safer)
644
+ delete_snapshots = False
645
+
646
+ # T031, T032: Delete inventory (already implemented in InventoryStorage)
647
+ try:
648
+ deleted_count = storage.delete(name, identity["account_id"], delete_snapshots=delete_snapshots)
649
+ except Exception as e:
650
+ console.print(f"✗ Error deleting inventory: {e}", style="bold red")
651
+ raise typer.Exit(code=2)
652
+
653
+ # T042: Audit logging for delete operation
654
+ logger.info(
655
+ f"Deleted inventory '{name}' for account {identity['account_id']}, "
656
+ f"deleted {deleted_count} snapshot files, snapshots_deleted={delete_snapshots}"
657
+ )
658
+
659
+ # T033: Display completion messages
660
+ console.print(f"\n✓ Inventory '[bold]{name}[/bold]' deleted", style="green")
661
+ if delete_snapshots and deleted_count > 0:
662
+ console.print(f"✓ {deleted_count} snapshot file(s) deleted", style="green")
663
+ elif inventory.snapshots and not delete_snapshots:
664
+ console.print(f" {len(inventory.snapshots)} snapshot file(s) preserved", style="cyan")
665
+
666
+ except typer.Exit:
667
+ raise
668
+ except Exception as e:
669
+ console.print(f"✗ Error deleting inventory: {e}", style="bold red")
670
+ import traceback
671
+
672
+ traceback.print_exc()
673
+ raise typer.Exit(code=2)
674
+
675
+
676
+ # Snapshot commands group
677
+ snapshot_app = typer.Typer(help="Snapshot management commands")
678
+ app.add_typer(snapshot_app, name="snapshot")
679
+
680
+
681
+ @snapshot_app.command("create")
682
+ def snapshot_create(
683
+ name: Optional[str] = typer.Argument(None, help="Snapshot name (auto-generated if not provided)"),
684
+ regions: Optional[str] = typer.Option(
685
+ None, "--regions", help="Comma-separated list of regions (default: us-east-1)"
686
+ ),
687
+ profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name to use"),
688
+ inventory: Optional[str] = typer.Option(
689
+ None, "--inventory", help="Inventory name to use for filters (conflicts with --include-tags/--exclude-tags)"
690
+ ),
691
+ set_active: bool = typer.Option(True, "--set-active/--no-set-active", help="Set as active snapshot"),
692
+ compress: bool = typer.Option(False, "--compress", help="Compress snapshot with gzip"),
693
+ before_date: Optional[str] = typer.Option(
694
+ None, "--before-date", help="Include only resources created before date (YYYY-MM-DD)"
695
+ ),
696
+ after_date: Optional[str] = typer.Option(
697
+ None, "--after-date", help="Include only resources created on/after date (YYYY-MM-DD)"
698
+ ),
699
+ filter_tags: Optional[str] = typer.Option(None, "--filter-tags", help="DEPRECATED: use --include-tags instead"),
700
+ include_tags: Optional[str] = typer.Option(
701
+ None, "--include-tags", help="Include only resources with ALL these tags (Key=Value,Key2=Value2)"
702
+ ),
703
+ exclude_tags: Optional[str] = typer.Option(
704
+ None, "--exclude-tags", help="Exclude resources with ANY of these tags (Key=Value,Key2=Value2)"
705
+ ),
706
+ ):
707
+ """Create a new snapshot of AWS resources.
708
+
709
+ Captures resources from 25 AWS services:
710
+ - IAM: Roles, Users, Groups, Policies
711
+ - Lambda: Functions, Layers
712
+ - S3: Buckets
713
+ - EC2: Instances, Volumes, VPCs, Security Groups, Subnets, VPC Endpoints
714
+ - RDS: DB Instances, DB Clusters (including Aurora)
715
+ - CloudWatch: Alarms, Log Groups
716
+ - SNS: Topics
717
+ - SQS: Queues
718
+ - DynamoDB: Tables
719
+ - ELB: Load Balancers (Classic, ALB, NLB, GWLB)
720
+ - CloudFormation: Stacks
721
+ - API Gateway: REST APIs, HTTP APIs, WebSocket APIs
722
+ - EventBridge: Event Buses, Rules
723
+ - Secrets Manager: Secrets
724
+ - KMS: Customer-Managed Keys
725
+ - Systems Manager: Parameters, Documents
726
+ - Route53: Hosted Zones
727
+ - ECS: Clusters, Services, Task Definitions
728
+ - EKS: Clusters, Node Groups, Fargate Profiles
729
+ - Step Functions: State Machines
730
+ - WAF: Web ACLs (Regional & CloudFront)
731
+ - CodePipeline: Pipelines
732
+ - CodeBuild: Projects
733
+ - Backup: Backup Plans, Backup Vaults
734
+
735
+ Historical Baselines & Filtering:
736
+ Use --before-date, --after-date, --include-tags, and/or --exclude-tags to create
737
+ snapshots representing resources as they existed at specific points in time or with
738
+ specific characteristics.
739
+
740
+ Examples:
741
+ - Production only: --include-tags Environment=production
742
+ - Exclude test/dev: --exclude-tags Environment=test,Environment=dev
743
+ - Multiple filters: --include-tags Team=platform,Environment=prod --exclude-tags Status=archived
744
+ """
745
+ try:
746
+ # Use profile parameter if provided, otherwise use config
747
+ aws_profile = profile if profile else config.aws_profile
748
+
749
+ # Validate credentials
750
+ console.print("🔐 Validating AWS credentials...")
751
+ identity = validate_credentials(aws_profile)
752
+ console.print(f"✓ Authenticated as: {identity['arn']}\n", style="green")
753
+
754
+ # T012: Validate filter conflict - inventory vs inline tags
755
+ if inventory and (include_tags or exclude_tags):
756
+ console.print(
757
+ "✗ Error: Cannot use --inventory with --include-tags or --exclude-tags\n"
758
+ " Filters are defined in the inventory. Either:\n"
759
+ " 1. Use --inventory to apply inventory's filters, OR\n"
760
+ " 2. Use --include-tags/--exclude-tags for ad-hoc filtering",
761
+ style="bold red",
762
+ )
763
+ raise typer.Exit(code=1)
764
+
765
+ # T013: Load inventory and apply its filters
766
+ from ..snapshot.inventory_storage import InventoryStorage
767
+
768
+ inventory_storage = InventoryStorage(config.storage_path)
769
+ active_inventory = None
770
+ inventory_name = "default"
771
+
772
+ if inventory:
773
+ # Load specified inventory
774
+ try:
775
+ active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
776
+ inventory_name = inventory
777
+ console.print(f"📦 Using inventory: [bold]{inventory}[/bold]", style="cyan")
778
+ if active_inventory.description:
779
+ console.print(f" {active_inventory.description}")
780
+ except Exception:
781
+ # T018: Handle nonexistent inventory
782
+ console.print(
783
+ f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
784
+ )
785
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
786
+ raise typer.Exit(code=1)
787
+ else:
788
+ # Get or create default inventory (lazy creation)
789
+ active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
790
+ inventory_name = "default"
791
+
792
+ # Generate snapshot name if not provided (T014: use inventory in naming)
793
+ if not name:
794
+ timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
795
+ name = f"{identity['account_id']}-{inventory_name}-{timestamp}"
796
+
797
+ # Parse regions - default to us-east-1
798
+ region_list = []
799
+ if regions:
800
+ region_list = [r.strip() for r in regions.split(",")]
801
+ elif config.regions:
802
+ region_list = config.regions
803
+ else:
804
+ # Default to us-east-1
805
+ region_list = ["us-east-1"]
806
+
807
+ console.print(f"📸 Creating snapshot: [bold]{name}[/bold]")
808
+ console.print(f"Regions: {', '.join(region_list)}\n")
809
+
810
+ # Parse filters - use inventory filters if inventory specified, else inline filters
811
+ resource_filter = None
812
+
813
+ # T013: Determine which filters to use
814
+ if inventory:
815
+ # Use inventory's filters
816
+ include_tags_dict = active_inventory.include_tags if active_inventory.include_tags else None
817
+ exclude_tags_dict = active_inventory.exclude_tags if active_inventory.exclude_tags else None
818
+ else:
819
+ # Use inline filters from command-line
820
+ include_tags_dict = {}
821
+ exclude_tags_dict = {}
822
+
823
+ # Parse include tags (supports both --filter-tags and --include-tags)
824
+ if filter_tags:
825
+ console.print("⚠️ Note: --filter-tags is deprecated, use --include-tags", style="yellow")
826
+ try:
827
+ include_tags_dict = parse_tags(filter_tags)
828
+ except Exception as e:
829
+ console.print(f"✗ Error parsing filter-tags: {e}", style="bold red")
830
+ raise typer.Exit(code=1)
831
+
832
+ if include_tags:
833
+ try:
834
+ include_tags_dict.update(parse_tags(include_tags))
835
+ except Exception as e:
836
+ console.print(f"✗ Error parsing include-tags: {e}", style="bold red")
837
+ raise typer.Exit(code=1)
838
+
839
+ # Parse exclude tags
840
+ if exclude_tags:
841
+ try:
842
+ exclude_tags_dict = parse_tags(exclude_tags)
843
+ except Exception as e:
844
+ console.print(f"✗ Error parsing exclude-tags: {e}", style="bold red")
845
+ raise typer.Exit(code=1)
846
+
847
+ # Convert to None if empty
848
+ include_tags_dict = include_tags_dict if include_tags_dict else None
849
+ exclude_tags_dict = exclude_tags_dict if exclude_tags_dict else None
850
+
851
+ # Create filter if any filters or dates are specified
852
+ if before_date or after_date or include_tags_dict or exclude_tags_dict:
853
+ from datetime import datetime as dt
854
+
855
+ from ..snapshot.filter import ResourceFilter
856
+
857
+ # Parse dates
858
+ before_dt = None
859
+ after_dt = None
860
+
861
+ if before_date:
862
+ try:
863
+ # Parse as UTC timezone-aware
864
+ from datetime import timezone
865
+
866
+ before_dt = dt.strptime(before_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
867
+ except ValueError:
868
+ console.print("✗ Invalid --before-date format. Use YYYY-MM-DD (UTC)", style="bold red")
869
+ raise typer.Exit(code=1)
870
+
871
+ if after_date:
872
+ try:
873
+ # Parse as UTC timezone-aware
874
+ from datetime import timezone
875
+
876
+ after_dt = dt.strptime(after_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
877
+ except ValueError:
878
+ console.print("✗ Invalid --after-date format. Use YYYY-MM-DD (UTC)", style="bold red")
879
+ raise typer.Exit(code=1)
880
+
881
+ # Create filter
882
+ resource_filter = ResourceFilter(
883
+ before_date=before_dt,
884
+ after_date=after_dt,
885
+ include_tags=include_tags_dict,
886
+ exclude_tags=exclude_tags_dict,
887
+ )
888
+
889
+ console.print(f"{resource_filter.get_filter_summary()}\n")
890
+
891
+ # Import snapshot creation
892
+ from ..snapshot.capturer import create_snapshot
893
+
894
+ # T015: Pass inventory_name to create_snapshot
895
+ snapshot = create_snapshot(
896
+ name=name,
897
+ regions=region_list,
898
+ account_id=identity["account_id"],
899
+ profile_name=aws_profile,
900
+ set_active=set_active,
901
+ resource_filter=resource_filter,
902
+ inventory_name=inventory_name,
903
+ )
904
+
905
+ # T018: Check for zero resources after filtering
906
+ if snapshot.resource_count == 0:
907
+ console.print("⚠️ Warning: Snapshot contains 0 resources after filtering", style="bold yellow")
908
+ if resource_filter:
909
+ console.print(
910
+ " Your filters may be too restrictive. Consider:\n"
911
+ " - Adjusting tag filters\n"
912
+ " - Checking date ranges\n"
913
+ " - Verifying resources exist in the specified regions",
914
+ style="yellow",
915
+ )
916
+ console.print("\nSnapshot was not saved.\n")
917
+ raise typer.Exit(code=0)
918
+
919
+ # Save snapshot
920
+ storage = SnapshotStorage(config.storage_path)
921
+ filepath = storage.save_snapshot(snapshot, compress=compress)
922
+
923
+ # T016: Register snapshot with inventory
924
+ snapshot_filename = filepath.name
925
+ active_inventory.add_snapshot(snapshot_filename, set_active=set_active)
926
+ inventory_storage.save(active_inventory)
927
+
928
+ # T017: User feedback about inventory
929
+ console.print(f"\n✓ Added to inventory '[bold]{inventory_name}[/bold]'", style="green")
930
+ if set_active:
931
+ console.print(" Marked as active snapshot for this inventory", style="green")
932
+
933
+ # Display summary
934
+ console.print("\n✓ Snapshot complete!", style="bold green")
935
+ console.print("\nSummary:")
936
+ console.print(f" Name: {snapshot.name}")
937
+ console.print(f" Resources: {snapshot.resource_count}")
938
+ console.print(f" File: {filepath}")
939
+ console.print(f" Active: {'Yes' if snapshot.is_active else 'No'}")
940
+
941
+ # Show collection errors if any
942
+ collection_errors = snapshot.metadata.get("collection_errors", [])
943
+ if collection_errors:
944
+ console.print(f"\n⚠️ Note: {len(collection_errors)} service(s) were unavailable", style="yellow")
945
+
946
+ # Show filtering stats if filters were applied
947
+ if snapshot.filters_applied:
948
+ stats = snapshot.filters_applied.get("statistics", {})
949
+ console.print("\nFiltering:")
950
+ console.print(f" Collected: {stats.get('total_collected', 0)}")
951
+ console.print(f" Matched filters: {stats.get('final_count', 0)}")
952
+ console.print(f" Filtered out: {stats.get('total_collected', 0) - stats.get('final_count', 0)}")
953
+
954
+ # Show service breakdown
955
+ if snapshot.service_counts:
956
+ console.print("\nResources by service:")
957
+ table = Table(show_header=True)
958
+ table.add_column("Service", style="cyan")
959
+ table.add_column("Count", justify="right", style="green")
960
+
961
+ for service, count in sorted(snapshot.service_counts.items()):
962
+ table.add_row(service, str(count))
963
+
964
+ console.print(table)
965
+
966
+ except typer.Exit:
967
+ # Re-raise Exit exceptions (normal exit codes)
968
+ raise
969
+ except CredentialValidationError as e:
970
+ console.print(f"✗ Error: {e}", style="bold red")
971
+ raise typer.Exit(code=3)
972
+ except Exception as e:
973
+ console.print(f"✗ Error creating snapshot: {e}", style="bold red")
974
+ import traceback
975
+
976
+ traceback.print_exc()
977
+ raise typer.Exit(code=2)
978
+
979
+
980
+ @snapshot_app.command("list")
981
+ def snapshot_list():
982
+ """List all available snapshots."""
983
+ try:
984
+ storage = SnapshotStorage(config.storage_path)
985
+ snapshots = storage.list_snapshots()
986
+
987
+ if not snapshots:
988
+ console.print("No snapshots found.", style="yellow")
989
+ return
990
+
991
+ # Create table
992
+ table = Table(show_header=True, title="Available Snapshots")
993
+ table.add_column("Name", style="cyan")
994
+ table.add_column("Created", style="green")
995
+ table.add_column("Size (MB)", justify="right")
996
+ table.add_column("Active", justify="center")
997
+
998
+ for snap in snapshots:
999
+ active_marker = "✓" if snap["is_active"] else ""
1000
+ table.add_row(
1001
+ snap["name"],
1002
+ snap["modified"].strftime("%Y-%m-%d %H:%M"),
1003
+ f"{snap['size_mb']:.2f}",
1004
+ active_marker,
1005
+ )
1006
+
1007
+ console.print(table)
1008
+ console.print(f"\nTotal snapshots: {len(snapshots)}")
1009
+
1010
+ except Exception as e:
1011
+ console.print(f"✗ Error listing snapshots: {e}", style="bold red")
1012
+ raise typer.Exit(code=1)
1013
+
1014
+
1015
+ @snapshot_app.command("show")
1016
+ def snapshot_show(name: str = typer.Argument(..., help="Snapshot name to display")):
1017
+ """Display detailed information about a snapshot."""
1018
+ try:
1019
+ storage = SnapshotStorage(config.storage_path)
1020
+ snapshot = storage.load_snapshot(name)
1021
+
1022
+ console.print(f"\n[bold]Snapshot: {snapshot.name}[/bold]")
1023
+ console.print(f"Created: {snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
1024
+ console.print(f"Account: {snapshot.account_id}")
1025
+ console.print(f"Regions: {', '.join(snapshot.regions)}")
1026
+ console.print(f"Status: {'Active baseline' if snapshot.is_active else 'Inactive'}")
1027
+ console.print(f"Total resources: {snapshot.resource_count}\n")
1028
+
1029
+ # Show filters if applied
1030
+ if snapshot.filters_applied:
1031
+ console.print("Filters applied:")
1032
+ date_filters = snapshot.filters_applied.get("date_filters", {})
1033
+ if date_filters.get("before_date"):
1034
+ console.print(f" Before: {date_filters['before_date']}")
1035
+ if date_filters.get("after_date"):
1036
+ console.print(f" After: {date_filters['after_date']}")
1037
+ tag_filters = snapshot.filters_applied.get("tag_filters", {})
1038
+ if tag_filters:
1039
+ console.print(f" Tags: {tag_filters}")
1040
+ console.print()
1041
+
1042
+ # Service breakdown
1043
+ if snapshot.service_counts:
1044
+ console.print("Resources by service:")
1045
+ table = Table(show_header=True)
1046
+ table.add_column("Service", style="cyan")
1047
+ table.add_column("Count", justify="right", style="green")
1048
+ table.add_column("Percent", justify="right")
1049
+
1050
+ for service, count in sorted(snapshot.service_counts.items(), key=lambda x: x[1], reverse=True):
1051
+ percent = (count / snapshot.resource_count * 100) if snapshot.resource_count > 0 else 0
1052
+ table.add_row(service, str(count), f"{percent:.1f}%")
1053
+
1054
+ console.print(table)
1055
+
1056
+ except FileNotFoundError:
1057
+ console.print(f"✗ Snapshot '{name}' not found", style="bold red")
1058
+ raise typer.Exit(code=1)
1059
+ except Exception as e:
1060
+ console.print(f"✗ Error loading snapshot: {e}", style="bold red")
1061
+ raise typer.Exit(code=1)
1062
+
1063
+
1064
+ @snapshot_app.command("set-active")
1065
+ def snapshot_set_active(name: str = typer.Argument(..., help="Snapshot name to set as active")):
1066
+ """Set a snapshot as the active snapshot.
1067
+
1068
+ The active snapshot is used by default for delta and cost analysis.
1069
+ """
1070
+ try:
1071
+ storage = SnapshotStorage(config.storage_path)
1072
+ storage.set_active_snapshot(name)
1073
+
1074
+ console.print(f"✓ Set [bold]{name}[/bold] as active snapshot", style="green")
1075
+
1076
+ except FileNotFoundError:
1077
+ console.print(f"✗ Snapshot '{name}' not found", style="bold red")
1078
+ raise typer.Exit(code=1)
1079
+ except Exception as e:
1080
+ console.print(f"✗ Error setting active snapshot: {e}", style="bold red")
1081
+ raise typer.Exit(code=1)
1082
+
1083
+
1084
+ @snapshot_app.command("delete")
1085
+ def snapshot_delete(
1086
+ name: str = typer.Argument(..., help="Snapshot name to delete"),
1087
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
1088
+ ):
1089
+ """Delete a snapshot.
1090
+
1091
+ Cannot delete the active snapshot - set another snapshot as active first.
1092
+ """
1093
+ try:
1094
+ storage = SnapshotStorage(config.storage_path)
1095
+
1096
+ # Load snapshot to show info
1097
+ snapshot = storage.load_snapshot(name)
1098
+
1099
+ # Confirm deletion
1100
+ if not yes:
1101
+ console.print("\n[yellow]⚠️ About to delete snapshot:[/yellow]")
1102
+ console.print(f" Name: {snapshot.name}")
1103
+ console.print(f" Created: {snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
1104
+ console.print(f" Resources: {snapshot.resource_count}")
1105
+ console.print(f" Active: {'Yes' if snapshot.is_active else 'No'}\n")
1106
+
1107
+ confirm = typer.confirm("Are you sure you want to delete this snapshot?")
1108
+ if not confirm:
1109
+ console.print("Cancelled")
1110
+ raise typer.Exit(code=0)
1111
+
1112
+ # Delete snapshot
1113
+ storage.delete_snapshot(name)
1114
+
1115
+ console.print(f"✓ Deleted snapshot [bold]{name}[/bold]", style="green")
1116
+
1117
+ except FileNotFoundError:
1118
+ console.print(f"✗ Snapshot '{name}' not found", style="bold red")
1119
+ raise typer.Exit(code=1)
1120
+ except ValueError as e:
1121
+ console.print(f"✗ {e}", style="bold red")
1122
+ console.print("\nTip: Set another snapshot as active first:")
1123
+ console.print(" aws-snapshot set-active <other-snapshot-name>")
1124
+ raise typer.Exit(code=1)
1125
+ except Exception as e:
1126
+ console.print(f"✗ Error deleting snapshot: {e}", style="bold red")
1127
+ raise typer.Exit(code=1)
1128
+
1129
+
1130
+ @app.command()
1131
+ def delta(
1132
+ snapshot: Optional[str] = typer.Option(
1133
+ None, "--snapshot", help="Baseline snapshot name (default: active from inventory)"
1134
+ ),
1135
+ inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (default: 'default')"),
1136
+ resource_type: Optional[str] = typer.Option(None, "--resource-type", help="Filter by resource type"),
1137
+ region: Optional[str] = typer.Option(None, "--region", help="Filter by region"),
1138
+ show_details: bool = typer.Option(False, "--show-details", help="Show detailed resource information"),
1139
+ export: Optional[str] = typer.Option(None, "--export", help="Export to file (JSON or CSV based on extension)"),
1140
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
1141
+ ):
1142
+ """View resource changes since snapshot.
1143
+
1144
+ Compares current AWS state to the snapshot and shows added, deleted,
1145
+ and modified resources.
1146
+ """
1147
+ try:
1148
+ # T021: Get inventory and use its active snapshot
1149
+ from ..aws.credentials import validate_credentials
1150
+ from ..snapshot.inventory_storage import InventoryStorage
1151
+
1152
+ # Use profile parameter if provided, otherwise use config
1153
+ aws_profile = profile if profile else config.aws_profile
1154
+
1155
+ # Validate credentials to get account ID
1156
+ identity = validate_credentials(aws_profile)
1157
+
1158
+ # Load inventory
1159
+ inventory_storage = InventoryStorage(config.storage_path)
1160
+ inventory_name = inventory if inventory else "default"
1161
+
1162
+ if inventory:
1163
+ try:
1164
+ active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
1165
+ except Exception:
1166
+ # T024: Inventory doesn't exist
1167
+ console.print(
1168
+ f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
1169
+ )
1170
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
1171
+ raise typer.Exit(code=1)
1172
+ else:
1173
+ # Get or create default inventory
1174
+ active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
1175
+ inventory_name = "default"
1176
+
1177
+ # T026: User feedback about inventory
1178
+ console.print(f"📦 Using inventory: [bold]{inventory_name}[/bold]", style="cyan")
1179
+
1180
+ # T024, T025: Validate inventory has snapshots and active snapshot
1181
+ if not active_inventory.snapshots:
1182
+ console.print(f"✗ No snapshots exist in inventory '{inventory_name}'", style="bold red")
1183
+ console.print(f" Take a snapshot first: aws-snapshot create --inventory {inventory_name}", style="yellow")
1184
+ raise typer.Exit(code=1)
1185
+
1186
+ # Load snapshot
1187
+ storage = SnapshotStorage(config.storage_path)
1188
+
1189
+ if snapshot:
1190
+ # User specified a snapshot explicitly
1191
+ reference_snapshot = storage.load_snapshot(snapshot)
1192
+ else:
1193
+ # Use inventory's active snapshot
1194
+ if not active_inventory.active_snapshot:
1195
+ console.print(f"✗ No active snapshot in inventory '{inventory_name}'", style="bold red")
1196
+ console.print(
1197
+ f" Take a snapshot or set one as active: " f"aws-snapshot create --inventory {inventory_name}",
1198
+ style="yellow",
1199
+ )
1200
+ raise typer.Exit(code=1)
1201
+
1202
+ # Load the active snapshot (strip .yaml extension if present)
1203
+ snapshot_name = active_inventory.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
1204
+ reference_snapshot = storage.load_snapshot(snapshot_name)
1205
+
1206
+ console.print(f"🔍 Comparing to baseline: [bold]{reference_snapshot.name}[/bold]")
1207
+ console.print(f" Created: {reference_snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
1208
+
1209
+ # Prepare filters
1210
+ resource_type_filter = [resource_type] if resource_type else None
1211
+ region_filter = [region] if region else None
1212
+
1213
+ # Use profile parameter if provided, otherwise use config
1214
+ aws_profile = profile if profile else config.aws_profile
1215
+
1216
+ # Calculate delta
1217
+ from ..delta.calculator import compare_to_current_state
1218
+
1219
+ delta_report = compare_to_current_state(
1220
+ reference_snapshot,
1221
+ profile_name=aws_profile,
1222
+ regions=None, # Use reference snapshot regions
1223
+ resource_type_filter=resource_type_filter,
1224
+ region_filter=region_filter,
1225
+ )
1226
+
1227
+ # Display delta
1228
+ from ..delta.reporter import DeltaReporter
1229
+
1230
+ reporter = DeltaReporter(console)
1231
+ reporter.display(delta_report, show_details=show_details)
1232
+
1233
+ # Export if requested
1234
+ if export:
1235
+ if export.endswith(".json"):
1236
+ reporter.export_json(delta_report, export)
1237
+ elif export.endswith(".csv"):
1238
+ reporter.export_csv(delta_report, export)
1239
+ else:
1240
+ console.print("✗ Unsupported export format. Use .json or .csv", style="bold red")
1241
+ raise typer.Exit(code=1)
1242
+
1243
+ # Exit with code 0 if no changes (for scripting)
1244
+ if not delta_report.has_changes:
1245
+ raise typer.Exit(code=0)
1246
+
1247
+ except typer.Exit:
1248
+ # Re-raise Exit exceptions (normal exit codes)
1249
+ raise
1250
+ except FileNotFoundError as e:
1251
+ console.print(f"✗ Snapshot not found: {e}", style="bold red")
1252
+ raise typer.Exit(code=1)
1253
+ except Exception as e:
1254
+ console.print(f"✗ Error calculating delta: {e}", style="bold red")
1255
+ import traceback
1256
+
1257
+ traceback.print_exc()
1258
+ raise typer.Exit(code=2)
1259
+
1260
+
1261
+ @app.command()
1262
+ def cost(
1263
+ snapshot: Optional[str] = typer.Option(
1264
+ None, "--snapshot", help="Baseline snapshot name (default: active from inventory)"
1265
+ ),
1266
+ inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (default: 'default')"),
1267
+ start_date: Optional[str] = typer.Option(
1268
+ None, "--start-date", help="Start date (YYYY-MM-DD, default: snapshot date)"
1269
+ ),
1270
+ end_date: Optional[str] = typer.Option(None, "--end-date", help="End date (YYYY-MM-DD, default: today)"),
1271
+ granularity: str = typer.Option("MONTHLY", "--granularity", help="Cost granularity: DAILY or MONTHLY"),
1272
+ show_services: bool = typer.Option(True, "--show-services/--no-services", help="Show service breakdown"),
1273
+ export: Optional[str] = typer.Option(None, "--export", help="Export to file (JSON or CSV based on extension)"),
1274
+ profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
1275
+ ):
1276
+ """Analyze costs for resources in a specific inventory.
1277
+
1278
+ Shows costs for resources captured in the inventory's active snapshot,
1279
+ enabling per-team, per-environment, or per-project cost tracking.
1280
+ """
1281
+ try:
1282
+ # T020: Get inventory and use its active snapshot
1283
+ from ..aws.credentials import validate_credentials
1284
+ from ..snapshot.inventory_storage import InventoryStorage
1285
+
1286
+ # Use profile parameter if provided, otherwise use config
1287
+ aws_profile = profile if profile else config.aws_profile
1288
+
1289
+ # Validate credentials to get account ID
1290
+ identity = validate_credentials(aws_profile)
1291
+
1292
+ # Load inventory
1293
+ inventory_storage = InventoryStorage(config.storage_path)
1294
+ inventory_name = inventory if inventory else "default"
1295
+
1296
+ if inventory:
1297
+ try:
1298
+ active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
1299
+ except Exception:
1300
+ # T022: Inventory doesn't exist
1301
+ console.print(
1302
+ f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
1303
+ )
1304
+ console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
1305
+ raise typer.Exit(code=1)
1306
+ else:
1307
+ # Get or create default inventory
1308
+ active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
1309
+ inventory_name = "default"
1310
+
1311
+ # T026: User feedback about inventory
1312
+ console.print(f"📦 Using inventory: [bold]{inventory_name}[/bold]", style="cyan")
1313
+
1314
+ # T022, T023: Validate inventory has snapshots and active snapshot
1315
+ if not active_inventory.snapshots:
1316
+ console.print(f"✗ No snapshots exist in inventory '{inventory_name}'", style="bold red")
1317
+ console.print(f" Take a snapshot first: aws-snapshot create --inventory {inventory_name}", style="yellow")
1318
+ raise typer.Exit(code=1)
1319
+
1320
+ # Load snapshot
1321
+ storage = SnapshotStorage(config.storage_path)
1322
+
1323
+ if snapshot:
1324
+ # User specified a snapshot explicitly
1325
+ reference_snapshot = storage.load_snapshot(snapshot)
1326
+ else:
1327
+ # Use inventory's active snapshot
1328
+ if not active_inventory.active_snapshot:
1329
+ console.print(f"✗ No active snapshot in inventory '{inventory_name}'", style="bold red")
1330
+ console.print(
1331
+ f" Take a snapshot or set one as active: " f"aws-snapshot create --inventory {inventory_name}",
1332
+ style="yellow",
1333
+ )
1334
+ raise typer.Exit(code=1)
1335
+
1336
+ # Load the active snapshot (strip .yaml extension if present)
1337
+ snapshot_name = active_inventory.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
1338
+ reference_snapshot = storage.load_snapshot(snapshot_name)
1339
+
1340
+ console.print(f"💰 Analyzing costs for snapshot: [bold]{reference_snapshot.name}[/bold]\n")
1341
+
1342
+ # Parse dates
1343
+ from datetime import datetime as dt
1344
+
1345
+ start_dt = None
1346
+ end_dt = None
1347
+
1348
+ if start_date:
1349
+ try:
1350
+ # Parse as UTC timezone-aware
1351
+ from datetime import timezone
1352
+
1353
+ start_dt = dt.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
1354
+ except ValueError:
1355
+ console.print("✗ Invalid start date format. Use YYYY-MM-DD (UTC)", style="bold red")
1356
+ raise typer.Exit(code=1)
1357
+
1358
+ if end_date:
1359
+ try:
1360
+ # Parse as UTC timezone-aware
1361
+ from datetime import timezone
1362
+
1363
+ end_dt = dt.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
1364
+ except ValueError:
1365
+ console.print("✗ Invalid end date format. Use YYYY-MM-DD (UTC)", style="bold red")
1366
+ raise typer.Exit(code=1)
1367
+
1368
+ # Validate granularity
1369
+ if granularity not in ["DAILY", "MONTHLY"]:
1370
+ console.print("✗ Invalid granularity. Use DAILY or MONTHLY", style="bold red")
1371
+ raise typer.Exit(code=1)
1372
+
1373
+ # Use profile parameter if provided, otherwise use config
1374
+ aws_profile = profile if profile else config.aws_profile
1375
+
1376
+ # First, check if there are any deltas (new resources)
1377
+ console.print("🔍 Checking for resource changes since snapshot...\n")
1378
+ from ..delta.calculator import compare_to_current_state
1379
+
1380
+ delta_report = compare_to_current_state(
1381
+ reference_snapshot,
1382
+ profile_name=aws_profile,
1383
+ regions=None,
1384
+ )
1385
+
1386
+ # Analyze costs
1387
+ from ..cost.analyzer import CostAnalyzer
1388
+ from ..cost.explorer import CostExplorerClient, CostExplorerError
1389
+
1390
+ try:
1391
+ cost_explorer = CostExplorerClient(profile_name=aws_profile)
1392
+ analyzer = CostAnalyzer(cost_explorer)
1393
+
1394
+ # If no changes, only show baseline costs (no splitting)
1395
+ has_deltas = delta_report.has_changes
1396
+
1397
+ cost_report = analyzer.analyze(
1398
+ reference_snapshot,
1399
+ start_date=start_dt,
1400
+ end_date=end_dt,
1401
+ granularity=granularity,
1402
+ has_deltas=has_deltas,
1403
+ delta_report=delta_report,
1404
+ )
1405
+
1406
+ # Display cost report
1407
+ from ..cost.reporter import CostReporter
1408
+
1409
+ reporter = CostReporter(console)
1410
+ reporter.display(cost_report, show_services=show_services, has_deltas=has_deltas)
1411
+
1412
+ # Export if requested
1413
+ if export:
1414
+ if export.endswith(".json"):
1415
+ reporter.export_json(cost_report, export)
1416
+ elif export.endswith(".csv"):
1417
+ reporter.export_csv(cost_report, export)
1418
+ else:
1419
+ console.print("✗ Unsupported export format. Use .json or .csv", style="bold red")
1420
+ raise typer.Exit(code=1)
1421
+
1422
+ except CostExplorerError as e:
1423
+ console.print(f"✗ Cost Explorer error: {e}", style="bold red")
1424
+ console.print("\nTroubleshooting:")
1425
+ console.print(" 1. Ensure Cost Explorer is enabled in your AWS account")
1426
+ console.print(" 2. Check IAM permissions: ce:GetCostAndUsage")
1427
+ console.print(" 3. Cost data typically has a 24-48 hour lag")
1428
+ raise typer.Exit(code=3)
1429
+
1430
+ except typer.Exit:
1431
+ # Re-raise Exit exceptions (normal exit codes)
1432
+ raise
1433
+ except FileNotFoundError as e:
1434
+ console.print(f"✗ Snapshot not found: {e}", style="bold red")
1435
+ raise typer.Exit(code=1)
1436
+ except Exception as e:
1437
+ console.print(f"✗ Error analyzing costs: {e}", style="bold red")
1438
+ import traceback
1439
+
1440
+ traceback.print_exc()
1441
+ raise typer.Exit(code=2)
1442
+
1443
+
1444
+ def cli_main():
1445
+ """Entry point for console script."""
1446
+ app()
1447
+
1448
+
1449
+ if __name__ == "__main__":
1450
+ cli_main()