aws-inventory-manager 0.13.2__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.
- aws_inventory_manager-0.13.2.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.13.2.dist-info/METADATA +1226 -0
- aws_inventory_manager-0.13.2.dist-info/RECORD +145 -0
- aws_inventory_manager-0.13.2.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.13.2.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.13.2.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/aws/__init__.py +11 -0
- src/aws/client.py +128 -0
- src/aws/credentials.py +191 -0
- src/aws/rate_limiter.py +177 -0
- src/cli/__init__.py +12 -0
- src/cli/config.py +130 -0
- src/cli/main.py +3626 -0
- src/config_service/__init__.py +21 -0
- src/config_service/collector.py +346 -0
- src/config_service/detector.py +256 -0
- src/config_service/resource_type_mapping.py +328 -0
- src/cost/__init__.py +5 -0
- src/cost/analyzer.py +226 -0
- src/cost/explorer.py +209 -0
- src/cost/reporter.py +237 -0
- src/delta/__init__.py +5 -0
- src/delta/calculator.py +206 -0
- src/delta/differ.py +185 -0
- src/delta/formatters.py +272 -0
- src/delta/models.py +154 -0
- src/delta/reporter.py +234 -0
- src/models/__init__.py +21 -0
- src/models/config_diff.py +135 -0
- src/models/cost_report.py +87 -0
- src/models/deletion_operation.py +104 -0
- src/models/deletion_record.py +97 -0
- src/models/delta_report.py +122 -0
- src/models/efs_resource.py +80 -0
- src/models/elasticache_resource.py +90 -0
- src/models/group.py +318 -0
- src/models/inventory.py +133 -0
- src/models/protection_rule.py +123 -0
- src/models/report.py +288 -0
- src/models/resource.py +111 -0
- src/models/security_finding.py +102 -0
- src/models/snapshot.py +122 -0
- src/restore/__init__.py +20 -0
- src/restore/audit.py +175 -0
- src/restore/cleaner.py +461 -0
- src/restore/config.py +209 -0
- src/restore/deleter.py +976 -0
- src/restore/dependency.py +254 -0
- src/restore/safety.py +115 -0
- src/security/__init__.py +0 -0
- src/security/checks/__init__.py +0 -0
- src/security/checks/base.py +56 -0
- src/security/checks/ec2_checks.py +88 -0
- src/security/checks/elasticache_checks.py +149 -0
- src/security/checks/iam_checks.py +102 -0
- src/security/checks/rds_checks.py +140 -0
- src/security/checks/s3_checks.py +95 -0
- src/security/checks/secrets_checks.py +96 -0
- src/security/checks/sg_checks.py +142 -0
- src/security/cis_mapper.py +97 -0
- src/security/models.py +53 -0
- src/security/reporter.py +174 -0
- src/security/scanner.py +87 -0
- src/snapshot/__init__.py +6 -0
- src/snapshot/capturer.py +451 -0
- src/snapshot/filter.py +259 -0
- src/snapshot/inventory_storage.py +236 -0
- src/snapshot/report_formatter.py +250 -0
- src/snapshot/reporter.py +189 -0
- src/snapshot/resource_collectors/__init__.py +5 -0
- src/snapshot/resource_collectors/apigateway.py +140 -0
- src/snapshot/resource_collectors/backup.py +136 -0
- src/snapshot/resource_collectors/base.py +81 -0
- src/snapshot/resource_collectors/cloudformation.py +55 -0
- src/snapshot/resource_collectors/cloudwatch.py +109 -0
- src/snapshot/resource_collectors/codebuild.py +69 -0
- src/snapshot/resource_collectors/codepipeline.py +82 -0
- src/snapshot/resource_collectors/dynamodb.py +65 -0
- src/snapshot/resource_collectors/ec2.py +240 -0
- src/snapshot/resource_collectors/ecs.py +215 -0
- src/snapshot/resource_collectors/efs_collector.py +102 -0
- src/snapshot/resource_collectors/eks.py +200 -0
- src/snapshot/resource_collectors/elasticache_collector.py +79 -0
- src/snapshot/resource_collectors/elb.py +126 -0
- src/snapshot/resource_collectors/eventbridge.py +156 -0
- src/snapshot/resource_collectors/iam.py +188 -0
- src/snapshot/resource_collectors/kms.py +111 -0
- src/snapshot/resource_collectors/lambda_func.py +139 -0
- src/snapshot/resource_collectors/rds.py +109 -0
- src/snapshot/resource_collectors/route53.py +86 -0
- src/snapshot/resource_collectors/s3.py +105 -0
- src/snapshot/resource_collectors/secretsmanager.py +70 -0
- src/snapshot/resource_collectors/sns.py +68 -0
- src/snapshot/resource_collectors/sqs.py +82 -0
- src/snapshot/resource_collectors/ssm.py +160 -0
- src/snapshot/resource_collectors/stepfunctions.py +74 -0
- src/snapshot/resource_collectors/vpcendpoints.py +79 -0
- src/snapshot/resource_collectors/waf.py +159 -0
- src/snapshot/storage.py +351 -0
- src/storage/__init__.py +21 -0
- src/storage/audit_store.py +419 -0
- src/storage/database.py +294 -0
- src/storage/group_store.py +749 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +413 -0
- src/storage/schema.py +288 -0
- src/storage/snapshot_store.py +346 -0
- src/utils/__init__.py +12 -0
- src/utils/export.py +305 -0
- src/utils/hash.py +60 -0
- src/utils/logging.py +63 -0
- src/utils/pagination.py +41 -0
- src/utils/paths.py +51 -0
- src/utils/progress.py +41 -0
- src/utils/unsupported_resources.py +306 -0
- src/web/__init__.py +5 -0
- src/web/app.py +97 -0
- src/web/dependencies.py +69 -0
- src/web/routes/__init__.py +1 -0
- src/web/routes/api/__init__.py +18 -0
- src/web/routes/api/charts.py +156 -0
- src/web/routes/api/cleanup.py +186 -0
- src/web/routes/api/filters.py +253 -0
- src/web/routes/api/groups.py +305 -0
- src/web/routes/api/inventories.py +80 -0
- src/web/routes/api/queries.py +202 -0
- src/web/routes/api/resources.py +379 -0
- src/web/routes/api/snapshots.py +314 -0
- src/web/routes/api/views.py +260 -0
- src/web/routes/pages.py +198 -0
- src/web/services/__init__.py +1 -0
- src/web/templates/base.html +949 -0
- src/web/templates/components/navbar.html +31 -0
- src/web/templates/components/sidebar.html +104 -0
- src/web/templates/pages/audit_logs.html +86 -0
- src/web/templates/pages/cleanup.html +279 -0
- src/web/templates/pages/dashboard.html +227 -0
- src/web/templates/pages/diff.html +175 -0
- src/web/templates/pages/error.html +30 -0
- src/web/templates/pages/groups.html +721 -0
- src/web/templates/pages/queries.html +246 -0
- src/web/templates/pages/resources.html +2251 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- src/web/templates/pages/snapshots.html +429 -0
src/cli/main.py
ADDED
|
@@ -0,0 +1,3626 @@
|
|
|
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
|
+
use_config: bool = typer.Option(
|
|
827
|
+
True, "--use-config/--no-config", help="Use AWS Config for collection when available (default: enabled)"
|
|
828
|
+
),
|
|
829
|
+
config_aggregator: Optional[str] = typer.Option(
|
|
830
|
+
None, "--config-aggregator", help="AWS Config Aggregator name for multi-account collection"
|
|
831
|
+
),
|
|
832
|
+
verbose: bool = typer.Option(
|
|
833
|
+
False, "--verbose", "-v", help="Show detailed collection method breakdown"
|
|
834
|
+
),
|
|
835
|
+
):
|
|
836
|
+
"""Create a new snapshot of AWS resources.
|
|
837
|
+
|
|
838
|
+
Captures resources from 25 AWS services:
|
|
839
|
+
- IAM: Roles, Users, Groups, Policies
|
|
840
|
+
- Lambda: Functions, Layers
|
|
841
|
+
- S3: Buckets
|
|
842
|
+
- EC2: Instances, Volumes, VPCs, Security Groups, Subnets, VPC Endpoints
|
|
843
|
+
- RDS: DB Instances, DB Clusters (including Aurora)
|
|
844
|
+
- CloudWatch: Alarms, Log Groups
|
|
845
|
+
- SNS: Topics
|
|
846
|
+
- SQS: Queues
|
|
847
|
+
- DynamoDB: Tables
|
|
848
|
+
- ELB: Load Balancers (Classic, ALB, NLB, GWLB)
|
|
849
|
+
- CloudFormation: Stacks
|
|
850
|
+
- API Gateway: REST APIs, HTTP APIs, WebSocket APIs
|
|
851
|
+
- EventBridge: Event Buses, Rules
|
|
852
|
+
- Secrets Manager: Secrets
|
|
853
|
+
- KMS: Customer-Managed Keys
|
|
854
|
+
- Systems Manager: Parameters, Documents
|
|
855
|
+
- Route53: Hosted Zones
|
|
856
|
+
- ECS: Clusters, Services, Task Definitions
|
|
857
|
+
- EKS: Clusters, Node Groups, Fargate Profiles
|
|
858
|
+
- Step Functions: State Machines
|
|
859
|
+
- WAF: Web ACLs (Regional & CloudFront)
|
|
860
|
+
- CodePipeline: Pipelines
|
|
861
|
+
- CodeBuild: Projects
|
|
862
|
+
- Backup: Backup Plans, Backup Vaults
|
|
863
|
+
|
|
864
|
+
Historical Baselines & Filtering:
|
|
865
|
+
Use --before-date, --after-date, --include-tags, and/or --exclude-tags to create
|
|
866
|
+
snapshots representing resources as they existed at specific points in time or with
|
|
867
|
+
specific characteristics.
|
|
868
|
+
|
|
869
|
+
Examples:
|
|
870
|
+
- Production only: --include-tags Environment=production
|
|
871
|
+
- Exclude test/dev: --exclude-tags Environment=test,Environment=dev
|
|
872
|
+
- Multiple filters: --include-tags Team=platform,Environment=prod --exclude-tags Status=archived
|
|
873
|
+
"""
|
|
874
|
+
try:
|
|
875
|
+
# Use profile parameter if provided, otherwise use config
|
|
876
|
+
aws_profile = profile if profile else config.aws_profile
|
|
877
|
+
|
|
878
|
+
# Validate credentials
|
|
879
|
+
console.print("🔐 Validating AWS credentials...")
|
|
880
|
+
identity = validate_credentials(aws_profile)
|
|
881
|
+
console.print(f"✓ Authenticated as: {identity['arn']}\n", style="green")
|
|
882
|
+
|
|
883
|
+
# T012: Validate filter conflict - inventory vs inline tags
|
|
884
|
+
if inventory and (include_tags or exclude_tags):
|
|
885
|
+
console.print(
|
|
886
|
+
"✗ Error: Cannot use --inventory with --include-tags or --exclude-tags\n"
|
|
887
|
+
" Filters are defined in the inventory. Either:\n"
|
|
888
|
+
" 1. Use --inventory to apply inventory's filters, OR\n"
|
|
889
|
+
" 2. Use --include-tags/--exclude-tags for ad-hoc filtering",
|
|
890
|
+
style="bold red",
|
|
891
|
+
)
|
|
892
|
+
raise typer.Exit(code=1)
|
|
893
|
+
|
|
894
|
+
# T013: Load inventory and apply its filters
|
|
895
|
+
from ..snapshot.inventory_storage import InventoryStorage
|
|
896
|
+
|
|
897
|
+
inventory_storage = InventoryStorage(config.storage_path)
|
|
898
|
+
active_inventory = None
|
|
899
|
+
inventory_name = "default"
|
|
900
|
+
|
|
901
|
+
if inventory:
|
|
902
|
+
# Load specified inventory
|
|
903
|
+
try:
|
|
904
|
+
active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
|
|
905
|
+
inventory_name = inventory
|
|
906
|
+
console.print(f"📦 Using inventory: [bold]{inventory}[/bold]", style="cyan")
|
|
907
|
+
if active_inventory.description:
|
|
908
|
+
console.print(f" {active_inventory.description}")
|
|
909
|
+
except Exception:
|
|
910
|
+
# T018: Handle nonexistent inventory
|
|
911
|
+
console.print(
|
|
912
|
+
f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
|
|
913
|
+
)
|
|
914
|
+
console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
|
|
915
|
+
raise typer.Exit(code=1)
|
|
916
|
+
else:
|
|
917
|
+
# Get or create default inventory (lazy creation)
|
|
918
|
+
active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
|
|
919
|
+
inventory_name = "default"
|
|
920
|
+
|
|
921
|
+
# Generate snapshot name if not provided (T014: use inventory in naming)
|
|
922
|
+
if not name:
|
|
923
|
+
timestamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
|
924
|
+
name = f"{identity['account_id']}-{inventory_name}-{timestamp}"
|
|
925
|
+
|
|
926
|
+
# Parse regions - default to us-east-1
|
|
927
|
+
region_list = []
|
|
928
|
+
if regions:
|
|
929
|
+
region_list = [r.strip() for r in regions.split(",")]
|
|
930
|
+
elif config.regions:
|
|
931
|
+
region_list = config.regions
|
|
932
|
+
else:
|
|
933
|
+
# Default to us-east-1
|
|
934
|
+
region_list = ["us-east-1"]
|
|
935
|
+
|
|
936
|
+
console.print(f"📸 Creating snapshot: [bold]{name}[/bold]")
|
|
937
|
+
console.print(f"Regions: {', '.join(region_list)}\n")
|
|
938
|
+
|
|
939
|
+
# Parse filters - use inventory filters if inventory specified, else inline filters
|
|
940
|
+
resource_filter = None
|
|
941
|
+
|
|
942
|
+
# T013: Determine which filters to use
|
|
943
|
+
if inventory:
|
|
944
|
+
# Use inventory's filters
|
|
945
|
+
include_tags_dict = active_inventory.include_tags if active_inventory.include_tags else None
|
|
946
|
+
exclude_tags_dict = active_inventory.exclude_tags if active_inventory.exclude_tags else None
|
|
947
|
+
else:
|
|
948
|
+
# Use inline filters from command-line
|
|
949
|
+
include_tags_dict = {}
|
|
950
|
+
exclude_tags_dict = {}
|
|
951
|
+
|
|
952
|
+
# Parse include tags (supports both --filter-tags and --include-tags)
|
|
953
|
+
if filter_tags:
|
|
954
|
+
console.print("⚠️ Note: --filter-tags is deprecated, use --include-tags", style="yellow")
|
|
955
|
+
try:
|
|
956
|
+
include_tags_dict = parse_tags(filter_tags)
|
|
957
|
+
except Exception as e:
|
|
958
|
+
console.print(f"✗ Error parsing filter-tags: {e}", style="bold red")
|
|
959
|
+
raise typer.Exit(code=1)
|
|
960
|
+
|
|
961
|
+
if include_tags:
|
|
962
|
+
try:
|
|
963
|
+
include_tags_dict.update(parse_tags(include_tags))
|
|
964
|
+
except Exception as e:
|
|
965
|
+
console.print(f"✗ Error parsing include-tags: {e}", style="bold red")
|
|
966
|
+
raise typer.Exit(code=1)
|
|
967
|
+
|
|
968
|
+
# Parse exclude tags
|
|
969
|
+
if exclude_tags:
|
|
970
|
+
try:
|
|
971
|
+
exclude_tags_dict = parse_tags(exclude_tags)
|
|
972
|
+
except Exception as e:
|
|
973
|
+
console.print(f"✗ Error parsing exclude-tags: {e}", style="bold red")
|
|
974
|
+
raise typer.Exit(code=1)
|
|
975
|
+
|
|
976
|
+
# Convert to None if empty
|
|
977
|
+
include_tags_dict = include_tags_dict if include_tags_dict else None
|
|
978
|
+
exclude_tags_dict = exclude_tags_dict if exclude_tags_dict else None
|
|
979
|
+
|
|
980
|
+
# Create filter if any filters or dates are specified
|
|
981
|
+
if before_date or after_date or include_tags_dict or exclude_tags_dict:
|
|
982
|
+
from datetime import datetime as dt
|
|
983
|
+
|
|
984
|
+
from ..snapshot.filter import ResourceFilter
|
|
985
|
+
|
|
986
|
+
# Parse dates
|
|
987
|
+
before_dt = None
|
|
988
|
+
after_dt = None
|
|
989
|
+
|
|
990
|
+
if before_date:
|
|
991
|
+
try:
|
|
992
|
+
# Parse as UTC timezone-aware
|
|
993
|
+
from datetime import timezone
|
|
994
|
+
|
|
995
|
+
before_dt = dt.strptime(before_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
996
|
+
except ValueError:
|
|
997
|
+
console.print("✗ Invalid --before-date format. Use YYYY-MM-DD (UTC)", style="bold red")
|
|
998
|
+
raise typer.Exit(code=1)
|
|
999
|
+
|
|
1000
|
+
if after_date:
|
|
1001
|
+
try:
|
|
1002
|
+
# Parse as UTC timezone-aware
|
|
1003
|
+
from datetime import timezone
|
|
1004
|
+
|
|
1005
|
+
after_dt = dt.strptime(after_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
1006
|
+
except ValueError:
|
|
1007
|
+
console.print("✗ Invalid --after-date format. Use YYYY-MM-DD (UTC)", style="bold red")
|
|
1008
|
+
raise typer.Exit(code=1)
|
|
1009
|
+
|
|
1010
|
+
# Create filter
|
|
1011
|
+
resource_filter = ResourceFilter(
|
|
1012
|
+
before_date=before_dt,
|
|
1013
|
+
after_date=after_dt,
|
|
1014
|
+
include_tags=include_tags_dict,
|
|
1015
|
+
exclude_tags=exclude_tags_dict,
|
|
1016
|
+
)
|
|
1017
|
+
|
|
1018
|
+
console.print(f"{resource_filter.get_filter_summary()}\n")
|
|
1019
|
+
|
|
1020
|
+
# Import snapshot creation
|
|
1021
|
+
from ..snapshot.capturer import create_snapshot
|
|
1022
|
+
|
|
1023
|
+
# T015: Pass inventory_name to create_snapshot
|
|
1024
|
+
# Show Config status
|
|
1025
|
+
if use_config:
|
|
1026
|
+
console.print("🔧 AWS Config collection: [bold green]enabled[/bold green] (fallback to direct API if unavailable)")
|
|
1027
|
+
if config_aggregator:
|
|
1028
|
+
console.print(f" Using aggregator: {config_aggregator}")
|
|
1029
|
+
else:
|
|
1030
|
+
console.print("🔧 AWS Config collection: [bold yellow]disabled[/bold yellow] (using direct API)")
|
|
1031
|
+
|
|
1032
|
+
snapshot = create_snapshot(
|
|
1033
|
+
name=name,
|
|
1034
|
+
regions=region_list,
|
|
1035
|
+
account_id=identity["account_id"],
|
|
1036
|
+
profile_name=aws_profile,
|
|
1037
|
+
set_active=set_active,
|
|
1038
|
+
resource_filter=resource_filter,
|
|
1039
|
+
inventory_name=inventory_name,
|
|
1040
|
+
use_config=use_config,
|
|
1041
|
+
config_aggregator=config_aggregator,
|
|
1042
|
+
)
|
|
1043
|
+
|
|
1044
|
+
# T018: Check for zero resources after filtering
|
|
1045
|
+
if snapshot.resource_count == 0:
|
|
1046
|
+
console.print("⚠️ Warning: Snapshot contains 0 resources after filtering", style="bold yellow")
|
|
1047
|
+
if resource_filter:
|
|
1048
|
+
console.print(
|
|
1049
|
+
" Your filters may be too restrictive. Consider:\n"
|
|
1050
|
+
" - Adjusting tag filters\n"
|
|
1051
|
+
" - Checking date ranges\n"
|
|
1052
|
+
" - Verifying resources exist in the specified regions",
|
|
1053
|
+
style="yellow",
|
|
1054
|
+
)
|
|
1055
|
+
console.print("\nSnapshot was not saved.\n")
|
|
1056
|
+
raise typer.Exit(code=0)
|
|
1057
|
+
|
|
1058
|
+
# Save snapshot
|
|
1059
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1060
|
+
filepath = storage.save_snapshot(snapshot, compress=compress)
|
|
1061
|
+
|
|
1062
|
+
# T016: Register snapshot with inventory
|
|
1063
|
+
snapshot_filename = filepath.name
|
|
1064
|
+
active_inventory.add_snapshot(snapshot_filename, set_active=set_active)
|
|
1065
|
+
inventory_storage.save(active_inventory)
|
|
1066
|
+
|
|
1067
|
+
# T017: User feedback about inventory
|
|
1068
|
+
console.print(f"\n✓ Added to inventory '[bold]{inventory_name}[/bold]'", style="green")
|
|
1069
|
+
if set_active:
|
|
1070
|
+
console.print(" Marked as active snapshot for this inventory", style="green")
|
|
1071
|
+
|
|
1072
|
+
# Display summary
|
|
1073
|
+
console.print("\n✓ Snapshot complete!", style="bold green")
|
|
1074
|
+
console.print("\nSummary:")
|
|
1075
|
+
console.print(f" Name: {snapshot.name}")
|
|
1076
|
+
console.print(f" Resources: {snapshot.resource_count}")
|
|
1077
|
+
console.print(f" File: {filepath}")
|
|
1078
|
+
console.print(f" Active: {'Yes' if snapshot.is_active else 'No'}")
|
|
1079
|
+
|
|
1080
|
+
# Show collection errors if any
|
|
1081
|
+
collection_errors = snapshot.metadata.get("collection_errors", [])
|
|
1082
|
+
if collection_errors:
|
|
1083
|
+
console.print(f"\n⚠️ Note: {len(collection_errors)} service(s) were unavailable", style="yellow")
|
|
1084
|
+
|
|
1085
|
+
# Show filtering stats if filters were applied
|
|
1086
|
+
if snapshot.filters_applied:
|
|
1087
|
+
stats = snapshot.filters_applied.get("statistics", {})
|
|
1088
|
+
console.print("\nFiltering:")
|
|
1089
|
+
console.print(f" Collected: {stats.get('total_collected', 0)}")
|
|
1090
|
+
console.print(f" Matched filters: {stats.get('final_count', 0)}")
|
|
1091
|
+
console.print(f" Filtered out: {stats.get('total_collected', 0) - stats.get('final_count', 0)}")
|
|
1092
|
+
|
|
1093
|
+
# Show service breakdown
|
|
1094
|
+
if snapshot.service_counts:
|
|
1095
|
+
console.print("\nResources by service:")
|
|
1096
|
+
table = Table(show_header=True)
|
|
1097
|
+
table.add_column("Service", style="cyan")
|
|
1098
|
+
table.add_column("Count", justify="right", style="green")
|
|
1099
|
+
|
|
1100
|
+
for service, count in sorted(snapshot.service_counts.items()):
|
|
1101
|
+
table.add_row(service, str(count))
|
|
1102
|
+
|
|
1103
|
+
console.print(table)
|
|
1104
|
+
|
|
1105
|
+
# Show collection method summary (Config vs Direct API)
|
|
1106
|
+
collection_sources = snapshot.metadata.get("collection_sources", {})
|
|
1107
|
+
config_enabled_regions = snapshot.metadata.get("config_enabled_regions", [])
|
|
1108
|
+
|
|
1109
|
+
if collection_sources:
|
|
1110
|
+
# Count unique sources by method
|
|
1111
|
+
config_types = [t for t, s in collection_sources.items() if s == "config"]
|
|
1112
|
+
direct_types = [t for t, s in collection_sources.items() if s == "direct_api"]
|
|
1113
|
+
|
|
1114
|
+
console.print("\nCollection Method:")
|
|
1115
|
+
if config_enabled_regions:
|
|
1116
|
+
console.print(f" AWS Config: [green]Enabled[/green] in {', '.join(config_enabled_regions)}")
|
|
1117
|
+
console.print(f" [green]Config[/green]: {len(config_types)} resource type(s)")
|
|
1118
|
+
console.print(f" [yellow]Direct API[/yellow]: {len(direct_types)} resource type(s)")
|
|
1119
|
+
else:
|
|
1120
|
+
console.print(" AWS Config: [yellow]Not enabled[/yellow] (using direct API)")
|
|
1121
|
+
console.print(f" Direct API: {len(direct_types)} resource type(s)")
|
|
1122
|
+
|
|
1123
|
+
# Show detailed table only with --verbose
|
|
1124
|
+
if verbose and (config_types or direct_types):
|
|
1125
|
+
console.print()
|
|
1126
|
+
method_table = Table(show_header=True, title="Collection Sources by Resource Type")
|
|
1127
|
+
method_table.add_column("Resource Type", style="cyan")
|
|
1128
|
+
method_table.add_column("Method", style="green")
|
|
1129
|
+
method_table.add_column("Reason", style="dim")
|
|
1130
|
+
|
|
1131
|
+
for resource_type in sorted(collection_sources.keys()):
|
|
1132
|
+
method = collection_sources[resource_type]
|
|
1133
|
+
if method == "config":
|
|
1134
|
+
reason = "Config enabled & type recorded"
|
|
1135
|
+
method_display = "[green]Config[/green]"
|
|
1136
|
+
else:
|
|
1137
|
+
# Determine reason for direct API
|
|
1138
|
+
if not config_enabled_regions:
|
|
1139
|
+
reason = "Config not enabled"
|
|
1140
|
+
else:
|
|
1141
|
+
reason = "Type not recorded by Config"
|
|
1142
|
+
method_display = "[yellow]Direct API[/yellow]"
|
|
1143
|
+
method_table.add_row(resource_type, method_display, reason)
|
|
1144
|
+
|
|
1145
|
+
console.print(method_table)
|
|
1146
|
+
elif not verbose and (config_types or direct_types):
|
|
1147
|
+
console.print("\n [dim]Use --verbose to see detailed breakdown by resource type[/dim]")
|
|
1148
|
+
elif not use_config:
|
|
1149
|
+
console.print("\nCollection Method:")
|
|
1150
|
+
console.print(" All resources collected via Direct API (--no-config specified)")
|
|
1151
|
+
|
|
1152
|
+
except typer.Exit:
|
|
1153
|
+
# Re-raise Exit exceptions (normal exit codes)
|
|
1154
|
+
raise
|
|
1155
|
+
except CredentialValidationError as e:
|
|
1156
|
+
console.print(f"✗ Error: {e}", style="bold red")
|
|
1157
|
+
raise typer.Exit(code=3)
|
|
1158
|
+
except Exception as e:
|
|
1159
|
+
console.print(f"✗ Error creating snapshot: {e}", style="bold red")
|
|
1160
|
+
logger.exception("Error in snapshot create command")
|
|
1161
|
+
raise typer.Exit(code=2)
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
@snapshot_app.command("list")
|
|
1165
|
+
def snapshot_list(profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name")):
|
|
1166
|
+
"""List all available snapshots."""
|
|
1167
|
+
try:
|
|
1168
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1169
|
+
snapshots = storage.list_snapshots()
|
|
1170
|
+
|
|
1171
|
+
if not snapshots:
|
|
1172
|
+
console.print("No snapshots found.", style="yellow")
|
|
1173
|
+
return
|
|
1174
|
+
|
|
1175
|
+
# Create table
|
|
1176
|
+
table = Table(show_header=True, title="Available Snapshots")
|
|
1177
|
+
table.add_column("Name", style="cyan")
|
|
1178
|
+
table.add_column("Created", style="green")
|
|
1179
|
+
table.add_column("Size (MB)", justify="right")
|
|
1180
|
+
table.add_column("Active", justify="center")
|
|
1181
|
+
|
|
1182
|
+
for snap in snapshots:
|
|
1183
|
+
active_marker = "✓" if snap["is_active"] else ""
|
|
1184
|
+
table.add_row(
|
|
1185
|
+
snap["name"],
|
|
1186
|
+
snap["modified"].strftime("%Y-%m-%d %H:%M"),
|
|
1187
|
+
f"{snap['size_mb']:.2f}",
|
|
1188
|
+
active_marker,
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
console.print(table)
|
|
1192
|
+
console.print(f"\nTotal snapshots: {len(snapshots)}")
|
|
1193
|
+
|
|
1194
|
+
except Exception as e:
|
|
1195
|
+
console.print(f"✗ Error listing snapshots: {e}", style="bold red")
|
|
1196
|
+
raise typer.Exit(code=1)
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
@snapshot_app.command("show")
|
|
1200
|
+
def snapshot_show(
|
|
1201
|
+
name: str = typer.Argument(..., help="Snapshot name to display"),
|
|
1202
|
+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
|
|
1203
|
+
):
|
|
1204
|
+
"""Display detailed information about a snapshot."""
|
|
1205
|
+
try:
|
|
1206
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1207
|
+
snapshot = storage.load_snapshot(name)
|
|
1208
|
+
|
|
1209
|
+
console.print(f"\n[bold]Snapshot: {snapshot.name}[/bold]")
|
|
1210
|
+
console.print(f"Created: {snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
1211
|
+
console.print(f"Account: {snapshot.account_id}")
|
|
1212
|
+
console.print(f"Regions: {', '.join(snapshot.regions)}")
|
|
1213
|
+
console.print(f"Status: {'Active baseline' if snapshot.is_active else 'Inactive'}")
|
|
1214
|
+
console.print(f"Total resources: {snapshot.resource_count}\n")
|
|
1215
|
+
|
|
1216
|
+
# Show filters if applied
|
|
1217
|
+
if snapshot.filters_applied:
|
|
1218
|
+
console.print("Filters applied:")
|
|
1219
|
+
date_filters = snapshot.filters_applied.get("date_filters", {})
|
|
1220
|
+
if date_filters.get("before_date"):
|
|
1221
|
+
console.print(f" Before: {date_filters['before_date']}")
|
|
1222
|
+
if date_filters.get("after_date"):
|
|
1223
|
+
console.print(f" After: {date_filters['after_date']}")
|
|
1224
|
+
tag_filters = snapshot.filters_applied.get("tag_filters", {})
|
|
1225
|
+
if tag_filters:
|
|
1226
|
+
console.print(f" Tags: {tag_filters}")
|
|
1227
|
+
console.print()
|
|
1228
|
+
|
|
1229
|
+
# Service breakdown
|
|
1230
|
+
if snapshot.service_counts:
|
|
1231
|
+
console.print("Resources by service:")
|
|
1232
|
+
table = Table(show_header=True)
|
|
1233
|
+
table.add_column("Service", style="cyan")
|
|
1234
|
+
table.add_column("Count", justify="right", style="green")
|
|
1235
|
+
table.add_column("Percent", justify="right")
|
|
1236
|
+
|
|
1237
|
+
for service, count in sorted(snapshot.service_counts.items(), key=lambda x: x[1], reverse=True):
|
|
1238
|
+
percent = (count / snapshot.resource_count * 100) if snapshot.resource_count > 0 else 0
|
|
1239
|
+
table.add_row(service, str(count), f"{percent:.1f}%")
|
|
1240
|
+
|
|
1241
|
+
console.print(table)
|
|
1242
|
+
|
|
1243
|
+
except FileNotFoundError:
|
|
1244
|
+
console.print(f"✗ Snapshot '{name}' not found", style="bold red")
|
|
1245
|
+
raise typer.Exit(code=1)
|
|
1246
|
+
except Exception as e:
|
|
1247
|
+
console.print(f"✗ Error loading snapshot: {e}", style="bold red")
|
|
1248
|
+
raise typer.Exit(code=1)
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
@snapshot_app.command("set-active")
|
|
1252
|
+
def snapshot_set_active(
|
|
1253
|
+
name: str = typer.Argument(..., help="Snapshot name to set as active"),
|
|
1254
|
+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
|
|
1255
|
+
):
|
|
1256
|
+
"""Set a snapshot as the active snapshot.
|
|
1257
|
+
|
|
1258
|
+
The active snapshot is used by default for delta and cost analysis.
|
|
1259
|
+
"""
|
|
1260
|
+
try:
|
|
1261
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1262
|
+
storage.set_active_snapshot(name)
|
|
1263
|
+
|
|
1264
|
+
console.print(f"✓ Set [bold]{name}[/bold] as active snapshot", style="green")
|
|
1265
|
+
|
|
1266
|
+
except FileNotFoundError:
|
|
1267
|
+
console.print(f"✗ Snapshot '{name}' not found", style="bold red")
|
|
1268
|
+
raise typer.Exit(code=1)
|
|
1269
|
+
except Exception as e:
|
|
1270
|
+
console.print(f"✗ Error setting active snapshot: {e}", style="bold red")
|
|
1271
|
+
raise typer.Exit(code=1)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
@snapshot_app.command("delete")
|
|
1275
|
+
def snapshot_delete(
|
|
1276
|
+
name: str = typer.Argument(..., help="Snapshot name to delete"),
|
|
1277
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
1278
|
+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
|
|
1279
|
+
):
|
|
1280
|
+
"""Delete a snapshot.
|
|
1281
|
+
|
|
1282
|
+
Cannot delete the active snapshot - set another snapshot as active first.
|
|
1283
|
+
"""
|
|
1284
|
+
try:
|
|
1285
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1286
|
+
|
|
1287
|
+
# Load snapshot to show info
|
|
1288
|
+
snapshot = storage.load_snapshot(name)
|
|
1289
|
+
|
|
1290
|
+
# Confirm deletion
|
|
1291
|
+
if not yes:
|
|
1292
|
+
console.print("\n[yellow]⚠️ About to delete snapshot:[/yellow]")
|
|
1293
|
+
console.print(f" Name: {snapshot.name}")
|
|
1294
|
+
console.print(f" Created: {snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
1295
|
+
console.print(f" Resources: {snapshot.resource_count}")
|
|
1296
|
+
console.print(f" Active: {'Yes' if snapshot.is_active else 'No'}\n")
|
|
1297
|
+
|
|
1298
|
+
confirm = typer.confirm("Are you sure you want to delete this snapshot?")
|
|
1299
|
+
if not confirm:
|
|
1300
|
+
console.print("Cancelled")
|
|
1301
|
+
raise typer.Exit(code=0)
|
|
1302
|
+
|
|
1303
|
+
# Delete snapshot
|
|
1304
|
+
storage.delete_snapshot(name)
|
|
1305
|
+
|
|
1306
|
+
console.print(f"✓ Deleted snapshot [bold]{name}[/bold]", style="green")
|
|
1307
|
+
|
|
1308
|
+
except FileNotFoundError:
|
|
1309
|
+
console.print(f"✗ Snapshot '{name}' not found", style="bold red")
|
|
1310
|
+
raise typer.Exit(code=1)
|
|
1311
|
+
except ValueError as e:
|
|
1312
|
+
console.print(f"✗ {e}", style="bold red")
|
|
1313
|
+
console.print("\nTip: Set another snapshot as active first:")
|
|
1314
|
+
console.print(" aws-snapshot set-active <other-snapshot-name>")
|
|
1315
|
+
raise typer.Exit(code=1)
|
|
1316
|
+
except Exception as e:
|
|
1317
|
+
console.print(f"✗ Error deleting snapshot: {e}", style="bold red")
|
|
1318
|
+
raise typer.Exit(code=1)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
@snapshot_app.command("rename")
|
|
1322
|
+
def snapshot_rename(
|
|
1323
|
+
old_name: str = typer.Argument(..., help="Current snapshot name"),
|
|
1324
|
+
new_name: str = typer.Argument(..., help="New snapshot name"),
|
|
1325
|
+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
|
|
1326
|
+
):
|
|
1327
|
+
"""Rename a snapshot.
|
|
1328
|
+
|
|
1329
|
+
Example:
|
|
1330
|
+
awsinv snapshot rename old-snapshot-name new-snapshot-name
|
|
1331
|
+
"""
|
|
1332
|
+
try:
|
|
1333
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1334
|
+
|
|
1335
|
+
# Check if source exists
|
|
1336
|
+
if not storage.exists(old_name):
|
|
1337
|
+
console.print(f"✗ Snapshot '{old_name}' not found", style="bold red")
|
|
1338
|
+
raise typer.Exit(code=1)
|
|
1339
|
+
|
|
1340
|
+
# Check if target already exists
|
|
1341
|
+
if storage.exists(new_name):
|
|
1342
|
+
console.print(f"✗ Snapshot '{new_name}' already exists", style="bold red")
|
|
1343
|
+
raise typer.Exit(code=1)
|
|
1344
|
+
|
|
1345
|
+
# Rename
|
|
1346
|
+
storage.rename_snapshot(old_name, new_name)
|
|
1347
|
+
|
|
1348
|
+
console.print(f"✓ Renamed snapshot [bold]{old_name}[/bold] to [bold]{new_name}[/bold]", style="green")
|
|
1349
|
+
|
|
1350
|
+
except Exception as e:
|
|
1351
|
+
console.print(f"✗ Error renaming snapshot: {e}", style="bold red")
|
|
1352
|
+
raise typer.Exit(code=1)
|
|
1353
|
+
|
|
1354
|
+
|
|
1355
|
+
@snapshot_app.command("report")
|
|
1356
|
+
def snapshot_report(
|
|
1357
|
+
snapshot_name: Optional[str] = typer.Argument(None, help="Snapshot name (default: active snapshot)"),
|
|
1358
|
+
inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (required if multiple exist)"),
|
|
1359
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
|
|
1360
|
+
storage_path: Optional[str] = typer.Option(None, "--storage-path", help="Override storage location"),
|
|
1361
|
+
resource_type: Optional[List[str]] = typer.Option(
|
|
1362
|
+
None, "--resource-type", help="Filter by resource type (can specify multiple)"
|
|
1363
|
+
),
|
|
1364
|
+
region: Optional[List[str]] = typer.Option(None, "--region", help="Filter by region (can specify multiple)"),
|
|
1365
|
+
detailed: bool = typer.Option(
|
|
1366
|
+
False, "--detailed", help="Show detailed resource information (ARN, tags, creation date)"
|
|
1367
|
+
),
|
|
1368
|
+
page_size: int = typer.Option(100, "--page-size", help="Resources per page in detailed view (default: 100)"),
|
|
1369
|
+
export: Optional[str] = typer.Option(
|
|
1370
|
+
None, "--export", help="Export report to file (format detected from extension: .json, .csv, .txt)"
|
|
1371
|
+
),
|
|
1372
|
+
):
|
|
1373
|
+
"""Display resource summary report for a snapshot.
|
|
1374
|
+
|
|
1375
|
+
Shows aggregated resource counts by service, region, and type with
|
|
1376
|
+
visual progress bars and formatted output. Can export to JSON, CSV, or TXT formats.
|
|
1377
|
+
|
|
1378
|
+
Snapshot Selection (in order of precedence):
|
|
1379
|
+
1. Explicit snapshot name argument
|
|
1380
|
+
2. Most recent snapshot from specified --inventory
|
|
1381
|
+
3. Active snapshot (set via 'awsinv snapshot set-active')
|
|
1382
|
+
|
|
1383
|
+
Examples:
|
|
1384
|
+
awsinv snapshot report # Report on active snapshot
|
|
1385
|
+
awsinv snapshot report baseline-2025-01 # Report on specific snapshot
|
|
1386
|
+
awsinv snapshot report --inventory prod # Most recent snapshot from 'prod' inventory
|
|
1387
|
+
awsinv snapshot report --resource-type ec2 # Filter by resource type
|
|
1388
|
+
awsinv snapshot report --region us-east-1 # Filter by region
|
|
1389
|
+
awsinv snapshot report --resource-type ec2 --resource-type lambda # Multiple filters
|
|
1390
|
+
awsinv snapshot report --export report.json # Export full report to JSON
|
|
1391
|
+
awsinv snapshot report --export resources.csv # Export resources to CSV
|
|
1392
|
+
awsinv snapshot report --export summary.txt # Export summary to TXT
|
|
1393
|
+
awsinv snapshot report --detailed --export details.json # Export detailed view
|
|
1394
|
+
"""
|
|
1395
|
+
from ..models.report import FilterCriteria
|
|
1396
|
+
from ..snapshot.report_formatter import ReportFormatter
|
|
1397
|
+
from ..snapshot.reporter import SnapshotReporter
|
|
1398
|
+
from ..utils.export import detect_format, export_report_csv, export_report_json, export_report_txt
|
|
1399
|
+
|
|
1400
|
+
try:
|
|
1401
|
+
# Use provided storage path or default from config
|
|
1402
|
+
storage = SnapshotStorage(storage_path or config.storage_path)
|
|
1403
|
+
|
|
1404
|
+
# Determine which snapshot to load
|
|
1405
|
+
target_snapshot_name: str
|
|
1406
|
+
if snapshot_name:
|
|
1407
|
+
# Explicit snapshot name provided
|
|
1408
|
+
target_snapshot_name = snapshot_name
|
|
1409
|
+
elif inventory:
|
|
1410
|
+
# Inventory specified - find most recent snapshot from that inventory
|
|
1411
|
+
from datetime import datetime as dt
|
|
1412
|
+
from typing import TypedDict
|
|
1413
|
+
|
|
1414
|
+
class InventorySnapshot(TypedDict):
|
|
1415
|
+
name: str
|
|
1416
|
+
created_at: dt
|
|
1417
|
+
|
|
1418
|
+
all_snapshots = storage.list_snapshots()
|
|
1419
|
+
inventory_snapshots: List[InventorySnapshot] = []
|
|
1420
|
+
|
|
1421
|
+
for snap_meta in all_snapshots:
|
|
1422
|
+
try:
|
|
1423
|
+
snap = storage.load_snapshot(snap_meta["name"])
|
|
1424
|
+
if snap.inventory_name == inventory:
|
|
1425
|
+
inventory_snapshots.append(
|
|
1426
|
+
InventorySnapshot(
|
|
1427
|
+
name=snap.name,
|
|
1428
|
+
created_at=snap.created_at,
|
|
1429
|
+
)
|
|
1430
|
+
)
|
|
1431
|
+
except Exception:
|
|
1432
|
+
continue
|
|
1433
|
+
|
|
1434
|
+
if not inventory_snapshots:
|
|
1435
|
+
console.print(f"✗ No snapshots found for inventory '{inventory}'", style="bold red")
|
|
1436
|
+
console.print("\nCreate a snapshot first:")
|
|
1437
|
+
console.print(f" awsinv snapshot create --inventory {inventory}")
|
|
1438
|
+
raise typer.Exit(code=1)
|
|
1439
|
+
|
|
1440
|
+
# Sort by created_at and pick most recent
|
|
1441
|
+
inventory_snapshots.sort(key=lambda x: x["created_at"], reverse=True)
|
|
1442
|
+
target_snapshot_name = inventory_snapshots[0]["name"]
|
|
1443
|
+
console.print(
|
|
1444
|
+
f"ℹ Using most recent snapshot from inventory '{inventory}': {target_snapshot_name}", style="dim"
|
|
1445
|
+
)
|
|
1446
|
+
else:
|
|
1447
|
+
# Try to get active snapshot
|
|
1448
|
+
active_name = storage.get_active_snapshot_name()
|
|
1449
|
+
if not active_name:
|
|
1450
|
+
console.print("✗ No active snapshot found", style="bold red")
|
|
1451
|
+
console.print("\nSet an active snapshot with:")
|
|
1452
|
+
console.print(" awsinv snapshot set-active <name>")
|
|
1453
|
+
console.print("\nOr specify a snapshot explicitly:")
|
|
1454
|
+
console.print(" awsinv snapshot report <snapshot-name>")
|
|
1455
|
+
console.print("\nOr specify an inventory to use the most recent snapshot:")
|
|
1456
|
+
console.print(" awsinv snapshot report --inventory <inventory-name>")
|
|
1457
|
+
raise typer.Exit(code=1)
|
|
1458
|
+
target_snapshot_name = active_name
|
|
1459
|
+
|
|
1460
|
+
# Load the snapshot
|
|
1461
|
+
try:
|
|
1462
|
+
snapshot = storage.load_snapshot(target_snapshot_name)
|
|
1463
|
+
except FileNotFoundError:
|
|
1464
|
+
console.print(f"✗ Snapshot '{target_snapshot_name}' not found", style="bold red")
|
|
1465
|
+
|
|
1466
|
+
# Show available snapshots
|
|
1467
|
+
try:
|
|
1468
|
+
all_snapshots = storage.list_snapshots()
|
|
1469
|
+
if all_snapshots:
|
|
1470
|
+
console.print("\nAvailable snapshots:")
|
|
1471
|
+
for snap_name in all_snapshots[:5]:
|
|
1472
|
+
console.print(f" • {snap_name}")
|
|
1473
|
+
if len(all_snapshots) > 5:
|
|
1474
|
+
console.print(f" ... and {len(all_snapshots) - 5} more")
|
|
1475
|
+
console.print("\nRun 'awsinv snapshot list' to see all snapshots.")
|
|
1476
|
+
except Exception:
|
|
1477
|
+
pass
|
|
1478
|
+
|
|
1479
|
+
raise typer.Exit(code=1)
|
|
1480
|
+
|
|
1481
|
+
# Handle empty snapshot
|
|
1482
|
+
if snapshot.resource_count == 0:
|
|
1483
|
+
console.print(f"⚠️ Warning: Snapshot '{snapshot.name}' contains 0 resources", style="yellow")
|
|
1484
|
+
console.print("\nNo report to generate.")
|
|
1485
|
+
raise typer.Exit(code=0)
|
|
1486
|
+
|
|
1487
|
+
# Create filter criteria if filters provided
|
|
1488
|
+
has_filters = bool(resource_type or region)
|
|
1489
|
+
criteria = None
|
|
1490
|
+
if has_filters:
|
|
1491
|
+
criteria = FilterCriteria(
|
|
1492
|
+
resource_types=resource_type if resource_type else None,
|
|
1493
|
+
regions=region if region else None,
|
|
1494
|
+
)
|
|
1495
|
+
|
|
1496
|
+
# Generate report
|
|
1497
|
+
reporter = SnapshotReporter(snapshot)
|
|
1498
|
+
metadata = reporter._extract_metadata()
|
|
1499
|
+
|
|
1500
|
+
# Detailed view vs Summary view
|
|
1501
|
+
if detailed:
|
|
1502
|
+
# Get detailed resources (with optional filtering)
|
|
1503
|
+
detailed_resources = list(reporter.get_detailed_resources(criteria))
|
|
1504
|
+
|
|
1505
|
+
# Export mode
|
|
1506
|
+
if export:
|
|
1507
|
+
try:
|
|
1508
|
+
# Detect format from file extension
|
|
1509
|
+
export_format = detect_format(export)
|
|
1510
|
+
|
|
1511
|
+
# Export based on format
|
|
1512
|
+
if export_format == "json":
|
|
1513
|
+
# For JSON, export full report structure with detailed resources
|
|
1514
|
+
summary = (
|
|
1515
|
+
reporter.generate_filtered_summary(criteria) if criteria else reporter.generate_summary()
|
|
1516
|
+
)
|
|
1517
|
+
export_path = export_report_json(export, metadata, summary, detailed_resources)
|
|
1518
|
+
console.print(
|
|
1519
|
+
f"✓ Exported {len(detailed_resources):,} resources to JSON: {export_path}",
|
|
1520
|
+
style="bold green",
|
|
1521
|
+
)
|
|
1522
|
+
elif export_format == "csv":
|
|
1523
|
+
# For CSV, export detailed resources
|
|
1524
|
+
export_path = export_report_csv(export, detailed_resources)
|
|
1525
|
+
console.print(
|
|
1526
|
+
f"✓ Exported {len(detailed_resources):,} resources to CSV: {export_path}",
|
|
1527
|
+
style="bold green",
|
|
1528
|
+
)
|
|
1529
|
+
elif export_format == "txt":
|
|
1530
|
+
# For TXT, export summary (detailed view doesn't make sense for plain text)
|
|
1531
|
+
summary = (
|
|
1532
|
+
reporter.generate_filtered_summary(criteria) if criteria else reporter.generate_summary()
|
|
1533
|
+
)
|
|
1534
|
+
export_path = export_report_txt(export, metadata, summary)
|
|
1535
|
+
console.print(f"✓ Exported summary to TXT: {export_path}", style="bold green")
|
|
1536
|
+
except FileExistsError as e:
|
|
1537
|
+
console.print(f"✗ {e}", style="bold red")
|
|
1538
|
+
console.print("\nUse a different filename or delete the existing file.", style="yellow")
|
|
1539
|
+
raise typer.Exit(code=1)
|
|
1540
|
+
except FileNotFoundError as e:
|
|
1541
|
+
console.print(f"✗ {e}", style="bold red")
|
|
1542
|
+
raise typer.Exit(code=1)
|
|
1543
|
+
except ValueError as e:
|
|
1544
|
+
console.print(f"✗ {e}", style="bold red")
|
|
1545
|
+
raise typer.Exit(code=1)
|
|
1546
|
+
else:
|
|
1547
|
+
# Display mode - show filter information if applied
|
|
1548
|
+
if criteria:
|
|
1549
|
+
console.print("\n[bold cyan]Filters Applied:[/bold cyan]")
|
|
1550
|
+
if resource_type:
|
|
1551
|
+
console.print(f" • Resource Types: {', '.join(resource_type)}")
|
|
1552
|
+
if region:
|
|
1553
|
+
console.print(f" • Regions: {', '.join(region)}")
|
|
1554
|
+
console.print(
|
|
1555
|
+
f" • Matching Resources: {len(detailed_resources):,} (of {snapshot.resource_count:,} total)\n"
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
# Format and display detailed view
|
|
1559
|
+
formatter = ReportFormatter(console)
|
|
1560
|
+
formatter.format_detailed(metadata, detailed_resources, page_size=page_size)
|
|
1561
|
+
else:
|
|
1562
|
+
# Generate summary (filtered or full)
|
|
1563
|
+
if criteria:
|
|
1564
|
+
summary = reporter.generate_filtered_summary(criteria)
|
|
1565
|
+
else:
|
|
1566
|
+
summary = reporter.generate_summary()
|
|
1567
|
+
|
|
1568
|
+
# Export mode
|
|
1569
|
+
if export:
|
|
1570
|
+
try:
|
|
1571
|
+
# Detect format from file extension
|
|
1572
|
+
export_format = detect_format(export)
|
|
1573
|
+
|
|
1574
|
+
# Export based on format
|
|
1575
|
+
if export_format == "json":
|
|
1576
|
+
# For JSON, export full report structure
|
|
1577
|
+
# Get all resources for complete export
|
|
1578
|
+
all_resources = list(reporter.get_detailed_resources(criteria))
|
|
1579
|
+
export_path = export_report_json(export, metadata, summary, all_resources)
|
|
1580
|
+
console.print(
|
|
1581
|
+
f"✓ Exported {summary.total_count:,} resources to JSON: {export_path}", style="bold green"
|
|
1582
|
+
)
|
|
1583
|
+
elif export_format == "csv":
|
|
1584
|
+
# For CSV, export resources
|
|
1585
|
+
all_resources = list(reporter.get_detailed_resources(criteria))
|
|
1586
|
+
export_path = export_report_csv(export, all_resources)
|
|
1587
|
+
console.print(
|
|
1588
|
+
f"✓ Exported {len(all_resources):,} resources to CSV: {export_path}", style="bold green"
|
|
1589
|
+
)
|
|
1590
|
+
elif export_format == "txt":
|
|
1591
|
+
# For TXT, export summary only
|
|
1592
|
+
export_path = export_report_txt(export, metadata, summary)
|
|
1593
|
+
console.print(f"✓ Exported summary to TXT: {export_path}", style="bold green")
|
|
1594
|
+
except FileExistsError as e:
|
|
1595
|
+
console.print(f"✗ {e}", style="bold red")
|
|
1596
|
+
console.print("\nUse a different filename or delete the existing file.", style="yellow")
|
|
1597
|
+
raise typer.Exit(code=1)
|
|
1598
|
+
except FileNotFoundError as e:
|
|
1599
|
+
console.print(f"✗ {e}", style="bold red")
|
|
1600
|
+
raise typer.Exit(code=1)
|
|
1601
|
+
except ValueError as e:
|
|
1602
|
+
console.print(f"✗ {e}", style="bold red")
|
|
1603
|
+
raise typer.Exit(code=1)
|
|
1604
|
+
else:
|
|
1605
|
+
# Display mode - show filter information
|
|
1606
|
+
if criteria:
|
|
1607
|
+
console.print("\n[bold cyan]Filters Applied:[/bold cyan]")
|
|
1608
|
+
if resource_type:
|
|
1609
|
+
console.print(f" • Resource Types: {', '.join(resource_type)}")
|
|
1610
|
+
if region:
|
|
1611
|
+
console.print(f" • Regions: {', '.join(region)}")
|
|
1612
|
+
console.print(
|
|
1613
|
+
f" • Matching Resources: {summary.total_count:,} (of {snapshot.resource_count:,} total)\n"
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
# Format and display summary report
|
|
1617
|
+
formatter = ReportFormatter(console)
|
|
1618
|
+
formatter.format_summary(metadata, summary, has_filters=has_filters)
|
|
1619
|
+
|
|
1620
|
+
except typer.Exit:
|
|
1621
|
+
raise
|
|
1622
|
+
except Exception as e:
|
|
1623
|
+
console.print(f"✗ Error generating report: {e}", style="bold red")
|
|
1624
|
+
logger.exception("Error in snapshot report command")
|
|
1625
|
+
raise typer.Exit(code=2)
|
|
1626
|
+
|
|
1627
|
+
|
|
1628
|
+
@app.command()
|
|
1629
|
+
def delta(
|
|
1630
|
+
snapshot: Optional[str] = typer.Option(
|
|
1631
|
+
None, "--snapshot", help="Baseline snapshot name (default: active from inventory)"
|
|
1632
|
+
),
|
|
1633
|
+
inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (default: 'default')"),
|
|
1634
|
+
resource_type: Optional[str] = typer.Option(None, "--resource-type", help="Filter by resource type"),
|
|
1635
|
+
region: Optional[str] = typer.Option(None, "--region", help="Filter by region"),
|
|
1636
|
+
show_details: bool = typer.Option(False, "--show-details", help="Show detailed resource information"),
|
|
1637
|
+
show_diff: bool = typer.Option(False, "--show-diff", help="Show field-level configuration differences"),
|
|
1638
|
+
export: Optional[str] = typer.Option(None, "--export", help="Export to file (JSON or CSV based on extension)"),
|
|
1639
|
+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
|
|
1640
|
+
):
|
|
1641
|
+
"""View resource changes since snapshot.
|
|
1642
|
+
|
|
1643
|
+
Compares current AWS state to the snapshot and shows added, deleted,
|
|
1644
|
+
and modified resources. Use --show-diff to see field-level configuration changes.
|
|
1645
|
+
"""
|
|
1646
|
+
try:
|
|
1647
|
+
# T021: Get inventory and use its active snapshot
|
|
1648
|
+
from ..aws.credentials import validate_credentials
|
|
1649
|
+
from ..snapshot.inventory_storage import InventoryStorage
|
|
1650
|
+
|
|
1651
|
+
# Use profile parameter if provided, otherwise use config
|
|
1652
|
+
aws_profile = profile if profile else config.aws_profile
|
|
1653
|
+
|
|
1654
|
+
# Validate credentials to get account ID
|
|
1655
|
+
identity = validate_credentials(aws_profile)
|
|
1656
|
+
|
|
1657
|
+
# Load inventory
|
|
1658
|
+
inventory_storage = InventoryStorage(config.storage_path)
|
|
1659
|
+
inventory_name = inventory if inventory else "default"
|
|
1660
|
+
|
|
1661
|
+
if inventory:
|
|
1662
|
+
try:
|
|
1663
|
+
active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
|
|
1664
|
+
except Exception:
|
|
1665
|
+
# T024: Inventory doesn't exist
|
|
1666
|
+
console.print(
|
|
1667
|
+
f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
|
|
1668
|
+
)
|
|
1669
|
+
console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
|
|
1670
|
+
raise typer.Exit(code=1)
|
|
1671
|
+
else:
|
|
1672
|
+
# Get or create default inventory
|
|
1673
|
+
active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
|
|
1674
|
+
inventory_name = "default"
|
|
1675
|
+
|
|
1676
|
+
# T026: User feedback about inventory
|
|
1677
|
+
console.print(f"📦 Using inventory: [bold]{inventory_name}[/bold]", style="cyan")
|
|
1678
|
+
|
|
1679
|
+
# T024, T025: Validate inventory has snapshots and active snapshot
|
|
1680
|
+
if not active_inventory.snapshots:
|
|
1681
|
+
console.print(f"✗ No snapshots exist in inventory '{inventory_name}'", style="bold red")
|
|
1682
|
+
console.print(f" Take a snapshot first: aws-snapshot create --inventory {inventory_name}", style="yellow")
|
|
1683
|
+
raise typer.Exit(code=1)
|
|
1684
|
+
|
|
1685
|
+
# Load snapshot
|
|
1686
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1687
|
+
|
|
1688
|
+
if snapshot:
|
|
1689
|
+
# User specified a snapshot explicitly
|
|
1690
|
+
reference_snapshot = storage.load_snapshot(snapshot)
|
|
1691
|
+
else:
|
|
1692
|
+
# Use inventory's active snapshot
|
|
1693
|
+
if not active_inventory.active_snapshot:
|
|
1694
|
+
console.print(f"✗ No active snapshot in inventory '{inventory_name}'", style="bold red")
|
|
1695
|
+
console.print(
|
|
1696
|
+
f" Take a snapshot or set one as active: " f"aws-snapshot create --inventory {inventory_name}",
|
|
1697
|
+
style="yellow",
|
|
1698
|
+
)
|
|
1699
|
+
raise typer.Exit(code=1)
|
|
1700
|
+
|
|
1701
|
+
# Load the active snapshot (strip .yaml extension if present)
|
|
1702
|
+
snapshot_name = active_inventory.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
|
|
1703
|
+
reference_snapshot = storage.load_snapshot(snapshot_name)
|
|
1704
|
+
|
|
1705
|
+
console.print(f"🔍 Comparing to baseline: [bold]{reference_snapshot.name}[/bold]")
|
|
1706
|
+
console.print(f" Created: {reference_snapshot.created_at.strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
|
|
1707
|
+
|
|
1708
|
+
# Prepare filters
|
|
1709
|
+
resource_type_filter = [resource_type] if resource_type else None
|
|
1710
|
+
region_filter = [region] if region else None
|
|
1711
|
+
|
|
1712
|
+
# Use profile parameter if provided, otherwise use config
|
|
1713
|
+
aws_profile = profile if profile else config.aws_profile
|
|
1714
|
+
|
|
1715
|
+
# Calculate delta
|
|
1716
|
+
from ..delta.calculator import compare_to_current_state
|
|
1717
|
+
|
|
1718
|
+
delta_report = compare_to_current_state(
|
|
1719
|
+
reference_snapshot,
|
|
1720
|
+
profile_name=aws_profile,
|
|
1721
|
+
regions=None, # Use reference snapshot regions
|
|
1722
|
+
resource_type_filter=resource_type_filter,
|
|
1723
|
+
region_filter=region_filter,
|
|
1724
|
+
include_drift_details=show_diff,
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
# Display delta
|
|
1728
|
+
from ..delta.reporter import DeltaReporter
|
|
1729
|
+
|
|
1730
|
+
reporter = DeltaReporter(console)
|
|
1731
|
+
reporter.display(delta_report, show_details=show_details)
|
|
1732
|
+
|
|
1733
|
+
# Export if requested
|
|
1734
|
+
if export:
|
|
1735
|
+
if export.endswith(".json"):
|
|
1736
|
+
reporter.export_json(delta_report, export)
|
|
1737
|
+
elif export.endswith(".csv"):
|
|
1738
|
+
reporter.export_csv(delta_report, export)
|
|
1739
|
+
else:
|
|
1740
|
+
console.print("✗ Unsupported export format. Use .json or .csv", style="bold red")
|
|
1741
|
+
raise typer.Exit(code=1)
|
|
1742
|
+
|
|
1743
|
+
# Exit with code 0 if no changes (for scripting)
|
|
1744
|
+
if not delta_report.has_changes:
|
|
1745
|
+
raise typer.Exit(code=0)
|
|
1746
|
+
|
|
1747
|
+
except typer.Exit:
|
|
1748
|
+
# Re-raise Exit exceptions (normal exit codes)
|
|
1749
|
+
raise
|
|
1750
|
+
except FileNotFoundError as e:
|
|
1751
|
+
console.print(f"✗ Snapshot not found: {e}", style="bold red")
|
|
1752
|
+
raise typer.Exit(code=1)
|
|
1753
|
+
except Exception as e:
|
|
1754
|
+
console.print(f"✗ Error calculating delta: {e}", style="bold red")
|
|
1755
|
+
logger.exception("Error in delta command")
|
|
1756
|
+
raise typer.Exit(code=2)
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
@app.command()
|
|
1760
|
+
def cost(
|
|
1761
|
+
snapshot: Optional[str] = typer.Option(
|
|
1762
|
+
None, "--snapshot", help="Baseline snapshot name (default: active from inventory)"
|
|
1763
|
+
),
|
|
1764
|
+
inventory: Optional[str] = typer.Option(None, "--inventory", help="Inventory name (default: 'default')"),
|
|
1765
|
+
start_date: Optional[str] = typer.Option(
|
|
1766
|
+
None, "--start-date", help="Start date (YYYY-MM-DD, default: snapshot date)"
|
|
1767
|
+
),
|
|
1768
|
+
end_date: Optional[str] = typer.Option(None, "--end-date", help="End date (YYYY-MM-DD, default: today)"),
|
|
1769
|
+
granularity: str = typer.Option("MONTHLY", "--granularity", help="Cost granularity: DAILY or MONTHLY"),
|
|
1770
|
+
show_services: bool = typer.Option(True, "--show-services/--no-services", help="Show service breakdown"),
|
|
1771
|
+
export: Optional[str] = typer.Option(None, "--export", help="Export to file (JSON or CSV based on extension)"),
|
|
1772
|
+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
|
|
1773
|
+
):
|
|
1774
|
+
"""Analyze costs for resources in a specific inventory.
|
|
1775
|
+
|
|
1776
|
+
Shows costs for resources captured in the inventory's active snapshot,
|
|
1777
|
+
enabling per-team, per-environment, or per-project cost tracking.
|
|
1778
|
+
"""
|
|
1779
|
+
try:
|
|
1780
|
+
# T020: Get inventory and use its active snapshot
|
|
1781
|
+
from ..aws.credentials import validate_credentials
|
|
1782
|
+
from ..snapshot.inventory_storage import InventoryStorage
|
|
1783
|
+
|
|
1784
|
+
# Use profile parameter if provided, otherwise use config
|
|
1785
|
+
aws_profile = profile if profile else config.aws_profile
|
|
1786
|
+
|
|
1787
|
+
# Validate credentials to get account ID
|
|
1788
|
+
identity = validate_credentials(aws_profile)
|
|
1789
|
+
|
|
1790
|
+
# Load inventory
|
|
1791
|
+
inventory_storage = InventoryStorage(config.storage_path)
|
|
1792
|
+
inventory_name = inventory if inventory else "default"
|
|
1793
|
+
|
|
1794
|
+
if inventory:
|
|
1795
|
+
try:
|
|
1796
|
+
active_inventory = inventory_storage.get_by_name(inventory, identity["account_id"])
|
|
1797
|
+
except Exception:
|
|
1798
|
+
# T022: Inventory doesn't exist
|
|
1799
|
+
console.print(
|
|
1800
|
+
f"✗ Inventory '{inventory}' not found for account {identity['account_id']}", style="bold red"
|
|
1801
|
+
)
|
|
1802
|
+
console.print(" Use 'aws-baseline inventory list' to see available inventories", style="yellow")
|
|
1803
|
+
raise typer.Exit(code=1)
|
|
1804
|
+
else:
|
|
1805
|
+
# Get or create default inventory
|
|
1806
|
+
active_inventory = inventory_storage.get_or_create_default(identity["account_id"])
|
|
1807
|
+
inventory_name = "default"
|
|
1808
|
+
|
|
1809
|
+
# T026: User feedback about inventory
|
|
1810
|
+
console.print(f"📦 Using inventory: [bold]{inventory_name}[/bold]", style="cyan")
|
|
1811
|
+
|
|
1812
|
+
# T022, T023: Validate inventory has snapshots and active snapshot
|
|
1813
|
+
if not active_inventory.snapshots:
|
|
1814
|
+
console.print(f"✗ No snapshots exist in inventory '{inventory_name}'", style="bold red")
|
|
1815
|
+
console.print(f" Take a snapshot first: aws-snapshot create --inventory {inventory_name}", style="yellow")
|
|
1816
|
+
raise typer.Exit(code=1)
|
|
1817
|
+
|
|
1818
|
+
# Load snapshot
|
|
1819
|
+
storage = SnapshotStorage(config.storage_path)
|
|
1820
|
+
|
|
1821
|
+
if snapshot:
|
|
1822
|
+
# User specified a snapshot explicitly
|
|
1823
|
+
reference_snapshot = storage.load_snapshot(snapshot)
|
|
1824
|
+
else:
|
|
1825
|
+
# Use inventory's active snapshot
|
|
1826
|
+
if not active_inventory.active_snapshot:
|
|
1827
|
+
console.print(f"✗ No active snapshot in inventory '{inventory_name}'", style="bold red")
|
|
1828
|
+
console.print(
|
|
1829
|
+
f" Take a snapshot or set one as active: " f"aws-snapshot create --inventory {inventory_name}",
|
|
1830
|
+
style="yellow",
|
|
1831
|
+
)
|
|
1832
|
+
raise typer.Exit(code=1)
|
|
1833
|
+
|
|
1834
|
+
# Load the active snapshot (strip .yaml extension if present)
|
|
1835
|
+
snapshot_name = active_inventory.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
|
|
1836
|
+
reference_snapshot = storage.load_snapshot(snapshot_name)
|
|
1837
|
+
|
|
1838
|
+
console.print(f"💰 Analyzing costs for snapshot: [bold]{reference_snapshot.name}[/bold]\n")
|
|
1839
|
+
|
|
1840
|
+
# Parse dates
|
|
1841
|
+
from datetime import datetime as dt
|
|
1842
|
+
|
|
1843
|
+
start_dt = None
|
|
1844
|
+
end_dt = None
|
|
1845
|
+
|
|
1846
|
+
if start_date:
|
|
1847
|
+
try:
|
|
1848
|
+
# Parse as UTC timezone-aware
|
|
1849
|
+
from datetime import timezone
|
|
1850
|
+
|
|
1851
|
+
start_dt = dt.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
1852
|
+
except ValueError:
|
|
1853
|
+
console.print("✗ Invalid start date format. Use YYYY-MM-DD (UTC)", style="bold red")
|
|
1854
|
+
raise typer.Exit(code=1)
|
|
1855
|
+
|
|
1856
|
+
if end_date:
|
|
1857
|
+
try:
|
|
1858
|
+
# Parse as UTC timezone-aware
|
|
1859
|
+
from datetime import timezone
|
|
1860
|
+
|
|
1861
|
+
end_dt = dt.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
1862
|
+
except ValueError:
|
|
1863
|
+
console.print("✗ Invalid end date format. Use YYYY-MM-DD (UTC)", style="bold red")
|
|
1864
|
+
raise typer.Exit(code=1)
|
|
1865
|
+
|
|
1866
|
+
# Validate granularity
|
|
1867
|
+
if granularity not in ["DAILY", "MONTHLY"]:
|
|
1868
|
+
console.print("✗ Invalid granularity. Use DAILY or MONTHLY", style="bold red")
|
|
1869
|
+
raise typer.Exit(code=1)
|
|
1870
|
+
|
|
1871
|
+
# Use profile parameter if provided, otherwise use config
|
|
1872
|
+
aws_profile = profile if profile else config.aws_profile
|
|
1873
|
+
|
|
1874
|
+
# First, check if there are any deltas (new resources)
|
|
1875
|
+
console.print("🔍 Checking for resource changes since snapshot...\n")
|
|
1876
|
+
from ..delta.calculator import compare_to_current_state
|
|
1877
|
+
|
|
1878
|
+
delta_report = compare_to_current_state(
|
|
1879
|
+
reference_snapshot,
|
|
1880
|
+
profile_name=aws_profile,
|
|
1881
|
+
regions=None,
|
|
1882
|
+
)
|
|
1883
|
+
|
|
1884
|
+
# Analyze costs
|
|
1885
|
+
from ..cost.analyzer import CostAnalyzer
|
|
1886
|
+
from ..cost.explorer import CostExplorerClient, CostExplorerError
|
|
1887
|
+
|
|
1888
|
+
try:
|
|
1889
|
+
cost_explorer = CostExplorerClient(profile_name=aws_profile)
|
|
1890
|
+
analyzer = CostAnalyzer(cost_explorer)
|
|
1891
|
+
|
|
1892
|
+
# If no changes, only show baseline costs (no splitting)
|
|
1893
|
+
has_deltas = delta_report.has_changes
|
|
1894
|
+
|
|
1895
|
+
cost_report = analyzer.analyze(
|
|
1896
|
+
reference_snapshot,
|
|
1897
|
+
start_date=start_dt,
|
|
1898
|
+
end_date=end_dt,
|
|
1899
|
+
granularity=granularity,
|
|
1900
|
+
has_deltas=has_deltas,
|
|
1901
|
+
delta_report=delta_report,
|
|
1902
|
+
)
|
|
1903
|
+
|
|
1904
|
+
# Display cost report
|
|
1905
|
+
from ..cost.reporter import CostReporter
|
|
1906
|
+
|
|
1907
|
+
reporter = CostReporter(console)
|
|
1908
|
+
reporter.display(cost_report, show_services=show_services, has_deltas=has_deltas)
|
|
1909
|
+
|
|
1910
|
+
# Export if requested
|
|
1911
|
+
if export:
|
|
1912
|
+
if export.endswith(".json"):
|
|
1913
|
+
reporter.export_json(cost_report, export)
|
|
1914
|
+
elif export.endswith(".csv"):
|
|
1915
|
+
reporter.export_csv(cost_report, export)
|
|
1916
|
+
else:
|
|
1917
|
+
console.print("✗ Unsupported export format. Use .json or .csv", style="bold red")
|
|
1918
|
+
raise typer.Exit(code=1)
|
|
1919
|
+
|
|
1920
|
+
except CostExplorerError as e:
|
|
1921
|
+
console.print(f"✗ Cost Explorer error: {e}", style="bold red")
|
|
1922
|
+
console.print("\nTroubleshooting:")
|
|
1923
|
+
console.print(" 1. Ensure Cost Explorer is enabled in your AWS account")
|
|
1924
|
+
console.print(" 2. Check IAM permissions: ce:GetCostAndUsage")
|
|
1925
|
+
console.print(" 3. Cost data typically has a 24-48 hour lag")
|
|
1926
|
+
raise typer.Exit(code=3)
|
|
1927
|
+
|
|
1928
|
+
except typer.Exit:
|
|
1929
|
+
# Re-raise Exit exceptions (normal exit codes)
|
|
1930
|
+
raise
|
|
1931
|
+
except FileNotFoundError as e:
|
|
1932
|
+
console.print(f"✗ Snapshot not found: {e}", style="bold red")
|
|
1933
|
+
raise typer.Exit(code=1)
|
|
1934
|
+
except Exception as e:
|
|
1935
|
+
console.print(f"✗ Error analyzing costs: {e}", style="bold red")
|
|
1936
|
+
logger.exception("Error in cost command")
|
|
1937
|
+
raise typer.Exit(code=2)
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
# ============================================================================
|
|
1941
|
+
# Security Commands
|
|
1942
|
+
# ============================================================================
|
|
1943
|
+
|
|
1944
|
+
security_app = typer.Typer(help="Security scanning and compliance checking commands")
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
@security_app.command(name="scan")
|
|
1948
|
+
def security_scan(
|
|
1949
|
+
snapshot: Optional[str] = typer.Option(None, "--snapshot", "-s", help="Snapshot name to scan"),
|
|
1950
|
+
inventory: Optional[str] = typer.Option(None, "--inventory", "-i", help="Inventory name (uses active snapshot)"),
|
|
1951
|
+
storage_dir: Optional[str] = typer.Option(None, "--storage-dir", help="Snapshot storage directory"),
|
|
1952
|
+
severity: Optional[str] = typer.Option(None, "--severity", help="Filter by severity: critical, high, medium, low"),
|
|
1953
|
+
export: Optional[str] = typer.Option(None, "--export", help="Export findings to file"),
|
|
1954
|
+
format: str = typer.Option("json", "--format", "-f", help="Export format: json or csv"),
|
|
1955
|
+
cis_only: bool = typer.Option(False, "--cis-only", help="Show only findings with CIS Benchmark mappings"),
|
|
1956
|
+
profile: Optional[str] = typer.Option(None, "--profile", "-p", help="AWS profile name"),
|
|
1957
|
+
):
|
|
1958
|
+
"""Scan a snapshot for security misconfigurations and compliance issues.
|
|
1959
|
+
|
|
1960
|
+
Performs comprehensive security checks including:
|
|
1961
|
+
- Public S3 buckets
|
|
1962
|
+
- Open security groups (SSH, RDP, databases)
|
|
1963
|
+
- Publicly accessible RDS instances
|
|
1964
|
+
- EC2 instances with IMDSv1 enabled
|
|
1965
|
+
- IAM credentials older than 90 days
|
|
1966
|
+
- Secrets Manager secrets not rotated in 90+ days
|
|
1967
|
+
|
|
1968
|
+
Examples:
|
|
1969
|
+
# Scan a specific snapshot
|
|
1970
|
+
awsinv security scan --snapshot my-snapshot
|
|
1971
|
+
|
|
1972
|
+
# Scan with severity filter
|
|
1973
|
+
awsinv security scan --snapshot my-snapshot --severity critical
|
|
1974
|
+
|
|
1975
|
+
# Export findings to JSON
|
|
1976
|
+
awsinv security scan --snapshot my-snapshot --export findings.json
|
|
1977
|
+
|
|
1978
|
+
# Export to CSV
|
|
1979
|
+
awsinv security scan --snapshot my-snapshot --export findings.csv --format csv
|
|
1980
|
+
|
|
1981
|
+
# Show only CIS-mapped findings
|
|
1982
|
+
awsinv security scan --snapshot my-snapshot --cis-only
|
|
1983
|
+
"""
|
|
1984
|
+
from ..security.cis_mapper import CISMapper
|
|
1985
|
+
from ..security.reporter import SecurityReporter
|
|
1986
|
+
from ..security.scanner import SecurityScanner
|
|
1987
|
+
from ..snapshot.inventory_storage import InventoryStorage
|
|
1988
|
+
|
|
1989
|
+
try:
|
|
1990
|
+
# Determine which snapshot to scan
|
|
1991
|
+
if not snapshot and not inventory:
|
|
1992
|
+
console.print("✗ Error: Must specify either --snapshot or --inventory", style="bold red")
|
|
1993
|
+
raise typer.Exit(code=1)
|
|
1994
|
+
|
|
1995
|
+
# Use profile parameter if provided, otherwise use config
|
|
1996
|
+
aws_profile = profile if profile else config.aws_profile
|
|
1997
|
+
|
|
1998
|
+
# Load snapshot
|
|
1999
|
+
storage = SnapshotStorage(storage_dir or config.storage_path)
|
|
2000
|
+
|
|
2001
|
+
if inventory:
|
|
2002
|
+
# Load active snapshot from inventory
|
|
2003
|
+
# Need AWS credentials to get account ID
|
|
2004
|
+
identity = validate_credentials(aws_profile)
|
|
2005
|
+
inv_storage = InventoryStorage(storage_dir or config.storage_path)
|
|
2006
|
+
inv = inv_storage.get_by_name(inventory, identity["account_id"])
|
|
2007
|
+
if not inv.active_snapshot:
|
|
2008
|
+
console.print(
|
|
2009
|
+
f"✗ Error: Inventory '{inventory}' has no active snapshot. "
|
|
2010
|
+
f"Use 'awsinv snapshot set-active' to set one.",
|
|
2011
|
+
style="bold red",
|
|
2012
|
+
)
|
|
2013
|
+
raise typer.Exit(code=1)
|
|
2014
|
+
# Strip .yaml or .yaml.gz extension if present
|
|
2015
|
+
snapshot_name = inv.active_snapshot.replace(".yaml.gz", "").replace(".yaml", "")
|
|
2016
|
+
snapshot_obj = storage.load_snapshot(snapshot_name)
|
|
2017
|
+
else:
|
|
2018
|
+
snapshot_obj = storage.load_snapshot(snapshot) # type: ignore
|
|
2019
|
+
|
|
2020
|
+
console.print(f"\n🔍 Scanning snapshot: [bold cyan]{snapshot_obj.name}[/bold cyan]\n")
|
|
2021
|
+
|
|
2022
|
+
# Parse severity filter
|
|
2023
|
+
severity_filter = None
|
|
2024
|
+
if severity:
|
|
2025
|
+
from ..models.security_finding import Severity
|
|
2026
|
+
|
|
2027
|
+
severity_map = {
|
|
2028
|
+
"critical": Severity.CRITICAL,
|
|
2029
|
+
"high": Severity.HIGH,
|
|
2030
|
+
"medium": Severity.MEDIUM,
|
|
2031
|
+
"low": Severity.LOW,
|
|
2032
|
+
}
|
|
2033
|
+
severity_filter = severity_map.get(severity.lower())
|
|
2034
|
+
if not severity_filter:
|
|
2035
|
+
console.print(f"✗ Invalid severity: {severity}. Must be: critical, high, medium, low", style="bold red")
|
|
2036
|
+
raise typer.Exit(code=1)
|
|
2037
|
+
|
|
2038
|
+
# Run security scan
|
|
2039
|
+
scanner = SecurityScanner()
|
|
2040
|
+
result = scanner.scan(snapshot_obj, severity_filter=severity_filter)
|
|
2041
|
+
|
|
2042
|
+
# Filter CIS-only if requested
|
|
2043
|
+
findings_to_report = result.findings
|
|
2044
|
+
if cis_only:
|
|
2045
|
+
findings_to_report = [f for f in result.findings if f.cis_control is not None]
|
|
2046
|
+
|
|
2047
|
+
# Display results
|
|
2048
|
+
reporter = SecurityReporter()
|
|
2049
|
+
|
|
2050
|
+
if len(findings_to_report) == 0:
|
|
2051
|
+
console.print("✓ [bold green]No security issues found![/bold green]\n")
|
|
2052
|
+
if severity_filter:
|
|
2053
|
+
console.print(f" (Filtered by severity: {severity})")
|
|
2054
|
+
if cis_only:
|
|
2055
|
+
console.print(" (Showing only CIS-mapped findings)")
|
|
2056
|
+
else:
|
|
2057
|
+
# Generate summary
|
|
2058
|
+
summary = reporter.generate_summary(findings_to_report)
|
|
2059
|
+
|
|
2060
|
+
console.print(f"[bold red]✗ Found {summary['total_findings']} security issue(s)[/bold red]\n")
|
|
2061
|
+
console.print(
|
|
2062
|
+
f" Critical: {summary['critical_count']} "
|
|
2063
|
+
f"High: {summary['high_count']} "
|
|
2064
|
+
f"Medium: {summary['medium_count']} "
|
|
2065
|
+
f"Low: {summary['low_count']}\n"
|
|
2066
|
+
)
|
|
2067
|
+
|
|
2068
|
+
# Display findings
|
|
2069
|
+
output = reporter.format_terminal(findings_to_report)
|
|
2070
|
+
console.print(output)
|
|
2071
|
+
|
|
2072
|
+
# Show CIS summary
|
|
2073
|
+
cis_mapper = CISMapper()
|
|
2074
|
+
cis_summary = cis_mapper.get_summary(findings_to_report)
|
|
2075
|
+
|
|
2076
|
+
if cis_summary["total_controls_checked"] > 0:
|
|
2077
|
+
console.print("\n[bold]CIS Benchmark Summary:[/bold]")
|
|
2078
|
+
console.print(
|
|
2079
|
+
f" Controls checked: {cis_summary['total_controls_checked']} "
|
|
2080
|
+
f"Failed: {cis_summary['controls_failed']} "
|
|
2081
|
+
f"Passed: {cis_summary['controls_passed']}"
|
|
2082
|
+
)
|
|
2083
|
+
|
|
2084
|
+
# Export if requested
|
|
2085
|
+
if export:
|
|
2086
|
+
if format.lower() == "json":
|
|
2087
|
+
reporter.export_json(findings_to_report, export)
|
|
2088
|
+
console.print(f"\n✓ Exported findings to: [cyan]{export}[/cyan] (JSON)")
|
|
2089
|
+
elif format.lower() == "csv":
|
|
2090
|
+
reporter.export_csv(findings_to_report, export)
|
|
2091
|
+
console.print(f"\n✓ Exported findings to: [cyan]{export}[/cyan] (CSV)")
|
|
2092
|
+
else:
|
|
2093
|
+
console.print(f"✗ Invalid format: {format}. Must be 'json' or 'csv'", style="bold red")
|
|
2094
|
+
raise typer.Exit(code=1)
|
|
2095
|
+
|
|
2096
|
+
except typer.Exit:
|
|
2097
|
+
# Re-raise Typer exit codes (for early returns like missing params)
|
|
2098
|
+
raise
|
|
2099
|
+
except FileNotFoundError as e:
|
|
2100
|
+
console.print(f"✗ Snapshot not found: {e}", style="bold red")
|
|
2101
|
+
raise typer.Exit(code=1)
|
|
2102
|
+
except Exception as e:
|
|
2103
|
+
console.print(f"✗ Error during security scan: {e}", style="bold red")
|
|
2104
|
+
logger.exception("Error in security scan command")
|
|
2105
|
+
raise typer.Exit(code=2)
|
|
2106
|
+
|
|
2107
|
+
|
|
2108
|
+
app.add_typer(security_app, name="security")
|
|
2109
|
+
|
|
2110
|
+
|
|
2111
|
+
# Cleanup commands (destructive operations)
|
|
2112
|
+
cleanup_app = typer.Typer(help="Delete resources - returns environment to baseline or removes unprotected resources")
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
@cleanup_app.command("preview")
|
|
2116
|
+
def cleanup_preview(
|
|
2117
|
+
baseline_snapshot: str = typer.Argument(..., help="Baseline snapshot - resources created after this will be deleted"),
|
|
2118
|
+
account_id: str = typer.Option(None, "--account-id", help="AWS account ID (auto-detected if not provided)"),
|
|
2119
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
|
|
2120
|
+
resource_types: Optional[List[str]] = typer.Option(
|
|
2121
|
+
None, "--type", help="Filter by resource types (e.g., AWS::EC2::Instance)"
|
|
2122
|
+
),
|
|
2123
|
+
regions: Optional[List[str]] = typer.Option(None, "--region", help="Filter by AWS regions"),
|
|
2124
|
+
protect_tags: Optional[List[str]] = typer.Option(
|
|
2125
|
+
None, "--protect-tag", help="Protect resources with tag (format: key=value, can repeat)"
|
|
2126
|
+
),
|
|
2127
|
+
config_file: Optional[str] = typer.Option(
|
|
2128
|
+
None, "--config", help="Path to protection rules config file"
|
|
2129
|
+
),
|
|
2130
|
+
output_format: str = typer.Option("table", "--format", help="Output format: table, json, yaml"),
|
|
2131
|
+
):
|
|
2132
|
+
"""Preview resources that would be DELETED to return to a baseline snapshot.
|
|
2133
|
+
|
|
2134
|
+
Shows what resources have been created since the snapshot without
|
|
2135
|
+
performing any deletions. This is a safe dry-run operation.
|
|
2136
|
+
|
|
2137
|
+
Examples:
|
|
2138
|
+
# Preview resources created since a baseline snapshot
|
|
2139
|
+
awsinv cleanup preview prod-baseline
|
|
2140
|
+
|
|
2141
|
+
# Preview with tag-based protection
|
|
2142
|
+
awsinv cleanup preview my-snapshot --protect-tag "project=baseline"
|
|
2143
|
+
|
|
2144
|
+
# Preview with multiple protection tags
|
|
2145
|
+
awsinv cleanup preview my-snapshot --protect-tag "project=baseline" --protect-tag "env=prod"
|
|
2146
|
+
|
|
2147
|
+
# Preview with config file
|
|
2148
|
+
awsinv cleanup preview my-snapshot --config .awsinv-cleanup.yaml
|
|
2149
|
+
|
|
2150
|
+
# Preview only EC2 instances in us-east-1
|
|
2151
|
+
awsinv cleanup preview my-snapshot --type AWS::EC2::Instance --region us-east-1
|
|
2152
|
+
"""
|
|
2153
|
+
from ..aws.credentials import get_account_id
|
|
2154
|
+
from ..restore.audit import AuditStorage
|
|
2155
|
+
from ..restore.cleaner import ResourceCleaner
|
|
2156
|
+
from ..restore.config import build_protection_rules, load_config_file
|
|
2157
|
+
from ..restore.safety import SafetyChecker
|
|
2158
|
+
|
|
2159
|
+
try:
|
|
2160
|
+
console.print("\n[bold cyan]🔍 Previewing Resource Cleanup[/bold cyan]\n")
|
|
2161
|
+
|
|
2162
|
+
# Auto-detect account ID if not provided
|
|
2163
|
+
if not account_id:
|
|
2164
|
+
try:
|
|
2165
|
+
account_id = get_account_id(profile_name=profile)
|
|
2166
|
+
console.print(f"[dim]Detected account ID: {account_id}[/dim]")
|
|
2167
|
+
except Exception as e:
|
|
2168
|
+
console.print(f"[red]Error detecting account ID: {e}[/red]")
|
|
2169
|
+
console.print("[yellow]Please provide --account-id explicitly[/yellow]")
|
|
2170
|
+
raise typer.Exit(code=1)
|
|
2171
|
+
|
|
2172
|
+
# Load config and build protection rules
|
|
2173
|
+
config = load_config_file(config_file)
|
|
2174
|
+
protection_rules = build_protection_rules(config, protect_tags)
|
|
2175
|
+
|
|
2176
|
+
if protection_rules:
|
|
2177
|
+
console.print(f"[dim]Loaded {len(protection_rules)} protection rule(s)[/dim]")
|
|
2178
|
+
|
|
2179
|
+
# Initialize components
|
|
2180
|
+
snapshot_storage = SnapshotStorage()
|
|
2181
|
+
safety_checker = SafetyChecker(rules=protection_rules)
|
|
2182
|
+
audit_storage = AuditStorage()
|
|
2183
|
+
|
|
2184
|
+
cleaner = ResourceCleaner(
|
|
2185
|
+
snapshot_storage=snapshot_storage,
|
|
2186
|
+
safety_checker=safety_checker,
|
|
2187
|
+
audit_storage=audit_storage,
|
|
2188
|
+
)
|
|
2189
|
+
|
|
2190
|
+
# Run preview
|
|
2191
|
+
with console.status("[bold green]Analyzing resources..."):
|
|
2192
|
+
operation = cleaner.preview(
|
|
2193
|
+
baseline_snapshot=baseline_snapshot,
|
|
2194
|
+
account_id=account_id,
|
|
2195
|
+
aws_profile=profile,
|
|
2196
|
+
resource_types=resource_types,
|
|
2197
|
+
regions=regions,
|
|
2198
|
+
)
|
|
2199
|
+
|
|
2200
|
+
# Display results
|
|
2201
|
+
console.print("\n[bold green]✓ Preview Complete[/bold green]\n")
|
|
2202
|
+
|
|
2203
|
+
# Summary panel
|
|
2204
|
+
summary_text = f"""
|
|
2205
|
+
[bold]Operation ID:[/bold] {operation.operation_id}
|
|
2206
|
+
[bold]Baseline Snapshot:[/bold] {operation.baseline_snapshot}
|
|
2207
|
+
[bold]Account ID:[/bold] {operation.account_id}
|
|
2208
|
+
[bold]Mode:[/bold] DRY-RUN (preview only)
|
|
2209
|
+
[bold]Status:[/bold] {operation.status.value.upper()}
|
|
2210
|
+
|
|
2211
|
+
[bold cyan]Resources Identified:[/bold cyan]
|
|
2212
|
+
• Total: {operation.total_resources}
|
|
2213
|
+
• Would be deleted: {operation.total_resources - operation.skipped_count}
|
|
2214
|
+
• Protected (skipped): {operation.skipped_count}
|
|
2215
|
+
"""
|
|
2216
|
+
|
|
2217
|
+
if operation.filters:
|
|
2218
|
+
filter_text = "\n[bold]Filters Applied:[/bold]"
|
|
2219
|
+
if operation.filters.get("resource_types"):
|
|
2220
|
+
filter_text += f"\n• Types: {', '.join(operation.filters['resource_types'])}"
|
|
2221
|
+
if operation.filters.get("regions"):
|
|
2222
|
+
filter_text += f"\n• Regions: {', '.join(operation.filters['regions'])}"
|
|
2223
|
+
summary_text += filter_text
|
|
2224
|
+
|
|
2225
|
+
console.print(Panel(summary_text.strip(), title="[bold]Preview Summary[/bold]", border_style="cyan"))
|
|
2226
|
+
|
|
2227
|
+
# Warning if resources would be deleted
|
|
2228
|
+
if operation.total_resources > operation.skipped_count:
|
|
2229
|
+
deletable_count = operation.total_resources - operation.skipped_count
|
|
2230
|
+
console.print(
|
|
2231
|
+
f"\n[yellow]⚠️ {deletable_count} resource(s) would be DELETED if you run 'cleanup execute'[/yellow]"
|
|
2232
|
+
)
|
|
2233
|
+
console.print("[dim]Use 'awsinv cleanup execute' with --confirm to actually delete resources[/dim]\n")
|
|
2234
|
+
else:
|
|
2235
|
+
console.print("\n[green]✓ No resources would be deleted - environment matches baseline[/green]\n")
|
|
2236
|
+
|
|
2237
|
+
except ValueError as e:
|
|
2238
|
+
console.print(f"\n[red]Error: {e}[/red]\n")
|
|
2239
|
+
raise typer.Exit(code=1)
|
|
2240
|
+
except Exception as e:
|
|
2241
|
+
console.print(f"\n[red]Unexpected error: {e}[/red]\n")
|
|
2242
|
+
logger.exception("Error in cleanup preview command")
|
|
2243
|
+
raise typer.Exit(code=2)
|
|
2244
|
+
|
|
2245
|
+
|
|
2246
|
+
@cleanup_app.command("execute")
|
|
2247
|
+
def cleanup_execute(
|
|
2248
|
+
baseline_snapshot: str = typer.Argument(..., help="Baseline snapshot - resources created after this will be deleted"),
|
|
2249
|
+
account_id: str = typer.Option(None, "--account-id", help="AWS account ID (auto-detected if not provided)"),
|
|
2250
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
|
|
2251
|
+
resource_types: Optional[List[str]] = typer.Option(None, "--type", help="Filter by resource types"),
|
|
2252
|
+
regions: Optional[List[str]] = typer.Option(None, "--region", help="Filter by AWS regions"),
|
|
2253
|
+
protect_tags: Optional[List[str]] = typer.Option(
|
|
2254
|
+
None, "--protect-tag", help="Protect resources with tag (format: key=value, can repeat)"
|
|
2255
|
+
),
|
|
2256
|
+
config_file: Optional[str] = typer.Option(
|
|
2257
|
+
None, "--config", help="Path to protection rules config file"
|
|
2258
|
+
),
|
|
2259
|
+
confirm: bool = typer.Option(False, "--confirm", help="Confirm deletion (REQUIRED for execution)"),
|
|
2260
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirmation prompt"),
|
|
2261
|
+
):
|
|
2262
|
+
"""DELETE resources created after a baseline snapshot.
|
|
2263
|
+
|
|
2264
|
+
⚠️ DESTRUCTIVE OPERATION: This will permanently delete AWS resources!
|
|
2265
|
+
|
|
2266
|
+
Deletes resources that were created after the snapshot, returning
|
|
2267
|
+
your AWS environment to that baseline state. Protected resources are skipped.
|
|
2268
|
+
|
|
2269
|
+
Examples:
|
|
2270
|
+
# Delete resources created after baseline, protecting tagged resources
|
|
2271
|
+
awsinv cleanup execute my-snapshot --protect-tag "project=baseline" --confirm
|
|
2272
|
+
|
|
2273
|
+
# Use config file for protection rules
|
|
2274
|
+
awsinv cleanup execute my-snapshot --config .awsinv-cleanup.yaml --confirm
|
|
2275
|
+
|
|
2276
|
+
# Delete only EC2 instances, skip prompt
|
|
2277
|
+
awsinv cleanup execute my-snapshot --confirm --yes --type AWS::EC2::Instance
|
|
2278
|
+
|
|
2279
|
+
# Delete in specific region with profile
|
|
2280
|
+
awsinv cleanup execute my-snapshot --confirm --region us-east-1 --profile prod
|
|
2281
|
+
"""
|
|
2282
|
+
from ..aws.credentials import get_account_id
|
|
2283
|
+
from ..restore.audit import AuditStorage
|
|
2284
|
+
from ..restore.cleaner import ResourceCleaner
|
|
2285
|
+
from ..restore.config import build_protection_rules, load_config_file
|
|
2286
|
+
from ..restore.safety import SafetyChecker
|
|
2287
|
+
|
|
2288
|
+
try:
|
|
2289
|
+
# Require --confirm flag
|
|
2290
|
+
if not confirm:
|
|
2291
|
+
console.print("\n[red]ERROR: --confirm flag is required for deletion operations[/red]")
|
|
2292
|
+
console.print("[yellow]This is a safety measure to prevent accidental deletions[/yellow]")
|
|
2293
|
+
console.print("\n[dim]Run with: awsinv cleanup execute <snapshot> --confirm[/dim]\n")
|
|
2294
|
+
raise typer.Exit(code=1)
|
|
2295
|
+
|
|
2296
|
+
console.print("\n[bold red]⚠️ DESTRUCTIVE OPERATION[/bold red]\n")
|
|
2297
|
+
|
|
2298
|
+
# Auto-detect account ID if not provided
|
|
2299
|
+
if not account_id:
|
|
2300
|
+
try:
|
|
2301
|
+
account_id = get_account_id(profile_name=profile)
|
|
2302
|
+
console.print(f"[dim]Detected account ID: {account_id}[/dim]")
|
|
2303
|
+
except Exception as e:
|
|
2304
|
+
console.print(f"[red]Error detecting account ID: {e}[/red]")
|
|
2305
|
+
console.print("[yellow]Please provide --account-id explicitly[/yellow]")
|
|
2306
|
+
raise typer.Exit(code=1)
|
|
2307
|
+
|
|
2308
|
+
# Load config and build protection rules
|
|
2309
|
+
config = load_config_file(config_file)
|
|
2310
|
+
protection_rules = build_protection_rules(config, protect_tags)
|
|
2311
|
+
|
|
2312
|
+
if protection_rules:
|
|
2313
|
+
console.print(f"[dim]Loaded {len(protection_rules)} protection rule(s)[/dim]")
|
|
2314
|
+
|
|
2315
|
+
# Initialize components
|
|
2316
|
+
snapshot_storage = SnapshotStorage()
|
|
2317
|
+
safety_checker = SafetyChecker(rules=protection_rules)
|
|
2318
|
+
audit_storage = AuditStorage()
|
|
2319
|
+
|
|
2320
|
+
cleaner = ResourceCleaner(
|
|
2321
|
+
snapshot_storage=snapshot_storage,
|
|
2322
|
+
safety_checker=safety_checker,
|
|
2323
|
+
audit_storage=audit_storage,
|
|
2324
|
+
)
|
|
2325
|
+
|
|
2326
|
+
# First, run preview to show what will be deleted
|
|
2327
|
+
console.print("[bold]Preview - Analyzing resources...[/bold]")
|
|
2328
|
+
with console.status("[bold green]Analyzing..."):
|
|
2329
|
+
preview_op = cleaner.preview(
|
|
2330
|
+
baseline_snapshot=baseline_snapshot,
|
|
2331
|
+
account_id=account_id,
|
|
2332
|
+
aws_profile=profile,
|
|
2333
|
+
resource_types=resource_types,
|
|
2334
|
+
regions=regions,
|
|
2335
|
+
)
|
|
2336
|
+
|
|
2337
|
+
deletable_count = preview_op.total_resources - preview_op.skipped_count
|
|
2338
|
+
|
|
2339
|
+
if deletable_count == 0:
|
|
2340
|
+
console.print("\n[green]✓ No resources to delete - environment matches baseline[/green]\n")
|
|
2341
|
+
raise typer.Exit(code=0)
|
|
2342
|
+
|
|
2343
|
+
# Show what will be deleted
|
|
2344
|
+
console.print("\n[bold yellow]The following will be PERMANENTLY DELETED:[/bold yellow]")
|
|
2345
|
+
console.print(f"• {deletable_count} resource(s) will be deleted")
|
|
2346
|
+
console.print(f"• {preview_op.skipped_count} resource(s) will be skipped (protected)")
|
|
2347
|
+
console.print(f"• Account: {account_id}")
|
|
2348
|
+
console.print(f"• Baseline: {baseline_snapshot}")
|
|
2349
|
+
|
|
2350
|
+
if preview_op.filters:
|
|
2351
|
+
if preview_op.filters.get("resource_types"):
|
|
2352
|
+
console.print(f"• Types: {', '.join(preview_op.filters['resource_types'])}")
|
|
2353
|
+
if preview_op.filters.get("regions"):
|
|
2354
|
+
console.print(f"• Regions: {', '.join(preview_op.filters['regions'])}")
|
|
2355
|
+
|
|
2356
|
+
# Interactive confirmation (unless --yes flag)
|
|
2357
|
+
if not yes:
|
|
2358
|
+
console.print()
|
|
2359
|
+
proceed = typer.confirm(
|
|
2360
|
+
"⚠️ Are you absolutely sure you want to DELETE these resources?",
|
|
2361
|
+
default=False,
|
|
2362
|
+
)
|
|
2363
|
+
if not proceed:
|
|
2364
|
+
console.print("\n[yellow]Aborted - no resources were deleted[/yellow]\n")
|
|
2365
|
+
raise typer.Exit(code=0)
|
|
2366
|
+
|
|
2367
|
+
# Execute deletion
|
|
2368
|
+
console.print("\n[bold red]Executing deletion...[/bold red]")
|
|
2369
|
+
with console.status("[bold red]Deleting resources..."):
|
|
2370
|
+
operation = cleaner.execute(
|
|
2371
|
+
baseline_snapshot=baseline_snapshot,
|
|
2372
|
+
account_id=account_id,
|
|
2373
|
+
confirmed=True,
|
|
2374
|
+
aws_profile=profile,
|
|
2375
|
+
resource_types=resource_types,
|
|
2376
|
+
regions=regions,
|
|
2377
|
+
)
|
|
2378
|
+
|
|
2379
|
+
# Display results
|
|
2380
|
+
console.print("\n[bold]Deletion Complete[/bold]\n")
|
|
2381
|
+
|
|
2382
|
+
# Results summary
|
|
2383
|
+
status_color = (
|
|
2384
|
+
"green"
|
|
2385
|
+
if operation.status.value == "completed"
|
|
2386
|
+
else "yellow" if operation.status.value == "partial" else "red"
|
|
2387
|
+
)
|
|
2388
|
+
|
|
2389
|
+
summary_text = f"""
|
|
2390
|
+
[bold]Operation ID:[/bold] {operation.operation_id}
|
|
2391
|
+
[bold]Status:[/bold] [{status_color}]{operation.status.value.upper()}[/{status_color}]
|
|
2392
|
+
|
|
2393
|
+
[bold]Results:[/bold]
|
|
2394
|
+
• Succeeded: {operation.succeeded_count}
|
|
2395
|
+
• Failed: {operation.failed_count}
|
|
2396
|
+
• Skipped: {operation.skipped_count}
|
|
2397
|
+
• Total: {operation.total_resources}
|
|
2398
|
+
"""
|
|
2399
|
+
|
|
2400
|
+
console.print(Panel(summary_text.strip(), title="[bold]Execution Summary[/bold]", border_style=status_color))
|
|
2401
|
+
|
|
2402
|
+
# Show audit log location
|
|
2403
|
+
console.print("\n[dim]📝 Full audit log saved to: ~/.snapshots/audit-logs/[/dim]\n")
|
|
2404
|
+
|
|
2405
|
+
# Exit with appropriate code
|
|
2406
|
+
if operation.failed_count > 0:
|
|
2407
|
+
raise typer.Exit(code=1)
|
|
2408
|
+
|
|
2409
|
+
except ValueError as e:
|
|
2410
|
+
console.print(f"\n[red]Error: {e}[/red]\n")
|
|
2411
|
+
raise typer.Exit(code=1)
|
|
2412
|
+
except Exception as e:
|
|
2413
|
+
console.print(f"\n[red]Unexpected error: {e}[/red]\n")
|
|
2414
|
+
logger.exception("Error in cleanup execute command")
|
|
2415
|
+
raise typer.Exit(code=2)
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
@cleanup_app.command("purge")
|
|
2419
|
+
def cleanup_purge(
|
|
2420
|
+
account_id: str = typer.Option(None, "--account-id", help="AWS account ID (auto-detected if not provided)"),
|
|
2421
|
+
profile: Optional[str] = typer.Option(None, "--profile", help="AWS profile name"),
|
|
2422
|
+
resource_types: Optional[List[str]] = typer.Option(None, "--type", help="Filter by resource types"),
|
|
2423
|
+
regions: Optional[List[str]] = typer.Option(None, "--region", help="Filter by AWS regions"),
|
|
2424
|
+
protect_tags: Optional[List[str]] = typer.Option(
|
|
2425
|
+
None, "--protect-tag", help="Protect resources with tag (format: key=value, can repeat)"
|
|
2426
|
+
),
|
|
2427
|
+
config_file: Optional[str] = typer.Option(
|
|
2428
|
+
None, "--config", help="Path to protection rules config file"
|
|
2429
|
+
),
|
|
2430
|
+
preview: bool = typer.Option(False, "--preview", help="Preview mode - show what would be deleted without deleting"),
|
|
2431
|
+
confirm: bool = typer.Option(False, "--confirm", help="Confirm deletion (REQUIRED for execution)"),
|
|
2432
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip interactive confirmation prompt"),
|
|
2433
|
+
):
|
|
2434
|
+
"""DELETE all resources EXCEPT those matching protection rules.
|
|
2435
|
+
|
|
2436
|
+
⚠️ DESTRUCTIVE OPERATION: This will permanently delete AWS resources!
|
|
2437
|
+
|
|
2438
|
+
Unlike 'cleanup execute', this does NOT compare to a snapshot. It deletes
|
|
2439
|
+
ALL resources that don't match protection rules (tags, types, etc.).
|
|
2440
|
+
|
|
2441
|
+
Use this for lab/sandbox cleanup where baseline resources are tagged.
|
|
2442
|
+
|
|
2443
|
+
Examples:
|
|
2444
|
+
# Preview what would be deleted (safe)
|
|
2445
|
+
awsinv cleanup purge --protect-tag "project=baseline" --preview
|
|
2446
|
+
|
|
2447
|
+
# Delete everything except baseline-tagged resources
|
|
2448
|
+
awsinv cleanup purge --protect-tag "project=baseline" --confirm
|
|
2449
|
+
|
|
2450
|
+
# Multiple protection tags (OR logic - protected if ANY match)
|
|
2451
|
+
awsinv cleanup purge --protect-tag "project=baseline" --protect-tag "env=prod" --confirm
|
|
2452
|
+
|
|
2453
|
+
# Use config file for protection rules
|
|
2454
|
+
awsinv cleanup purge --config .awsinv-cleanup.yaml --confirm
|
|
2455
|
+
|
|
2456
|
+
# Purge only specific resource types
|
|
2457
|
+
awsinv cleanup purge --protect-tag "project=baseline" --type AWS::EC2::Instance --confirm
|
|
2458
|
+
|
|
2459
|
+
# Purge in specific region
|
|
2460
|
+
awsinv cleanup purge --protect-tag "project=baseline" --region us-east-1 --confirm
|
|
2461
|
+
"""
|
|
2462
|
+
from ..aws.credentials import get_account_id
|
|
2463
|
+
from ..restore.audit import AuditStorage
|
|
2464
|
+
from ..restore.config import build_protection_rules, load_config_file
|
|
2465
|
+
from ..restore.deleter import ResourceDeleter
|
|
2466
|
+
from ..restore.safety import SafetyChecker
|
|
2467
|
+
from ..snapshot.capturer import SnapshotCapturer
|
|
2468
|
+
|
|
2469
|
+
try:
|
|
2470
|
+
# Load config and build protection rules
|
|
2471
|
+
config = load_config_file(config_file)
|
|
2472
|
+
protection_rules = build_protection_rules(config, protect_tags)
|
|
2473
|
+
|
|
2474
|
+
# Require at least one protection rule for purge
|
|
2475
|
+
if not protection_rules:
|
|
2476
|
+
console.print("\n[red]ERROR: At least one protection rule is required for purge[/red]")
|
|
2477
|
+
console.print("[yellow]Use --protect-tag or --config to specify what to keep[/yellow]")
|
|
2478
|
+
console.print("\n[dim]Example: awsinv cleanup purge --protect-tag \"project=baseline\" --preview[/dim]\n")
|
|
2479
|
+
raise typer.Exit(code=1)
|
|
2480
|
+
|
|
2481
|
+
if preview:
|
|
2482
|
+
console.print("\n[bold cyan]🔍 Purge Preview (dry-run)[/bold cyan]\n")
|
|
2483
|
+
else:
|
|
2484
|
+
if not confirm:
|
|
2485
|
+
console.print("\n[red]ERROR: --confirm flag is required for purge operations[/red]")
|
|
2486
|
+
console.print("[yellow]This is a safety measure to prevent accidental deletions[/yellow]")
|
|
2487
|
+
console.print("\n[dim]Run with: awsinv cleanup purge --protect-tag \"key=value\" --confirm[/dim]\n")
|
|
2488
|
+
raise typer.Exit(code=1)
|
|
2489
|
+
console.print("\n[bold red]⚠️ PURGE OPERATION - DESTRUCTIVE[/bold red]\n")
|
|
2490
|
+
|
|
2491
|
+
# Auto-detect account ID if not provided
|
|
2492
|
+
if not account_id:
|
|
2493
|
+
try:
|
|
2494
|
+
account_id = get_account_id(profile_name=profile)
|
|
2495
|
+
console.print(f"[dim]Detected account ID: {account_id}[/dim]")
|
|
2496
|
+
except Exception as e:
|
|
2497
|
+
console.print(f"[red]Error detecting account ID: {e}[/red]")
|
|
2498
|
+
console.print("[yellow]Please provide --account-id explicitly[/yellow]")
|
|
2499
|
+
raise typer.Exit(code=1)
|
|
2500
|
+
|
|
2501
|
+
console.print(f"[dim]Loaded {len(protection_rules)} protection rule(s)[/dim]")
|
|
2502
|
+
for rule in protection_rules:
|
|
2503
|
+
console.print(f"[dim] • {rule.description}[/dim]")
|
|
2504
|
+
|
|
2505
|
+
# Initialize safety checker
|
|
2506
|
+
safety_checker = SafetyChecker(rules=protection_rules)
|
|
2507
|
+
|
|
2508
|
+
# Collect current resources
|
|
2509
|
+
console.print("\n[bold]Scanning resources...[/bold]")
|
|
2510
|
+
capturer = SnapshotCapturer(profile_name=profile)
|
|
2511
|
+
target_regions = regions if regions else ["us-east-1"] # Default to us-east-1 if not specified
|
|
2512
|
+
|
|
2513
|
+
with console.status("[bold green]Collecting resources..."):
|
|
2514
|
+
all_resources = []
|
|
2515
|
+
for region in target_regions:
|
|
2516
|
+
try:
|
|
2517
|
+
resources = capturer.collect_resources(
|
|
2518
|
+
regions=[region],
|
|
2519
|
+
resource_types=resource_types,
|
|
2520
|
+
)
|
|
2521
|
+
all_resources.extend(resources)
|
|
2522
|
+
except Exception as e:
|
|
2523
|
+
logger.warning(f"Error collecting resources in {region}: {e}")
|
|
2524
|
+
|
|
2525
|
+
console.print(f"[dim]Found {len(all_resources)} total resources[/dim]")
|
|
2526
|
+
|
|
2527
|
+
# Apply protection rules
|
|
2528
|
+
to_delete = []
|
|
2529
|
+
protected = []
|
|
2530
|
+
|
|
2531
|
+
for resource in all_resources:
|
|
2532
|
+
# Convert Resource object to dict for safety checker
|
|
2533
|
+
resource_dict = {
|
|
2534
|
+
"resource_id": resource.name,
|
|
2535
|
+
"resource_type": resource.resource_type,
|
|
2536
|
+
"region": resource.region,
|
|
2537
|
+
"arn": resource.arn,
|
|
2538
|
+
"tags": resource.tags or {},
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
is_protected, reason = safety_checker.is_protected(resource_dict)
|
|
2542
|
+
|
|
2543
|
+
if is_protected:
|
|
2544
|
+
protected.append((resource, reason))
|
|
2545
|
+
else:
|
|
2546
|
+
to_delete.append(resource)
|
|
2547
|
+
|
|
2548
|
+
# Display summary
|
|
2549
|
+
console.print(f"\n[bold]Summary:[/bold]")
|
|
2550
|
+
console.print(f" • Total resources: {len(all_resources)}")
|
|
2551
|
+
console.print(f" • Protected (will keep): [green]{len(protected)}[/green]")
|
|
2552
|
+
console.print(f" • Unprotected (will delete): [red]{len(to_delete)}[/red]")
|
|
2553
|
+
|
|
2554
|
+
if preview:
|
|
2555
|
+
# Show what would be deleted
|
|
2556
|
+
if to_delete:
|
|
2557
|
+
console.print("\n[bold yellow]Resources that would be DELETED:[/bold yellow]")
|
|
2558
|
+
for resource in to_delete[:20]: # Show first 20
|
|
2559
|
+
console.print(f" [red]✗[/red] {resource.resource_type}: {resource.name} ({resource.region})")
|
|
2560
|
+
if len(to_delete) > 20:
|
|
2561
|
+
console.print(f" ... and {len(to_delete) - 20} more")
|
|
2562
|
+
|
|
2563
|
+
if protected:
|
|
2564
|
+
console.print("\n[bold green]Resources that would be PROTECTED:[/bold green]")
|
|
2565
|
+
for resource, reason in protected[:10]: # Show first 10
|
|
2566
|
+
console.print(f" [green]✓[/green] {resource.resource_type}: {resource.name} - {reason}")
|
|
2567
|
+
if len(protected) > 10:
|
|
2568
|
+
console.print(f" ... and {len(protected) - 10} more")
|
|
2569
|
+
|
|
2570
|
+
console.print("\n[dim]This was a preview. Use --confirm to actually delete resources.[/dim]\n")
|
|
2571
|
+
raise typer.Exit(code=0)
|
|
2572
|
+
|
|
2573
|
+
# Execution mode
|
|
2574
|
+
if len(to_delete) == 0:
|
|
2575
|
+
console.print("\n[green]✓ No unprotected resources to delete[/green]\n")
|
|
2576
|
+
raise typer.Exit(code=0)
|
|
2577
|
+
|
|
2578
|
+
# Interactive confirmation
|
|
2579
|
+
if not yes:
|
|
2580
|
+
console.print(f"\n[bold red]About to DELETE {len(to_delete)} resources![/bold red]")
|
|
2581
|
+
confirm_prompt = typer.confirm("Are you sure you want to proceed?")
|
|
2582
|
+
if not confirm_prompt:
|
|
2583
|
+
console.print("\n[yellow]Aborted - no resources were deleted[/yellow]\n")
|
|
2584
|
+
raise typer.Exit(code=0)
|
|
2585
|
+
|
|
2586
|
+
# Execute deletion
|
|
2587
|
+
console.print("\n[bold red]Executing deletion...[/bold red]")
|
|
2588
|
+
deleter = ResourceDeleter(aws_profile=profile)
|
|
2589
|
+
audit_storage = AuditStorage()
|
|
2590
|
+
|
|
2591
|
+
succeeded = 0
|
|
2592
|
+
failed = 0
|
|
2593
|
+
|
|
2594
|
+
with console.status("[bold red]Deleting resources..."):
|
|
2595
|
+
for resource in to_delete:
|
|
2596
|
+
success, error = deleter.delete_resource(
|
|
2597
|
+
resource_type=resource.resource_type,
|
|
2598
|
+
resource_id=resource.name,
|
|
2599
|
+
region=resource.region,
|
|
2600
|
+
arn=resource.arn,
|
|
2601
|
+
)
|
|
2602
|
+
if success:
|
|
2603
|
+
succeeded += 1
|
|
2604
|
+
logger.info(f"Deleted {resource.resource_type}: {resource.name}")
|
|
2605
|
+
else:
|
|
2606
|
+
failed += 1
|
|
2607
|
+
logger.warning(f"Failed to delete {resource.resource_type}: {resource.name} - {error}")
|
|
2608
|
+
|
|
2609
|
+
# Display results
|
|
2610
|
+
console.print("\n[bold]Purge Complete[/bold]\n")
|
|
2611
|
+
|
|
2612
|
+
status_color = "green" if failed == 0 else "yellow" if succeeded > 0 else "red"
|
|
2613
|
+
|
|
2614
|
+
summary_text = f"""
|
|
2615
|
+
[bold]Results:[/bold]
|
|
2616
|
+
• Succeeded: [green]{succeeded}[/green]
|
|
2617
|
+
• Failed: [red]{failed}[/red]
|
|
2618
|
+
• Protected (skipped): {len(protected)}
|
|
2619
|
+
• Total scanned: {len(all_resources)}
|
|
2620
|
+
"""
|
|
2621
|
+
|
|
2622
|
+
console.print(Panel(summary_text.strip(), title="[bold]Purge Summary[/bold]", border_style=status_color))
|
|
2623
|
+
|
|
2624
|
+
if failed > 0:
|
|
2625
|
+
raise typer.Exit(code=1)
|
|
2626
|
+
|
|
2627
|
+
except typer.Exit:
|
|
2628
|
+
raise
|
|
2629
|
+
except ValueError as e:
|
|
2630
|
+
console.print(f"\n[red]Error: {e}[/red]\n")
|
|
2631
|
+
raise typer.Exit(code=1)
|
|
2632
|
+
except Exception as e:
|
|
2633
|
+
console.print(f"\n[red]Unexpected error: {e}[/red]\n")
|
|
2634
|
+
logger.exception("Error in purge command")
|
|
2635
|
+
raise typer.Exit(code=2)
|
|
2636
|
+
|
|
2637
|
+
|
|
2638
|
+
app.add_typer(cleanup_app, name="cleanup")
|
|
2639
|
+
|
|
2640
|
+
|
|
2641
|
+
# ============================================================================
|
|
2642
|
+
# QUERY COMMANDS - SQL queries across snapshots
|
|
2643
|
+
# ============================================================================
|
|
2644
|
+
|
|
2645
|
+
query_app = typer.Typer(help="Query resources across snapshots using SQL")
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
@query_app.command("sql")
|
|
2649
|
+
def query_sql(
|
|
2650
|
+
query: str = typer.Argument(..., help="SQL query to execute (SELECT only)"),
|
|
2651
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, csv"),
|
|
2652
|
+
limit: int = typer.Option(100, "--limit", "-l", help="Maximum results to return"),
|
|
2653
|
+
snapshot: Optional[str] = typer.Option(
|
|
2654
|
+
None, "--snapshot", "-s", help="Filter by snapshot name", envvar="AWSINV_SNAPSHOT_ID"
|
|
2655
|
+
),
|
|
2656
|
+
):
|
|
2657
|
+
"""Execute raw SQL query against the resource database.
|
|
2658
|
+
|
|
2659
|
+
Only SELECT queries are allowed for safety. The database contains tables:
|
|
2660
|
+
- snapshots: Snapshot metadata
|
|
2661
|
+
- resources: Resource details (arn, type, name, region, config_hash)
|
|
2662
|
+
- resource_tags: Tags for each resource (resource_id, key, value)
|
|
2663
|
+
- inventories: Inventory definitions
|
|
2664
|
+
- audit_operations: Audit operation logs
|
|
2665
|
+
- audit_records: Individual resource audit records
|
|
2666
|
+
|
|
2667
|
+
Examples:
|
|
2668
|
+
awsinv query sql "SELECT resource_type, COUNT(*) as count FROM resources GROUP BY resource_type"
|
|
2669
|
+
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'"
|
|
2670
|
+
# Use --snapshot to automatically filter by snapshot_id
|
|
2671
|
+
awsinv query sql "SELECT * FROM resources" --snapshot my-snapshot
|
|
2672
|
+
"""
|
|
2673
|
+
from ..storage import Database, ResourceStore
|
|
2674
|
+
import json
|
|
2675
|
+
import csv
|
|
2676
|
+
import sys
|
|
2677
|
+
import re
|
|
2678
|
+
|
|
2679
|
+
setup_logging()
|
|
2680
|
+
|
|
2681
|
+
try:
|
|
2682
|
+
db = Database()
|
|
2683
|
+
db.ensure_schema()
|
|
2684
|
+
store = ResourceStore(db)
|
|
2685
|
+
|
|
2686
|
+
# Apply snapshot filter if provided
|
|
2687
|
+
if snapshot:
|
|
2688
|
+
# Look up snapshot ID
|
|
2689
|
+
rows = db.fetchall("SELECT id FROM snapshots WHERE name = ?", (snapshot,))
|
|
2690
|
+
if not rows:
|
|
2691
|
+
console.print(f"[red]Error: Snapshot '{snapshot}' not found[/red]")
|
|
2692
|
+
raise typer.Exit(code=1)
|
|
2693
|
+
|
|
2694
|
+
snapshot_id = rows[0]["id"]
|
|
2695
|
+
|
|
2696
|
+
# Inject WHERE clause logic
|
|
2697
|
+
# 1. Check for existing WHERE
|
|
2698
|
+
match_where = re.search(r'(?i)\bwhere\b', query)
|
|
2699
|
+
if match_where:
|
|
2700
|
+
# Insert AND after WHERE
|
|
2701
|
+
start, end = match_where.span()
|
|
2702
|
+
query = query[:end] + f" snapshot_id = {snapshot_id} AND" + query[end:]
|
|
2703
|
+
else:
|
|
2704
|
+
# 2. Check for clauses that must come AFTER WHERE (GROUP BY, HAVING, ORDER BY, LIMIT)
|
|
2705
|
+
match_clause = re.search(r'(?i)\b(group\s+by|having|order\s+by|limit)\b', query)
|
|
2706
|
+
if match_clause:
|
|
2707
|
+
start, end = match_clause.span()
|
|
2708
|
+
query = query[:start] + f" WHERE snapshot_id = {snapshot_id} " + query[start:]
|
|
2709
|
+
else:
|
|
2710
|
+
# 3. Simple append
|
|
2711
|
+
query = query.rstrip(";") + f" WHERE snapshot_id = {snapshot_id}"
|
|
2712
|
+
|
|
2713
|
+
logger.debug(f"Modified query with snapshot filter: {query}")
|
|
2714
|
+
|
|
2715
|
+
# Add LIMIT if not present
|
|
2716
|
+
query_upper = query.strip().upper()
|
|
2717
|
+
if "LIMIT" not in query_upper:
|
|
2718
|
+
query = f"{query.rstrip(';')} LIMIT {limit}"
|
|
2719
|
+
|
|
2720
|
+
results = store.query_raw(query)
|
|
2721
|
+
|
|
2722
|
+
if not results:
|
|
2723
|
+
console.print("[yellow]No results found[/yellow]")
|
|
2724
|
+
return
|
|
2725
|
+
|
|
2726
|
+
if format == "json":
|
|
2727
|
+
console.print(json.dumps(results, indent=2, default=str))
|
|
2728
|
+
elif format == "csv":
|
|
2729
|
+
if results:
|
|
2730
|
+
writer = csv.DictWriter(sys.stdout, fieldnames=results[0].keys())
|
|
2731
|
+
writer.writeheader()
|
|
2732
|
+
writer.writerows(results)
|
|
2733
|
+
else: # table
|
|
2734
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
2735
|
+
for key in results[0].keys():
|
|
2736
|
+
table.add_column(key)
|
|
2737
|
+
for row in results:
|
|
2738
|
+
table.add_row(*[str(v) if v is not None else "" for v in row.values()])
|
|
2739
|
+
console.print(table)
|
|
2740
|
+
|
|
2741
|
+
console.print(f"\n[dim]{len(results)} row(s) returned[/dim]")
|
|
2742
|
+
|
|
2743
|
+
except ValueError as e:
|
|
2744
|
+
console.print(f"[red]Query error: {e}[/red]")
|
|
2745
|
+
raise typer.Exit(code=1)
|
|
2746
|
+
except Exception as e:
|
|
2747
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
2748
|
+
logger.exception("Query failed")
|
|
2749
|
+
raise typer.Exit(code=1)
|
|
2750
|
+
|
|
2751
|
+
|
|
2752
|
+
@query_app.command("resources")
|
|
2753
|
+
def query_resources(
|
|
2754
|
+
type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by resource type (e.g., 's3:bucket', 'ec2')"),
|
|
2755
|
+
region: Optional[str] = typer.Option(None, "--region", "-r", help="Filter by region"),
|
|
2756
|
+
tag: Optional[str] = typer.Option(None, "--tag", help="Filter by tag (Key=Value)"),
|
|
2757
|
+
arn: Optional[str] = typer.Option(None, "--arn", help="Filter by ARN pattern (supports wildcards)"),
|
|
2758
|
+
snapshot: Optional[str] = typer.Option(None, "--snapshot", "-s", help="Limit to specific snapshot"),
|
|
2759
|
+
limit: int = typer.Option(100, "--limit", "-l", help="Maximum results to return"),
|
|
2760
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
2761
|
+
):
|
|
2762
|
+
"""Search resources with filters across all snapshots.
|
|
2763
|
+
|
|
2764
|
+
Examples:
|
|
2765
|
+
awsinv query resources --type s3:bucket
|
|
2766
|
+
awsinv query resources --region us-east-1 --type ec2
|
|
2767
|
+
awsinv query resources --tag Environment=production
|
|
2768
|
+
awsinv query resources --arn "arn:aws:s3:::my-bucket*"
|
|
2769
|
+
awsinv query resources --snapshot baseline-2024 --type lambda
|
|
2770
|
+
"""
|
|
2771
|
+
from ..storage import Database, ResourceStore
|
|
2772
|
+
import json
|
|
2773
|
+
|
|
2774
|
+
setup_logging()
|
|
2775
|
+
|
|
2776
|
+
try:
|
|
2777
|
+
db = Database()
|
|
2778
|
+
db.ensure_schema()
|
|
2779
|
+
store = ResourceStore(db)
|
|
2780
|
+
|
|
2781
|
+
# Parse tag filter
|
|
2782
|
+
tag_key = None
|
|
2783
|
+
tag_value = None
|
|
2784
|
+
if tag:
|
|
2785
|
+
if "=" in tag:
|
|
2786
|
+
tag_key, tag_value = tag.split("=", 1)
|
|
2787
|
+
else:
|
|
2788
|
+
tag_key = tag
|
|
2789
|
+
|
|
2790
|
+
results = store.search(
|
|
2791
|
+
arn_pattern=arn,
|
|
2792
|
+
resource_type=type,
|
|
2793
|
+
region=region,
|
|
2794
|
+
tag_key=tag_key,
|
|
2795
|
+
tag_value=tag_value,
|
|
2796
|
+
snapshot_name=snapshot,
|
|
2797
|
+
limit=limit,
|
|
2798
|
+
)
|
|
2799
|
+
|
|
2800
|
+
if not results:
|
|
2801
|
+
console.print("[yellow]No resources found matching filters[/yellow]")
|
|
2802
|
+
return
|
|
2803
|
+
|
|
2804
|
+
if format == "json":
|
|
2805
|
+
console.print(json.dumps(results, indent=2, default=str))
|
|
2806
|
+
else:
|
|
2807
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
2808
|
+
table.add_column("ARN", style="cyan", no_wrap=True)
|
|
2809
|
+
table.add_column("Type")
|
|
2810
|
+
table.add_column("Name")
|
|
2811
|
+
table.add_column("Region")
|
|
2812
|
+
table.add_column("Snapshot")
|
|
2813
|
+
|
|
2814
|
+
for r in results:
|
|
2815
|
+
# Truncate ARN for display
|
|
2816
|
+
arn_display = r["arn"]
|
|
2817
|
+
if len(arn_display) > 60:
|
|
2818
|
+
arn_display = "..." + arn_display[-57:]
|
|
2819
|
+
table.add_row(
|
|
2820
|
+
arn_display,
|
|
2821
|
+
r["resource_type"],
|
|
2822
|
+
r["name"],
|
|
2823
|
+
r["region"],
|
|
2824
|
+
r["snapshot_name"],
|
|
2825
|
+
)
|
|
2826
|
+
console.print(table)
|
|
2827
|
+
|
|
2828
|
+
console.print(f"\n[dim]{len(results)} resource(s) found[/dim]")
|
|
2829
|
+
|
|
2830
|
+
except Exception as e:
|
|
2831
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
2832
|
+
logger.exception("Query failed")
|
|
2833
|
+
raise typer.Exit(code=1)
|
|
2834
|
+
|
|
2835
|
+
|
|
2836
|
+
@query_app.command("history")
|
|
2837
|
+
def query_history(
|
|
2838
|
+
arn: str = typer.Argument(..., help="Resource ARN to track across snapshots"),
|
|
2839
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
2840
|
+
):
|
|
2841
|
+
"""Show snapshot history for a specific resource.
|
|
2842
|
+
|
|
2843
|
+
Tracks when a resource appeared in snapshots and whether its configuration changed.
|
|
2844
|
+
|
|
2845
|
+
Example:
|
|
2846
|
+
awsinv query history "arn:aws:s3:::my-bucket"
|
|
2847
|
+
"""
|
|
2848
|
+
from ..storage import Database, ResourceStore
|
|
2849
|
+
import json
|
|
2850
|
+
|
|
2851
|
+
setup_logging()
|
|
2852
|
+
|
|
2853
|
+
try:
|
|
2854
|
+
db = Database()
|
|
2855
|
+
db.ensure_schema()
|
|
2856
|
+
store = ResourceStore(db)
|
|
2857
|
+
|
|
2858
|
+
results = store.get_history(arn)
|
|
2859
|
+
|
|
2860
|
+
if not results:
|
|
2861
|
+
console.print(f"[yellow]No history found for ARN: {arn}[/yellow]")
|
|
2862
|
+
return
|
|
2863
|
+
|
|
2864
|
+
if format == "json":
|
|
2865
|
+
console.print(json.dumps(results, indent=2, default=str))
|
|
2866
|
+
else:
|
|
2867
|
+
console.print(f"\n[bold]History for:[/bold] {arn}\n")
|
|
2868
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
2869
|
+
table.add_column("Snapshot")
|
|
2870
|
+
table.add_column("Snapshot Date")
|
|
2871
|
+
table.add_column("Config Hash")
|
|
2872
|
+
table.add_column("Source")
|
|
2873
|
+
|
|
2874
|
+
prev_hash = None
|
|
2875
|
+
for r in results:
|
|
2876
|
+
config_hash = r["config_hash"][:12] if r["config_hash"] else "N/A"
|
|
2877
|
+
# Mark config changes
|
|
2878
|
+
if prev_hash and prev_hash != r["config_hash"]:
|
|
2879
|
+
config_hash = f"[yellow]{config_hash}[/yellow] (changed)"
|
|
2880
|
+
prev_hash = r["config_hash"]
|
|
2881
|
+
|
|
2882
|
+
table.add_row(
|
|
2883
|
+
r["snapshot_name"],
|
|
2884
|
+
str(r["snapshot_created_at"])[:19],
|
|
2885
|
+
config_hash,
|
|
2886
|
+
r["source"] or "direct_api",
|
|
2887
|
+
)
|
|
2888
|
+
console.print(table)
|
|
2889
|
+
|
|
2890
|
+
console.print(f"\n[dim]Found in {len(results)} snapshot(s)[/dim]")
|
|
2891
|
+
|
|
2892
|
+
except Exception as e:
|
|
2893
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
2894
|
+
logger.exception("Query failed")
|
|
2895
|
+
raise typer.Exit(code=1)
|
|
2896
|
+
|
|
2897
|
+
|
|
2898
|
+
@query_app.command("stats")
|
|
2899
|
+
def query_stats(
|
|
2900
|
+
snapshot: Optional[str] = typer.Option(None, "--snapshot", "-s", help="Specific snapshot (default: all)"),
|
|
2901
|
+
group_by: str = typer.Option("type", "--group-by", "-g", help="Group by: type, region, service, snapshot"),
|
|
2902
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
2903
|
+
):
|
|
2904
|
+
"""Show resource statistics and counts.
|
|
2905
|
+
|
|
2906
|
+
Examples:
|
|
2907
|
+
awsinv query stats
|
|
2908
|
+
awsinv query stats --group-by region
|
|
2909
|
+
awsinv query stats --snapshot baseline-2024 --group-by service
|
|
2910
|
+
"""
|
|
2911
|
+
from ..storage import Database, ResourceStore, SnapshotStore
|
|
2912
|
+
import json
|
|
2913
|
+
|
|
2914
|
+
setup_logging()
|
|
2915
|
+
|
|
2916
|
+
try:
|
|
2917
|
+
db = Database()
|
|
2918
|
+
db.ensure_schema()
|
|
2919
|
+
resource_store = ResourceStore(db)
|
|
2920
|
+
snapshot_store = SnapshotStore(db)
|
|
2921
|
+
|
|
2922
|
+
# Get overall stats
|
|
2923
|
+
total_snapshots = snapshot_store.get_snapshot_count()
|
|
2924
|
+
total_resources = snapshot_store.get_resource_count()
|
|
2925
|
+
|
|
2926
|
+
console.print(f"\n[bold]Database Statistics[/bold]")
|
|
2927
|
+
console.print(f"Total snapshots: [cyan]{total_snapshots}[/cyan]")
|
|
2928
|
+
console.print(f"Total resources: [cyan]{total_resources}[/cyan]")
|
|
2929
|
+
|
|
2930
|
+
if snapshot:
|
|
2931
|
+
console.print(f"Filtering by snapshot: [cyan]{snapshot}[/cyan]")
|
|
2932
|
+
console.print()
|
|
2933
|
+
|
|
2934
|
+
results = resource_store.get_stats(snapshot_name=snapshot, group_by=group_by)
|
|
2935
|
+
|
|
2936
|
+
if not results:
|
|
2937
|
+
console.print("[yellow]No statistics available[/yellow]")
|
|
2938
|
+
return
|
|
2939
|
+
|
|
2940
|
+
if format == "json":
|
|
2941
|
+
console.print(json.dumps(results, indent=2, default=str))
|
|
2942
|
+
else:
|
|
2943
|
+
group_label = {
|
|
2944
|
+
"type": "Resource Type",
|
|
2945
|
+
"region": "Region",
|
|
2946
|
+
"service": "Service",
|
|
2947
|
+
"snapshot": "Snapshot",
|
|
2948
|
+
}.get(group_by, "Group")
|
|
2949
|
+
|
|
2950
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
2951
|
+
table.add_column(group_label)
|
|
2952
|
+
table.add_column("Count", justify="right")
|
|
2953
|
+
|
|
2954
|
+
for r in results:
|
|
2955
|
+
table.add_row(r["group_key"] or "Unknown", str(r["count"]))
|
|
2956
|
+
console.print(table)
|
|
2957
|
+
|
|
2958
|
+
except Exception as e:
|
|
2959
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
2960
|
+
logger.exception("Query failed")
|
|
2961
|
+
raise typer.Exit(code=1)
|
|
2962
|
+
|
|
2963
|
+
|
|
2964
|
+
@query_app.command("diff")
|
|
2965
|
+
def query_diff(
|
|
2966
|
+
snapshot1: str = typer.Argument(..., help="First (older) snapshot name"),
|
|
2967
|
+
snapshot2: str = typer.Argument(..., help="Second (newer) snapshot name"),
|
|
2968
|
+
type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by resource type"),
|
|
2969
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json, summary"),
|
|
2970
|
+
):
|
|
2971
|
+
"""Compare resources between two snapshots.
|
|
2972
|
+
|
|
2973
|
+
Shows resources that were added, removed, or modified between snapshots.
|
|
2974
|
+
|
|
2975
|
+
Example:
|
|
2976
|
+
awsinv query diff baseline-2024 current-2024
|
|
2977
|
+
awsinv query diff snap1 snap2 --type s3:bucket
|
|
2978
|
+
"""
|
|
2979
|
+
from ..storage import Database, ResourceStore
|
|
2980
|
+
import json
|
|
2981
|
+
|
|
2982
|
+
setup_logging()
|
|
2983
|
+
|
|
2984
|
+
try:
|
|
2985
|
+
db = Database()
|
|
2986
|
+
db.ensure_schema()
|
|
2987
|
+
store = ResourceStore(db)
|
|
2988
|
+
|
|
2989
|
+
result = store.compare_snapshots(snapshot1, snapshot2)
|
|
2990
|
+
|
|
2991
|
+
# Filter by type if specified
|
|
2992
|
+
if type:
|
|
2993
|
+
result["added"] = [r for r in result["added"] if type.lower() in r["resource_type"].lower()]
|
|
2994
|
+
result["removed"] = [r for r in result["removed"] if type.lower() in r["resource_type"].lower()]
|
|
2995
|
+
result["modified"] = [r for r in result["modified"] if type.lower() in r["resource_type"].lower()]
|
|
2996
|
+
# Update counts
|
|
2997
|
+
result["summary"]["added_count"] = len(result["added"])
|
|
2998
|
+
result["summary"]["removed_count"] = len(result["removed"])
|
|
2999
|
+
result["summary"]["modified_count"] = len(result["modified"])
|
|
3000
|
+
|
|
3001
|
+
summary = result["summary"]
|
|
3002
|
+
|
|
3003
|
+
if format == "json":
|
|
3004
|
+
console.print(json.dumps(result, indent=2, default=str))
|
|
3005
|
+
return
|
|
3006
|
+
|
|
3007
|
+
# Print summary
|
|
3008
|
+
console.print(f"\n[bold]Comparing Snapshots[/bold]")
|
|
3009
|
+
console.print(f" {snapshot1} ({summary['snapshot1_count']} resources)")
|
|
3010
|
+
console.print(f" {snapshot2} ({summary['snapshot2_count']} resources)")
|
|
3011
|
+
console.print()
|
|
3012
|
+
|
|
3013
|
+
if format == "summary":
|
|
3014
|
+
console.print(f"[green]+ Added:[/green] {summary['added_count']}")
|
|
3015
|
+
console.print(f"[red]- Removed:[/red] {summary['removed_count']}")
|
|
3016
|
+
console.print(f"[yellow]~ Modified:[/yellow] {summary['modified_count']}")
|
|
3017
|
+
return
|
|
3018
|
+
|
|
3019
|
+
# Show details
|
|
3020
|
+
if result["added"]:
|
|
3021
|
+
console.print(f"\n[green][bold]Added ({len(result['added'])})[/bold][/green]")
|
|
3022
|
+
table = Table(show_header=True, header_style="green")
|
|
3023
|
+
table.add_column("ARN")
|
|
3024
|
+
table.add_column("Type")
|
|
3025
|
+
table.add_column("Region")
|
|
3026
|
+
for r in result["added"][:20]:
|
|
3027
|
+
table.add_row(r["arn"][-60:], r["resource_type"], r["region"])
|
|
3028
|
+
console.print(table)
|
|
3029
|
+
if len(result["added"]) > 20:
|
|
3030
|
+
console.print(f"[dim]...and {len(result['added']) - 20} more[/dim]")
|
|
3031
|
+
|
|
3032
|
+
if result["removed"]:
|
|
3033
|
+
console.print(f"\n[red][bold]Removed ({len(result['removed'])})[/bold][/red]")
|
|
3034
|
+
table = Table(show_header=True, header_style="red")
|
|
3035
|
+
table.add_column("ARN")
|
|
3036
|
+
table.add_column("Type")
|
|
3037
|
+
table.add_column("Region")
|
|
3038
|
+
for r in result["removed"][:20]:
|
|
3039
|
+
table.add_row(r["arn"][-60:], r["resource_type"], r["region"])
|
|
3040
|
+
console.print(table)
|
|
3041
|
+
if len(result["removed"]) > 20:
|
|
3042
|
+
console.print(f"[dim]...and {len(result['removed']) - 20} more[/dim]")
|
|
3043
|
+
|
|
3044
|
+
if result["modified"]:
|
|
3045
|
+
console.print(f"\n[yellow][bold]Modified ({len(result['modified'])})[/bold][/yellow]")
|
|
3046
|
+
table = Table(show_header=True, header_style="yellow")
|
|
3047
|
+
table.add_column("ARN")
|
|
3048
|
+
table.add_column("Type")
|
|
3049
|
+
table.add_column("Old Hash")
|
|
3050
|
+
table.add_column("New Hash")
|
|
3051
|
+
for r in result["modified"][:20]:
|
|
3052
|
+
table.add_row(
|
|
3053
|
+
r["arn"][-50:],
|
|
3054
|
+
r["resource_type"],
|
|
3055
|
+
r["old_hash"][:12],
|
|
3056
|
+
r["new_hash"][:12],
|
|
3057
|
+
)
|
|
3058
|
+
console.print(table)
|
|
3059
|
+
if len(result["modified"]) > 20:
|
|
3060
|
+
console.print(f"[dim]...and {len(result['modified']) - 20} more[/dim]")
|
|
3061
|
+
|
|
3062
|
+
if not result["added"] and not result["removed"] and not result["modified"]:
|
|
3063
|
+
console.print("[green]No differences found between snapshots[/green]")
|
|
3064
|
+
|
|
3065
|
+
except Exception as e:
|
|
3066
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3067
|
+
logger.exception("Query failed")
|
|
3068
|
+
raise typer.Exit(code=1)
|
|
3069
|
+
|
|
3070
|
+
|
|
3071
|
+
app.add_typer(query_app, name="query")
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
# =============================================================================
|
|
3075
|
+
# Group Commands
|
|
3076
|
+
# =============================================================================
|
|
3077
|
+
|
|
3078
|
+
group_app = typer.Typer(help="Manage resource groups for baseline comparison")
|
|
3079
|
+
|
|
3080
|
+
|
|
3081
|
+
@group_app.command("create")
|
|
3082
|
+
def group_create(
|
|
3083
|
+
name: str = typer.Argument(..., help="Name for the new group"),
|
|
3084
|
+
from_snapshot: Optional[str] = typer.Option(
|
|
3085
|
+
None, "--from-snapshot", "-s", help="Create group from resources in this snapshot"
|
|
3086
|
+
),
|
|
3087
|
+
description: str = typer.Option("", "--description", "-d", help="Group description"),
|
|
3088
|
+
type_filter: Optional[str] = typer.Option(
|
|
3089
|
+
None, "--type", "-t", help="Filter by resource type when creating from snapshot"
|
|
3090
|
+
),
|
|
3091
|
+
region_filter: Optional[str] = typer.Option(
|
|
3092
|
+
None, "--region", "-r", help="Filter by region when creating from snapshot"
|
|
3093
|
+
),
|
|
3094
|
+
):
|
|
3095
|
+
"""Create a new resource group.
|
|
3096
|
+
|
|
3097
|
+
Groups define a set of resources (by name + type) that should exist in every account.
|
|
3098
|
+
Use --from-snapshot to populate the group from an existing snapshot.
|
|
3099
|
+
|
|
3100
|
+
Examples:
|
|
3101
|
+
# Create empty group
|
|
3102
|
+
awsinv group create baseline --description "Production baseline resources"
|
|
3103
|
+
|
|
3104
|
+
# Create from snapshot
|
|
3105
|
+
awsinv group create baseline --from-snapshot "empty-account-2026-01"
|
|
3106
|
+
|
|
3107
|
+
# Create with filters
|
|
3108
|
+
awsinv group create iam-baseline --from-snapshot snap1 --type iam
|
|
3109
|
+
"""
|
|
3110
|
+
from ..storage import Database, GroupStore
|
|
3111
|
+
|
|
3112
|
+
setup_logging()
|
|
3113
|
+
|
|
3114
|
+
try:
|
|
3115
|
+
db = Database()
|
|
3116
|
+
db.ensure_schema()
|
|
3117
|
+
store = GroupStore(db)
|
|
3118
|
+
|
|
3119
|
+
if store.exists(name):
|
|
3120
|
+
console.print(f"[red]Error: Group '{name}' already exists[/red]")
|
|
3121
|
+
raise typer.Exit(code=1)
|
|
3122
|
+
|
|
3123
|
+
if from_snapshot:
|
|
3124
|
+
# Create from snapshot
|
|
3125
|
+
count = store.create_from_snapshot(
|
|
3126
|
+
group_name=name,
|
|
3127
|
+
snapshot_name=from_snapshot,
|
|
3128
|
+
description=description,
|
|
3129
|
+
type_filter=type_filter,
|
|
3130
|
+
region_filter=region_filter,
|
|
3131
|
+
)
|
|
3132
|
+
console.print(f"[green]✓ Created group '{name}' with {count} resources from snapshot '{from_snapshot}'[/green]")
|
|
3133
|
+
else:
|
|
3134
|
+
# Create empty group
|
|
3135
|
+
from ..models.group import ResourceGroup
|
|
3136
|
+
|
|
3137
|
+
group = ResourceGroup(name=name, description=description)
|
|
3138
|
+
store.save(group)
|
|
3139
|
+
console.print(f"[green]✓ Created empty group '{name}'[/green]")
|
|
3140
|
+
|
|
3141
|
+
except ValueError as e:
|
|
3142
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3143
|
+
raise typer.Exit(code=1)
|
|
3144
|
+
except Exception as e:
|
|
3145
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3146
|
+
logger.exception("Group creation failed")
|
|
3147
|
+
raise typer.Exit(code=1)
|
|
3148
|
+
|
|
3149
|
+
|
|
3150
|
+
@group_app.command("list")
|
|
3151
|
+
def group_list(
|
|
3152
|
+
format: str = typer.Option("table", "--format", "-f", help="Output format: table, json"),
|
|
3153
|
+
):
|
|
3154
|
+
"""List all resource groups.
|
|
3155
|
+
|
|
3156
|
+
Examples:
|
|
3157
|
+
awsinv group list
|
|
3158
|
+
awsinv group list --format json
|
|
3159
|
+
"""
|
|
3160
|
+
from ..storage import Database, GroupStore
|
|
3161
|
+
import json
|
|
3162
|
+
|
|
3163
|
+
setup_logging()
|
|
3164
|
+
|
|
3165
|
+
try:
|
|
3166
|
+
db = Database()
|
|
3167
|
+
db.ensure_schema()
|
|
3168
|
+
store = GroupStore(db)
|
|
3169
|
+
|
|
3170
|
+
groups = store.list_all()
|
|
3171
|
+
|
|
3172
|
+
if not groups:
|
|
3173
|
+
console.print("[yellow]No groups found. Create one with 'awsinv group create'[/yellow]")
|
|
3174
|
+
return
|
|
3175
|
+
|
|
3176
|
+
if format == "json":
|
|
3177
|
+
console.print(json.dumps(groups, indent=2, default=str))
|
|
3178
|
+
else:
|
|
3179
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
3180
|
+
table.add_column("Name", style="cyan")
|
|
3181
|
+
table.add_column("Description")
|
|
3182
|
+
table.add_column("Resources", justify="right")
|
|
3183
|
+
table.add_column("Source Snapshot")
|
|
3184
|
+
table.add_column("Favorite", justify="center")
|
|
3185
|
+
|
|
3186
|
+
for g in groups:
|
|
3187
|
+
table.add_row(
|
|
3188
|
+
g["name"],
|
|
3189
|
+
g["description"][:40] + "..." if len(g["description"]) > 40 else g["description"],
|
|
3190
|
+
str(g["resource_count"]),
|
|
3191
|
+
g["source_snapshot"] or "-",
|
|
3192
|
+
"★" if g["is_favorite"] else "",
|
|
3193
|
+
)
|
|
3194
|
+
|
|
3195
|
+
console.print(table)
|
|
3196
|
+
|
|
3197
|
+
except Exception as e:
|
|
3198
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3199
|
+
logger.exception("Failed to list groups")
|
|
3200
|
+
raise typer.Exit(code=1)
|
|
3201
|
+
|
|
3202
|
+
|
|
3203
|
+
@group_app.command("show")
|
|
3204
|
+
def group_show(
|
|
3205
|
+
name: str = typer.Argument(..., help="Group name"),
|
|
3206
|
+
limit: int = typer.Option(50, "--limit", "-l", help="Maximum members to display"),
|
|
3207
|
+
):
|
|
3208
|
+
"""Show details of a resource group including its members.
|
|
3209
|
+
|
|
3210
|
+
Examples:
|
|
3211
|
+
awsinv group show baseline
|
|
3212
|
+
awsinv group show baseline --limit 100
|
|
3213
|
+
"""
|
|
3214
|
+
from ..storage import Database, GroupStore
|
|
3215
|
+
|
|
3216
|
+
setup_logging()
|
|
3217
|
+
|
|
3218
|
+
try:
|
|
3219
|
+
db = Database()
|
|
3220
|
+
db.ensure_schema()
|
|
3221
|
+
store = GroupStore(db)
|
|
3222
|
+
|
|
3223
|
+
group = store.load(name)
|
|
3224
|
+
if not group:
|
|
3225
|
+
console.print(f"[red]Error: Group '{name}' not found[/red]")
|
|
3226
|
+
raise typer.Exit(code=1)
|
|
3227
|
+
|
|
3228
|
+
# Show group info
|
|
3229
|
+
console.print(
|
|
3230
|
+
Panel(
|
|
3231
|
+
f"[bold]{group.name}[/bold]\n\n"
|
|
3232
|
+
f"[dim]Description:[/dim] {group.description or '(none)'}\n"
|
|
3233
|
+
f"[dim]Source Snapshot:[/dim] {group.source_snapshot or '(none)'}\n"
|
|
3234
|
+
f"[dim]Resource Count:[/dim] {group.resource_count}\n"
|
|
3235
|
+
f"[dim]Created:[/dim] {group.created_at}\n"
|
|
3236
|
+
f"[dim]Last Updated:[/dim] {group.last_updated}",
|
|
3237
|
+
title="Group Details",
|
|
3238
|
+
border_style="blue",
|
|
3239
|
+
)
|
|
3240
|
+
)
|
|
3241
|
+
|
|
3242
|
+
# Show members
|
|
3243
|
+
if group.members:
|
|
3244
|
+
console.print(f"\n[bold]Members[/bold] (showing first {min(limit, len(group.members))} of {len(group.members)}):")
|
|
3245
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
3246
|
+
table.add_column("Resource Name", style="cyan")
|
|
3247
|
+
table.add_column("Type")
|
|
3248
|
+
table.add_column("Original ARN", style="dim")
|
|
3249
|
+
|
|
3250
|
+
for member in group.members[:limit]:
|
|
3251
|
+
table.add_row(
|
|
3252
|
+
member.resource_name,
|
|
3253
|
+
member.resource_type,
|
|
3254
|
+
member.original_arn[:60] + "..." if member.original_arn and len(member.original_arn) > 60 else (member.original_arn or "-"),
|
|
3255
|
+
)
|
|
3256
|
+
|
|
3257
|
+
console.print(table)
|
|
3258
|
+
else:
|
|
3259
|
+
console.print("\n[yellow]Group has no members[/yellow]")
|
|
3260
|
+
|
|
3261
|
+
except Exception as e:
|
|
3262
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3263
|
+
logger.exception("Failed to show group")
|
|
3264
|
+
raise typer.Exit(code=1)
|
|
3265
|
+
|
|
3266
|
+
|
|
3267
|
+
@group_app.command("delete")
|
|
3268
|
+
def group_delete(
|
|
3269
|
+
name: str = typer.Argument(..., help="Group name to delete"),
|
|
3270
|
+
confirm: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
|
3271
|
+
):
|
|
3272
|
+
"""Delete a resource group.
|
|
3273
|
+
|
|
3274
|
+
Examples:
|
|
3275
|
+
awsinv group delete baseline
|
|
3276
|
+
awsinv group delete baseline --yes
|
|
3277
|
+
"""
|
|
3278
|
+
from ..storage import Database, GroupStore
|
|
3279
|
+
|
|
3280
|
+
setup_logging()
|
|
3281
|
+
|
|
3282
|
+
try:
|
|
3283
|
+
db = Database()
|
|
3284
|
+
db.ensure_schema()
|
|
3285
|
+
store = GroupStore(db)
|
|
3286
|
+
|
|
3287
|
+
if not store.exists(name):
|
|
3288
|
+
console.print(f"[red]Error: Group '{name}' not found[/red]")
|
|
3289
|
+
raise typer.Exit(code=1)
|
|
3290
|
+
|
|
3291
|
+
if not confirm:
|
|
3292
|
+
confirm_input = typer.confirm(f"Are you sure you want to delete group '{name}'?")
|
|
3293
|
+
if not confirm_input:
|
|
3294
|
+
console.print("[yellow]Cancelled[/yellow]")
|
|
3295
|
+
raise typer.Exit(code=0)
|
|
3296
|
+
|
|
3297
|
+
store.delete(name)
|
|
3298
|
+
console.print(f"[green]✓ Deleted group '{name}'[/green]")
|
|
3299
|
+
|
|
3300
|
+
except typer.Exit:
|
|
3301
|
+
raise
|
|
3302
|
+
except Exception as e:
|
|
3303
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3304
|
+
logger.exception("Failed to delete group")
|
|
3305
|
+
raise typer.Exit(code=1)
|
|
3306
|
+
|
|
3307
|
+
|
|
3308
|
+
@group_app.command("compare")
|
|
3309
|
+
def group_compare(
|
|
3310
|
+
name: str = typer.Argument(..., help="Group name"),
|
|
3311
|
+
snapshot: str = typer.Option(..., "--snapshot", "-s", help="Snapshot to compare against"),
|
|
3312
|
+
format: str = typer.Option("summary", "--format", "-f", help="Output format: summary, table, json"),
|
|
3313
|
+
show_details: bool = typer.Option(False, "--details", "-d", help="Show individual resource details"),
|
|
3314
|
+
):
|
|
3315
|
+
"""Compare a snapshot against a resource group.
|
|
3316
|
+
|
|
3317
|
+
Shows which resources from the group are present in the snapshot,
|
|
3318
|
+
which are missing, and which resources in the snapshot are not in the group.
|
|
3319
|
+
|
|
3320
|
+
Examples:
|
|
3321
|
+
awsinv group compare baseline --snapshot prod-account-2026-01
|
|
3322
|
+
awsinv group compare baseline -s prod-account --format json
|
|
3323
|
+
awsinv group compare baseline -s prod-account --details
|
|
3324
|
+
"""
|
|
3325
|
+
from ..storage import Database, GroupStore
|
|
3326
|
+
import json
|
|
3327
|
+
|
|
3328
|
+
setup_logging()
|
|
3329
|
+
|
|
3330
|
+
try:
|
|
3331
|
+
db = Database()
|
|
3332
|
+
db.ensure_schema()
|
|
3333
|
+
store = GroupStore(db)
|
|
3334
|
+
|
|
3335
|
+
result = store.compare_snapshot(name, snapshot)
|
|
3336
|
+
|
|
3337
|
+
if format == "json":
|
|
3338
|
+
console.print(json.dumps(result, indent=2, default=str))
|
|
3339
|
+
return
|
|
3340
|
+
|
|
3341
|
+
# Summary output
|
|
3342
|
+
console.print(
|
|
3343
|
+
Panel(
|
|
3344
|
+
f"[bold]Comparing snapshot '{snapshot}' against group '{name}'[/bold]\n\n"
|
|
3345
|
+
f"[dim]Total in group:[/dim] {result['total_in_group']}\n"
|
|
3346
|
+
f"[dim]Total in snapshot:[/dim] {result['total_in_snapshot']}\n\n"
|
|
3347
|
+
f"[green]✓ Matched:[/green] {result['matched']}\n"
|
|
3348
|
+
f"[red]✗ Missing from snapshot:[/red] {result['missing_from_snapshot']}\n"
|
|
3349
|
+
f"[yellow]+ Not in group:[/yellow] {result['not_in_group']}",
|
|
3350
|
+
title="Comparison Results",
|
|
3351
|
+
border_style="blue",
|
|
3352
|
+
)
|
|
3353
|
+
)
|
|
3354
|
+
|
|
3355
|
+
if show_details or format == "table":
|
|
3356
|
+
# Show missing resources
|
|
3357
|
+
if result["resources"]["missing"]:
|
|
3358
|
+
console.print("\n[red bold]Missing from snapshot:[/red bold]")
|
|
3359
|
+
table = Table(show_header=True, header_style="bold red")
|
|
3360
|
+
table.add_column("Resource Name")
|
|
3361
|
+
table.add_column("Type")
|
|
3362
|
+
for r in result["resources"]["missing"][:25]:
|
|
3363
|
+
table.add_row(r["name"], r["resource_type"])
|
|
3364
|
+
console.print(table)
|
|
3365
|
+
if len(result["resources"]["missing"]) > 25:
|
|
3366
|
+
console.print(f"[dim]... and {len(result['resources']['missing']) - 25} more[/dim]")
|
|
3367
|
+
|
|
3368
|
+
# Show extra resources
|
|
3369
|
+
if result["resources"]["extra"]:
|
|
3370
|
+
console.print("\n[yellow bold]Not in group (extra):[/yellow bold]")
|
|
3371
|
+
table = Table(show_header=True, header_style="bold yellow")
|
|
3372
|
+
table.add_column("Resource Name")
|
|
3373
|
+
table.add_column("Type")
|
|
3374
|
+
table.add_column("ARN", style="dim")
|
|
3375
|
+
for r in result["resources"]["extra"][:25]:
|
|
3376
|
+
table.add_row(
|
|
3377
|
+
r["name"],
|
|
3378
|
+
r["resource_type"],
|
|
3379
|
+
r["arn"][:50] + "..." if len(r["arn"]) > 50 else r["arn"],
|
|
3380
|
+
)
|
|
3381
|
+
console.print(table)
|
|
3382
|
+
if len(result["resources"]["extra"]) > 25:
|
|
3383
|
+
console.print(f"[dim]... and {len(result['resources']['extra']) - 25} more[/dim]")
|
|
3384
|
+
|
|
3385
|
+
except ValueError as e:
|
|
3386
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3387
|
+
raise typer.Exit(code=1)
|
|
3388
|
+
except Exception as e:
|
|
3389
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3390
|
+
logger.exception("Comparison failed")
|
|
3391
|
+
raise typer.Exit(code=1)
|
|
3392
|
+
|
|
3393
|
+
|
|
3394
|
+
@group_app.command("add")
|
|
3395
|
+
def group_add(
|
|
3396
|
+
name: str = typer.Argument(..., help="Group name"),
|
|
3397
|
+
resource: str = typer.Option(..., "--resource", "-r", help="Resource to add as 'name:type' (e.g., 'my-bucket:s3:bucket')"),
|
|
3398
|
+
):
|
|
3399
|
+
"""Add a resource to a group manually.
|
|
3400
|
+
|
|
3401
|
+
Resources are specified as 'name:type' where type is the AWS resource type.
|
|
3402
|
+
|
|
3403
|
+
Examples:
|
|
3404
|
+
awsinv group add baseline --resource "my-bucket:s3:bucket"
|
|
3405
|
+
awsinv group add iam-baseline --resource "AdminRole:iam:role"
|
|
3406
|
+
"""
|
|
3407
|
+
from ..storage import Database, GroupStore
|
|
3408
|
+
from ..models.group import GroupMember
|
|
3409
|
+
|
|
3410
|
+
setup_logging()
|
|
3411
|
+
|
|
3412
|
+
try:
|
|
3413
|
+
# Parse resource string
|
|
3414
|
+
parts = resource.split(":", 1)
|
|
3415
|
+
if len(parts) != 2:
|
|
3416
|
+
console.print("[red]Error: Resource must be specified as 'name:type' (e.g., 'my-bucket:s3:bucket')[/red]")
|
|
3417
|
+
raise typer.Exit(code=1)
|
|
3418
|
+
|
|
3419
|
+
resource_name, resource_type = parts
|
|
3420
|
+
|
|
3421
|
+
db = Database()
|
|
3422
|
+
db.ensure_schema()
|
|
3423
|
+
store = GroupStore(db)
|
|
3424
|
+
|
|
3425
|
+
if not store.exists(name):
|
|
3426
|
+
console.print(f"[red]Error: Group '{name}' not found[/red]")
|
|
3427
|
+
raise typer.Exit(code=1)
|
|
3428
|
+
|
|
3429
|
+
member = GroupMember(resource_name=resource_name, resource_type=resource_type)
|
|
3430
|
+
added = store.add_members(name, [member])
|
|
3431
|
+
|
|
3432
|
+
if added > 0:
|
|
3433
|
+
console.print(f"[green]✓ Added '{resource_name}' ({resource_type}) to group '{name}'[/green]")
|
|
3434
|
+
else:
|
|
3435
|
+
console.print(f"[yellow]Resource already exists in group[/yellow]")
|
|
3436
|
+
|
|
3437
|
+
except typer.Exit:
|
|
3438
|
+
raise
|
|
3439
|
+
except Exception as e:
|
|
3440
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3441
|
+
logger.exception("Failed to add resource to group")
|
|
3442
|
+
raise typer.Exit(code=1)
|
|
3443
|
+
|
|
3444
|
+
|
|
3445
|
+
@group_app.command("remove")
|
|
3446
|
+
def group_remove(
|
|
3447
|
+
name: str = typer.Argument(..., help="Group name"),
|
|
3448
|
+
resource: str = typer.Option(..., "--resource", "-r", help="Resource to remove as 'name:type'"),
|
|
3449
|
+
):
|
|
3450
|
+
"""Remove a resource from a group.
|
|
3451
|
+
|
|
3452
|
+
Examples:
|
|
3453
|
+
awsinv group remove baseline --resource "my-bucket:s3:bucket"
|
|
3454
|
+
"""
|
|
3455
|
+
from ..storage import Database, GroupStore
|
|
3456
|
+
|
|
3457
|
+
setup_logging()
|
|
3458
|
+
|
|
3459
|
+
try:
|
|
3460
|
+
# Parse resource string
|
|
3461
|
+
parts = resource.split(":", 1)
|
|
3462
|
+
if len(parts) != 2:
|
|
3463
|
+
console.print("[red]Error: Resource must be specified as 'name:type'[/red]")
|
|
3464
|
+
raise typer.Exit(code=1)
|
|
3465
|
+
|
|
3466
|
+
resource_name, resource_type = parts
|
|
3467
|
+
|
|
3468
|
+
db = Database()
|
|
3469
|
+
db.ensure_schema()
|
|
3470
|
+
store = GroupStore(db)
|
|
3471
|
+
|
|
3472
|
+
if not store.exists(name):
|
|
3473
|
+
console.print(f"[red]Error: Group '{name}' not found[/red]")
|
|
3474
|
+
raise typer.Exit(code=1)
|
|
3475
|
+
|
|
3476
|
+
removed = store.remove_member(name, resource_name, resource_type)
|
|
3477
|
+
|
|
3478
|
+
if removed:
|
|
3479
|
+
console.print(f"[green]✓ Removed '{resource_name}' ({resource_type}) from group '{name}'[/green]")
|
|
3480
|
+
else:
|
|
3481
|
+
console.print(f"[yellow]Resource not found in group[/yellow]")
|
|
3482
|
+
|
|
3483
|
+
except typer.Exit:
|
|
3484
|
+
raise
|
|
3485
|
+
except Exception as e:
|
|
3486
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3487
|
+
logger.exception("Failed to remove resource from group")
|
|
3488
|
+
raise typer.Exit(code=1)
|
|
3489
|
+
|
|
3490
|
+
|
|
3491
|
+
@group_app.command("export")
|
|
3492
|
+
def group_export(
|
|
3493
|
+
name: str = typer.Argument(..., help="Group name"),
|
|
3494
|
+
format: str = typer.Option("yaml", "--format", "-f", help="Output format: yaml, csv, json"),
|
|
3495
|
+
output: Optional[str] = typer.Option(None, "--output", "-o", help="Output file (stdout if not specified)"),
|
|
3496
|
+
):
|
|
3497
|
+
"""Export a group definition.
|
|
3498
|
+
|
|
3499
|
+
Examples:
|
|
3500
|
+
awsinv group export baseline --format yaml
|
|
3501
|
+
awsinv group export baseline --format csv --output baseline.csv
|
|
3502
|
+
"""
|
|
3503
|
+
from ..storage import Database, GroupStore
|
|
3504
|
+
import json
|
|
3505
|
+
import yaml
|
|
3506
|
+
import csv
|
|
3507
|
+
import sys
|
|
3508
|
+
|
|
3509
|
+
setup_logging()
|
|
3510
|
+
|
|
3511
|
+
try:
|
|
3512
|
+
db = Database()
|
|
3513
|
+
db.ensure_schema()
|
|
3514
|
+
store = GroupStore(db)
|
|
3515
|
+
|
|
3516
|
+
group = store.load(name)
|
|
3517
|
+
if not group:
|
|
3518
|
+
console.print(f"[red]Error: Group '{name}' not found[/red]")
|
|
3519
|
+
raise typer.Exit(code=1)
|
|
3520
|
+
|
|
3521
|
+
# Prepare output
|
|
3522
|
+
if format == "json":
|
|
3523
|
+
content = json.dumps(group.to_dict(), indent=2, default=str)
|
|
3524
|
+
elif format == "csv":
|
|
3525
|
+
import io
|
|
3526
|
+
|
|
3527
|
+
buffer = io.StringIO()
|
|
3528
|
+
writer = csv.writer(buffer)
|
|
3529
|
+
writer.writerow(["resource_name", "resource_type", "original_arn"])
|
|
3530
|
+
for member in group.members:
|
|
3531
|
+
writer.writerow([member.resource_name, member.resource_type, member.original_arn or ""])
|
|
3532
|
+
content = buffer.getvalue()
|
|
3533
|
+
else: # yaml
|
|
3534
|
+
content = yaml.dump(group.to_dict(), default_flow_style=False, sort_keys=False)
|
|
3535
|
+
|
|
3536
|
+
if output:
|
|
3537
|
+
with open(output, "w") as f:
|
|
3538
|
+
f.write(content)
|
|
3539
|
+
console.print(f"[green]✓ Exported group '{name}' to {output}[/green]")
|
|
3540
|
+
else:
|
|
3541
|
+
console.print(content)
|
|
3542
|
+
|
|
3543
|
+
except typer.Exit:
|
|
3544
|
+
raise
|
|
3545
|
+
except Exception as e:
|
|
3546
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
3547
|
+
logger.exception("Export failed")
|
|
3548
|
+
raise typer.Exit(code=1)
|
|
3549
|
+
|
|
3550
|
+
|
|
3551
|
+
app.add_typer(group_app, name="group")
|
|
3552
|
+
|
|
3553
|
+
|
|
3554
|
+
# =============================================================================
|
|
3555
|
+
# Serve Command (Web UI)
|
|
3556
|
+
# =============================================================================
|
|
3557
|
+
|
|
3558
|
+
|
|
3559
|
+
@app.command()
|
|
3560
|
+
def serve(
|
|
3561
|
+
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
|
|
3562
|
+
port: int = typer.Option(8080, "--port", "-p", help="Port to bind to"),
|
|
3563
|
+
open_browser: bool = typer.Option(True, "--open/--no-open", help="Open browser on startup"),
|
|
3564
|
+
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload for development"),
|
|
3565
|
+
):
|
|
3566
|
+
"""Launch web-based inventory browser.
|
|
3567
|
+
|
|
3568
|
+
Starts a local web server with a beautiful UI for browsing snapshots,
|
|
3569
|
+
exploring resources, running queries, and managing cleanup operations.
|
|
3570
|
+
"""
|
|
3571
|
+
try:
|
|
3572
|
+
import uvicorn
|
|
3573
|
+
except ImportError:
|
|
3574
|
+
console.print(
|
|
3575
|
+
"[red]Web dependencies not installed.[/red]\n"
|
|
3576
|
+
"Install with: [cyan]pip install aws-inventory-manager[web][/cyan]"
|
|
3577
|
+
)
|
|
3578
|
+
raise typer.Exit(code=1)
|
|
3579
|
+
|
|
3580
|
+
from ..web.app import create_app
|
|
3581
|
+
|
|
3582
|
+
# Load config for storage path
|
|
3583
|
+
global config
|
|
3584
|
+
if config is None:
|
|
3585
|
+
config = Config.load()
|
|
3586
|
+
|
|
3587
|
+
console.print(
|
|
3588
|
+
Panel.fit(
|
|
3589
|
+
f"[cyan bold]AWS Inventory Browser[/cyan bold]\n\n"
|
|
3590
|
+
f"[green]Server:[/green] http://{host}:{port}\n"
|
|
3591
|
+
f"[dim]Press Ctrl+C to stop[/dim]",
|
|
3592
|
+
title="Starting Web Server",
|
|
3593
|
+
border_style="blue",
|
|
3594
|
+
)
|
|
3595
|
+
)
|
|
3596
|
+
|
|
3597
|
+
if open_browser:
|
|
3598
|
+
import threading
|
|
3599
|
+
import time
|
|
3600
|
+
import webbrowser
|
|
3601
|
+
|
|
3602
|
+
def open_delayed():
|
|
3603
|
+
time.sleep(1.5)
|
|
3604
|
+
webbrowser.open(f"http://{host}:{port}")
|
|
3605
|
+
|
|
3606
|
+
threading.Thread(target=open_delayed, daemon=True).start()
|
|
3607
|
+
|
|
3608
|
+
# Create app with storage path from config
|
|
3609
|
+
app_instance = create_app(config.storage_path)
|
|
3610
|
+
|
|
3611
|
+
uvicorn.run(
|
|
3612
|
+
app_instance,
|
|
3613
|
+
host=host,
|
|
3614
|
+
port=port,
|
|
3615
|
+
reload=reload,
|
|
3616
|
+
log_level="info",
|
|
3617
|
+
)
|
|
3618
|
+
|
|
3619
|
+
|
|
3620
|
+
def cli_main():
|
|
3621
|
+
"""Entry point for console script."""
|
|
3622
|
+
app()
|
|
3623
|
+
|
|
3624
|
+
|
|
3625
|
+
if __name__ == "__main__":
|
|
3626
|
+
cli_main()
|