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
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""Resource API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import io
|
|
7
|
+
import json
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
from urllib.parse import unquote
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
14
|
+
from fastapi.responses import StreamingResponse
|
|
15
|
+
|
|
16
|
+
from ...dependencies import get_database, get_resource_store, get_snapshot_store
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/resources")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("")
|
|
22
|
+
async def search_resources(
|
|
23
|
+
q: Optional[str] = Query(None, description="Search query (ARN pattern)"),
|
|
24
|
+
type: Optional[str] = Query(None, description="Filter by resource type"),
|
|
25
|
+
region: Optional[str] = Query(None, description="Filter by region"),
|
|
26
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
27
|
+
tag_key: Optional[str] = Query(None, description="Filter by tag key"),
|
|
28
|
+
tag_value: Optional[str] = Query(None, description="Filter by tag value"),
|
|
29
|
+
include_tags: bool = Query(False, description="Include tags in response"),
|
|
30
|
+
limit: int = Query(50, le=500),
|
|
31
|
+
offset: int = Query(0, ge=0),
|
|
32
|
+
):
|
|
33
|
+
"""Search resources across snapshots."""
|
|
34
|
+
store = get_resource_store()
|
|
35
|
+
|
|
36
|
+
resources = store.search(
|
|
37
|
+
arn_pattern=q,
|
|
38
|
+
resource_type=type,
|
|
39
|
+
region=region,
|
|
40
|
+
snapshot_name=snapshot,
|
|
41
|
+
tag_key=tag_key,
|
|
42
|
+
tag_value=tag_value,
|
|
43
|
+
limit=limit,
|
|
44
|
+
offset=offset,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Optionally include tags for each resource
|
|
48
|
+
if include_tags:
|
|
49
|
+
enriched_resources = []
|
|
50
|
+
for resource in resources:
|
|
51
|
+
r = dict(resource)
|
|
52
|
+
tags = store.get_tags_for_resource(
|
|
53
|
+
resource["arn"],
|
|
54
|
+
snapshot_name=resource.get("snapshot_name"),
|
|
55
|
+
)
|
|
56
|
+
r["tags"] = tags
|
|
57
|
+
enriched_resources.append(r)
|
|
58
|
+
resources = enriched_resources
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
"count": len(resources),
|
|
62
|
+
"limit": limit,
|
|
63
|
+
"offset": offset,
|
|
64
|
+
"resources": resources,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@router.get("/types")
|
|
69
|
+
async def get_resource_types(
|
|
70
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
71
|
+
):
|
|
72
|
+
"""Get unique resource types."""
|
|
73
|
+
store = get_resource_store()
|
|
74
|
+
types = store.get_unique_resource_types(snapshot_name=snapshot)
|
|
75
|
+
return {"types": types}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@router.get("/regions")
|
|
79
|
+
async def get_regions(
|
|
80
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
81
|
+
):
|
|
82
|
+
"""Get unique regions."""
|
|
83
|
+
store = get_resource_store()
|
|
84
|
+
regions = store.get_unique_regions(snapshot_name=snapshot)
|
|
85
|
+
return {"regions": regions}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/tags/keys")
|
|
89
|
+
async def get_tag_keys(
|
|
90
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
91
|
+
):
|
|
92
|
+
"""Get unique tag keys."""
|
|
93
|
+
db = get_database()
|
|
94
|
+
|
|
95
|
+
if snapshot:
|
|
96
|
+
query = """
|
|
97
|
+
SELECT DISTINCT t.key
|
|
98
|
+
FROM resource_tags t
|
|
99
|
+
JOIN resources r ON t.resource_id = r.id
|
|
100
|
+
JOIN snapshots s ON r.snapshot_id = s.id
|
|
101
|
+
WHERE s.name = ?
|
|
102
|
+
ORDER BY t.key
|
|
103
|
+
"""
|
|
104
|
+
rows = db.fetchall(query, (snapshot,))
|
|
105
|
+
else:
|
|
106
|
+
query = """
|
|
107
|
+
SELECT DISTINCT key FROM resource_tags ORDER BY key
|
|
108
|
+
"""
|
|
109
|
+
rows = db.fetchall(query)
|
|
110
|
+
|
|
111
|
+
return {"keys": [row["key"] for row in rows]}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@router.get("/tags/values")
|
|
115
|
+
async def get_tag_values(
|
|
116
|
+
key: str = Query(..., description="Tag key to get values for"),
|
|
117
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
118
|
+
):
|
|
119
|
+
"""Get unique values for a tag key."""
|
|
120
|
+
db = get_database()
|
|
121
|
+
|
|
122
|
+
if snapshot:
|
|
123
|
+
query = """
|
|
124
|
+
SELECT DISTINCT t.value
|
|
125
|
+
FROM resource_tags t
|
|
126
|
+
JOIN resources r ON t.resource_id = r.id
|
|
127
|
+
JOIN snapshots s ON r.snapshot_id = s.id
|
|
128
|
+
WHERE t.key = ? AND s.name = ?
|
|
129
|
+
ORDER BY t.value
|
|
130
|
+
"""
|
|
131
|
+
rows = db.fetchall(query, (key, snapshot))
|
|
132
|
+
else:
|
|
133
|
+
query = """
|
|
134
|
+
SELECT DISTINCT value FROM resource_tags WHERE key = ? ORDER BY value
|
|
135
|
+
"""
|
|
136
|
+
rows = db.fetchall(query, (key,))
|
|
137
|
+
|
|
138
|
+
return {"key": key, "values": [row["value"] for row in rows]}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/stats")
|
|
142
|
+
async def get_stats(
|
|
143
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
144
|
+
group_by: str = Query("type", description="Group by: type, region, service"),
|
|
145
|
+
):
|
|
146
|
+
"""Get resource statistics."""
|
|
147
|
+
store = get_resource_store()
|
|
148
|
+
stats = store.get_stats(snapshot_name=snapshot, group_by=group_by)
|
|
149
|
+
return {"group_by": group_by, "stats": stats}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@router.get("/by-arn/{arn:path}")
|
|
153
|
+
async def get_resource_by_arn(arn: str):
|
|
154
|
+
"""Get resource details by ARN."""
|
|
155
|
+
# URL decode the ARN
|
|
156
|
+
decoded_arn = unquote(arn)
|
|
157
|
+
|
|
158
|
+
store = get_resource_store()
|
|
159
|
+
resources = store.search(arn_pattern=decoded_arn, limit=1)
|
|
160
|
+
|
|
161
|
+
if not resources:
|
|
162
|
+
raise HTTPException(status_code=404, detail=f"Resource not found: {decoded_arn}")
|
|
163
|
+
|
|
164
|
+
return resources[0]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@router.get("/history/{arn:path}")
|
|
168
|
+
async def get_resource_history(arn: str):
|
|
169
|
+
"""Get resource history across snapshots."""
|
|
170
|
+
decoded_arn = unquote(arn)
|
|
171
|
+
|
|
172
|
+
store = get_resource_store()
|
|
173
|
+
history = store.get_history(decoded_arn)
|
|
174
|
+
|
|
175
|
+
if not history:
|
|
176
|
+
raise HTTPException(status_code=404, detail=f"No history found for: {decoded_arn}")
|
|
177
|
+
|
|
178
|
+
return {"arn": decoded_arn, "snapshots": history}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/diff")
|
|
182
|
+
async def compare_snapshots(
|
|
183
|
+
snapshot1: str = Query(..., description="First snapshot name"),
|
|
184
|
+
snapshot2: str = Query(..., description="Second snapshot name"),
|
|
185
|
+
type: Optional[str] = Query(None, description="Filter by resource type"),
|
|
186
|
+
):
|
|
187
|
+
"""Compare resources between two snapshots."""
|
|
188
|
+
snapshot_store = get_snapshot_store()
|
|
189
|
+
|
|
190
|
+
# Validate snapshots exist
|
|
191
|
+
if not snapshot_store.exists(snapshot1):
|
|
192
|
+
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot1}' not found")
|
|
193
|
+
if not snapshot_store.exists(snapshot2):
|
|
194
|
+
raise HTTPException(status_code=404, detail=f"Snapshot '{snapshot2}' not found")
|
|
195
|
+
|
|
196
|
+
resource_store = get_resource_store()
|
|
197
|
+
diff = resource_store.compare_snapshots(snapshot1, snapshot2)
|
|
198
|
+
|
|
199
|
+
# Filter by type if specified
|
|
200
|
+
if type:
|
|
201
|
+
diff["added"] = [r for r in diff["added"] if r.get("resource_type") == type]
|
|
202
|
+
diff["removed"] = [r for r in diff["removed"] if r.get("resource_type") == type]
|
|
203
|
+
diff["modified"] = [r for r in diff["modified"] if r.get("resource_type") == type]
|
|
204
|
+
|
|
205
|
+
return diff
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@router.get("/export/csv")
|
|
209
|
+
async def export_resources_csv(
|
|
210
|
+
q: Optional[str] = Query(None, description="Search query (ARN pattern)"),
|
|
211
|
+
type: Optional[str] = Query(None, description="Filter by resource type"),
|
|
212
|
+
region: Optional[str] = Query(None, description="Filter by region"),
|
|
213
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
214
|
+
tag_key: Optional[str] = Query(None, description="Filter by tag key"),
|
|
215
|
+
tag_value: Optional[str] = Query(None, description="Filter by tag value"),
|
|
216
|
+
columns: Optional[str] = Query(None, description="Comma-separated column names to include"),
|
|
217
|
+
sort_by: Optional[str] = Query(None, description="Column to sort by"),
|
|
218
|
+
sort_order: str = Query("asc", description="Sort order: asc or desc"),
|
|
219
|
+
):
|
|
220
|
+
"""Export resources to CSV file."""
|
|
221
|
+
store = get_resource_store()
|
|
222
|
+
|
|
223
|
+
# Define base columns
|
|
224
|
+
base_columns = ["name", "arn", "resource_type", "region", "snapshot_name", "tags", "created_at", "config_hash"]
|
|
225
|
+
|
|
226
|
+
# Parse requested columns
|
|
227
|
+
requested_columns = [c.strip() for c in columns.split(",")] if columns else base_columns
|
|
228
|
+
|
|
229
|
+
# Check if we need tags (either "tags" column or any "tag:KEY" column)
|
|
230
|
+
include_tags = any(c == "tags" or c.startswith("tag:") for c in requested_columns)
|
|
231
|
+
|
|
232
|
+
# Get all matching resources (no limit for export)
|
|
233
|
+
resources = store.search(
|
|
234
|
+
arn_pattern=q,
|
|
235
|
+
resource_type=type,
|
|
236
|
+
region=region,
|
|
237
|
+
snapshot_name=snapshot,
|
|
238
|
+
tag_key=tag_key,
|
|
239
|
+
tag_value=tag_value,
|
|
240
|
+
limit=10000, # Reasonable limit for export
|
|
241
|
+
offset=0,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
if not resources:
|
|
245
|
+
raise HTTPException(status_code=404, detail="No resources found matching criteria")
|
|
246
|
+
|
|
247
|
+
# Enrich resources with tags if needed
|
|
248
|
+
enriched_resources = []
|
|
249
|
+
for resource in resources:
|
|
250
|
+
r = dict(resource)
|
|
251
|
+
if include_tags:
|
|
252
|
+
tags = store.get_tags_for_resource(
|
|
253
|
+
resource["arn"],
|
|
254
|
+
snapshot_name=resource.get("snapshot_name"),
|
|
255
|
+
)
|
|
256
|
+
# "tags" column: all tags as string
|
|
257
|
+
r["tags"] = "; ".join(f"{k}={v}" for k, v in tags.items()) if tags else ""
|
|
258
|
+
# Individual tag columns (tag:KEY)
|
|
259
|
+
for col in requested_columns:
|
|
260
|
+
if col.startswith("tag:"):
|
|
261
|
+
tag_key_name = col[4:] # Remove "tag:" prefix
|
|
262
|
+
r[col] = tags.get(tag_key_name, "")
|
|
263
|
+
enriched_resources.append(r)
|
|
264
|
+
resources = enriched_resources
|
|
265
|
+
|
|
266
|
+
# Filter columns to only those requested (allow tag:KEY columns)
|
|
267
|
+
selected_columns = [c for c in requested_columns if c in base_columns or c.startswith("tag:")]
|
|
268
|
+
if not selected_columns:
|
|
269
|
+
selected_columns = base_columns
|
|
270
|
+
|
|
271
|
+
# Sort if requested
|
|
272
|
+
if sort_by and (sort_by in base_columns or sort_by.startswith("tag:")):
|
|
273
|
+
reverse = sort_order.lower() == "desc"
|
|
274
|
+
resources = sorted(
|
|
275
|
+
resources,
|
|
276
|
+
key=lambda r: (r.get(sort_by) or "") if isinstance(r.get(sort_by), str) else r.get(sort_by, 0),
|
|
277
|
+
reverse=reverse,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
# Generate CSV
|
|
281
|
+
output = io.StringIO()
|
|
282
|
+
writer = csv.DictWriter(output, fieldnames=selected_columns, extrasaction="ignore")
|
|
283
|
+
writer.writeheader()
|
|
284
|
+
writer.writerows(resources)
|
|
285
|
+
|
|
286
|
+
# Create response
|
|
287
|
+
csv_content = output.getvalue()
|
|
288
|
+
output.close()
|
|
289
|
+
|
|
290
|
+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
291
|
+
filename = f"resources_export_{timestamp}.csv"
|
|
292
|
+
|
|
293
|
+
return StreamingResponse(
|
|
294
|
+
iter([csv_content]),
|
|
295
|
+
media_type="text/csv",
|
|
296
|
+
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@router.get("/export/yaml")
|
|
301
|
+
async def export_resources_yaml(
|
|
302
|
+
q: Optional[str] = Query(None, description="Search query (ARN pattern)"),
|
|
303
|
+
type: Optional[str] = Query(None, description="Filter by resource type"),
|
|
304
|
+
region: Optional[str] = Query(None, description="Filter by region"),
|
|
305
|
+
snapshot: Optional[str] = Query(None, description="Limit to specific snapshot"),
|
|
306
|
+
tag_key: Optional[str] = Query(None, description="Filter by tag key"),
|
|
307
|
+
tag_value: Optional[str] = Query(None, description="Filter by tag value"),
|
|
308
|
+
include_config: bool = Query(True, description="Include raw configuration"),
|
|
309
|
+
include_tags: bool = Query(True, description="Include resource tags"),
|
|
310
|
+
):
|
|
311
|
+
"""Export resources to YAML file with all properties."""
|
|
312
|
+
store = get_resource_store()
|
|
313
|
+
db = get_database()
|
|
314
|
+
|
|
315
|
+
# Get all matching resources
|
|
316
|
+
resources = store.search(
|
|
317
|
+
arn_pattern=q,
|
|
318
|
+
resource_type=type,
|
|
319
|
+
region=region,
|
|
320
|
+
snapshot_name=snapshot,
|
|
321
|
+
tag_key=tag_key,
|
|
322
|
+
tag_value=tag_value,
|
|
323
|
+
limit=10000,
|
|
324
|
+
offset=0,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
if not resources:
|
|
328
|
+
raise HTTPException(status_code=404, detail="No resources found matching criteria")
|
|
329
|
+
|
|
330
|
+
# Enrich resources with full data
|
|
331
|
+
enriched_resources: List[Dict[str, Any]] = []
|
|
332
|
+
|
|
333
|
+
for resource in resources:
|
|
334
|
+
enriched = dict(resource)
|
|
335
|
+
|
|
336
|
+
# Get tags if requested
|
|
337
|
+
if include_tags:
|
|
338
|
+
tags = store.get_tags_for_resource(
|
|
339
|
+
resource["arn"],
|
|
340
|
+
snapshot_name=resource.get("snapshot_name"),
|
|
341
|
+
)
|
|
342
|
+
enriched["tags"] = tags
|
|
343
|
+
|
|
344
|
+
# Get raw config if requested
|
|
345
|
+
if include_config:
|
|
346
|
+
# Fetch raw_config from database
|
|
347
|
+
row = db.fetchone(
|
|
348
|
+
"""
|
|
349
|
+
SELECT r.raw_config
|
|
350
|
+
FROM resources r
|
|
351
|
+
JOIN snapshots s ON r.snapshot_id = s.id
|
|
352
|
+
WHERE r.arn = ? AND s.name = ?
|
|
353
|
+
""",
|
|
354
|
+
(resource["arn"], resource.get("snapshot_name")),
|
|
355
|
+
)
|
|
356
|
+
if row and row["raw_config"]:
|
|
357
|
+
try:
|
|
358
|
+
enriched["config"] = json.loads(row["raw_config"])
|
|
359
|
+
except json.JSONDecodeError:
|
|
360
|
+
enriched["config"] = row["raw_config"]
|
|
361
|
+
|
|
362
|
+
enriched_resources.append(enriched)
|
|
363
|
+
|
|
364
|
+
# Generate YAML
|
|
365
|
+
yaml_content = yaml.dump(
|
|
366
|
+
{"resources": enriched_resources, "count": len(enriched_resources)},
|
|
367
|
+
default_flow_style=False,
|
|
368
|
+
allow_unicode=True,
|
|
369
|
+
sort_keys=False,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
|
373
|
+
filename = f"resources_export_{timestamp}.yaml"
|
|
374
|
+
|
|
375
|
+
return StreamingResponse(
|
|
376
|
+
iter([yaml_content]),
|
|
377
|
+
media_type="application/x-yaml",
|
|
378
|
+
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
|
379
|
+
)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Snapshot API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ...dependencies import get_resource_store, get_snapshot_store
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/snapshots")
|
|
19
|
+
|
|
20
|
+
# In-memory store for snapshot creation jobs
|
|
21
|
+
_snapshot_jobs: Dict[str, dict] = {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SnapshotSummary(BaseModel):
|
|
25
|
+
"""Snapshot summary for list view."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
created_at: str
|
|
29
|
+
account_id: str
|
|
30
|
+
regions: List[str]
|
|
31
|
+
resource_count: int
|
|
32
|
+
is_active: bool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SnapshotDetail(BaseModel):
|
|
36
|
+
"""Full snapshot details."""
|
|
37
|
+
|
|
38
|
+
name: str
|
|
39
|
+
created_at: str
|
|
40
|
+
account_id: str
|
|
41
|
+
regions: List[str]
|
|
42
|
+
resource_count: int
|
|
43
|
+
service_counts: dict
|
|
44
|
+
is_active: bool
|
|
45
|
+
metadata: Optional[dict] = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@router.get("", response_model=List[SnapshotSummary])
|
|
49
|
+
async def list_snapshots():
|
|
50
|
+
"""List all snapshots."""
|
|
51
|
+
store = get_snapshot_store()
|
|
52
|
+
snapshots = store.list_all()
|
|
53
|
+
|
|
54
|
+
return [
|
|
55
|
+
SnapshotSummary(
|
|
56
|
+
name=s["name"],
|
|
57
|
+
created_at=s["created_at"].isoformat() if hasattr(s["created_at"], "isoformat") else str(s["created_at"]),
|
|
58
|
+
account_id=s["account_id"],
|
|
59
|
+
regions=s.get("regions", []),
|
|
60
|
+
resource_count=s.get("resource_count", 0),
|
|
61
|
+
is_active=s.get("is_active", False),
|
|
62
|
+
)
|
|
63
|
+
for s in snapshots
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CreateSnapshotRequest(BaseModel):
|
|
68
|
+
"""Request model for creating a snapshot."""
|
|
69
|
+
|
|
70
|
+
name: Optional[str] = None # Auto-generated if not provided
|
|
71
|
+
regions: Optional[List[str]] = None # Defaults to us-east-1
|
|
72
|
+
inventory: Optional[str] = None # Inventory name to use
|
|
73
|
+
set_active: bool = True
|
|
74
|
+
use_config: bool = True # Use AWS Config for collection
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SnapshotJobStatus(BaseModel):
|
|
78
|
+
"""Status of a snapshot creation job."""
|
|
79
|
+
|
|
80
|
+
job_id: str
|
|
81
|
+
status: str # pending, running, completed, failed
|
|
82
|
+
snapshot_name: Optional[str] = None
|
|
83
|
+
message: Optional[str] = None
|
|
84
|
+
progress: Optional[int] = None
|
|
85
|
+
created_at: str
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _create_snapshot_sync(job_id: str, name: str, regions: List[str], inventory: Optional[str], set_active: bool, use_config: bool):
|
|
89
|
+
"""Synchronous snapshot creation function to run in background."""
|
|
90
|
+
import boto3
|
|
91
|
+
import sys
|
|
92
|
+
import os
|
|
93
|
+
# Add project root to path for imports in thread
|
|
94
|
+
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))))
|
|
95
|
+
if project_root not in sys.path:
|
|
96
|
+
sys.path.insert(0, project_root)
|
|
97
|
+
|
|
98
|
+
from src.config import config
|
|
99
|
+
from src.snapshot.storage import SnapshotStorage
|
|
100
|
+
from src.snapshot.inventory_storage import InventoryStorage
|
|
101
|
+
from src.snapshot.collector import SnapshotCollector
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
_snapshot_jobs[job_id]["status"] = "running"
|
|
105
|
+
_snapshot_jobs[job_id]["message"] = "Validating AWS credentials..."
|
|
106
|
+
|
|
107
|
+
# Get AWS identity
|
|
108
|
+
sts = boto3.client("sts")
|
|
109
|
+
identity = sts.get_caller_identity()
|
|
110
|
+
account_id = identity["Account"]
|
|
111
|
+
|
|
112
|
+
_snapshot_jobs[job_id]["message"] = f"Authenticated as account {account_id}"
|
|
113
|
+
|
|
114
|
+
# Load inventory if specified
|
|
115
|
+
inventory_storage = InventoryStorage(config.storage_path)
|
|
116
|
+
active_inventory = None
|
|
117
|
+
include_tags = {}
|
|
118
|
+
exclude_tags = {}
|
|
119
|
+
|
|
120
|
+
if inventory:
|
|
121
|
+
try:
|
|
122
|
+
active_inventory = inventory_storage.get_by_name(inventory, account_id)
|
|
123
|
+
include_tags = active_inventory.include_tags or {}
|
|
124
|
+
exclude_tags = active_inventory.exclude_tags or {}
|
|
125
|
+
_snapshot_jobs[job_id]["message"] = f"Using inventory: {inventory}"
|
|
126
|
+
except Exception:
|
|
127
|
+
_snapshot_jobs[job_id]["status"] = "failed"
|
|
128
|
+
_snapshot_jobs[job_id]["message"] = f"Inventory '{inventory}' not found"
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
_snapshot_jobs[job_id]["message"] = f"Collecting resources from {', '.join(regions)}..."
|
|
132
|
+
_snapshot_jobs[job_id]["progress"] = 10
|
|
133
|
+
|
|
134
|
+
# Create collector and collect resources
|
|
135
|
+
collector = SnapshotCollector(
|
|
136
|
+
regions=regions,
|
|
137
|
+
include_tags=include_tags,
|
|
138
|
+
exclude_tags=exclude_tags,
|
|
139
|
+
use_config=use_config,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
snapshot = collector.collect(snapshot_name=name)
|
|
143
|
+
_snapshot_jobs[job_id]["progress"] = 80
|
|
144
|
+
|
|
145
|
+
# Save snapshot
|
|
146
|
+
_snapshot_jobs[job_id]["message"] = f"Saving snapshot with {snapshot.resource_count} resources..."
|
|
147
|
+
storage = SnapshotStorage(config.storage_path)
|
|
148
|
+
snapshot.is_active = set_active
|
|
149
|
+
storage.save_snapshot(snapshot)
|
|
150
|
+
|
|
151
|
+
_snapshot_jobs[job_id]["progress"] = 100
|
|
152
|
+
_snapshot_jobs[job_id]["status"] = "completed"
|
|
153
|
+
_snapshot_jobs[job_id]["snapshot_name"] = snapshot.name
|
|
154
|
+
_snapshot_jobs[job_id]["message"] = f"Created snapshot '{snapshot.name}' with {snapshot.resource_count} resources"
|
|
155
|
+
|
|
156
|
+
except Exception as e:
|
|
157
|
+
logger.exception(f"Snapshot creation failed: {e}")
|
|
158
|
+
_snapshot_jobs[job_id]["status"] = "failed"
|
|
159
|
+
_snapshot_jobs[job_id]["message"] = str(e)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("")
|
|
163
|
+
async def create_snapshot(request: CreateSnapshotRequest, background_tasks: BackgroundTasks):
|
|
164
|
+
"""Create a new snapshot (runs in background)."""
|
|
165
|
+
from datetime import datetime
|
|
166
|
+
|
|
167
|
+
# Generate snapshot name if not provided
|
|
168
|
+
name = request.name
|
|
169
|
+
if not name:
|
|
170
|
+
name = f"snapshot-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
171
|
+
|
|
172
|
+
# Default regions
|
|
173
|
+
regions = request.regions or ["us-east-1"]
|
|
174
|
+
|
|
175
|
+
# Create job ID
|
|
176
|
+
job_id = str(uuid.uuid4())[:8]
|
|
177
|
+
|
|
178
|
+
# Initialize job status
|
|
179
|
+
_snapshot_jobs[job_id] = {
|
|
180
|
+
"job_id": job_id,
|
|
181
|
+
"status": "pending",
|
|
182
|
+
"snapshot_name": name,
|
|
183
|
+
"message": "Starting snapshot creation...",
|
|
184
|
+
"progress": 0,
|
|
185
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
# Run in background thread (not async since boto3 is sync)
|
|
189
|
+
import threading
|
|
190
|
+
thread = threading.Thread(
|
|
191
|
+
target=_create_snapshot_sync,
|
|
192
|
+
args=(job_id, name, regions, request.inventory, request.set_active, request.use_config),
|
|
193
|
+
daemon=True,
|
|
194
|
+
)
|
|
195
|
+
thread.start()
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
"job_id": job_id,
|
|
199
|
+
"message": f"Snapshot creation started for '{name}'",
|
|
200
|
+
"status_url": f"/api/snapshots/jobs/{job_id}",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.get("/jobs/{job_id}")
|
|
205
|
+
async def get_snapshot_job_status(job_id: str):
|
|
206
|
+
"""Get status of a snapshot creation job."""
|
|
207
|
+
if job_id not in _snapshot_jobs:
|
|
208
|
+
raise HTTPException(status_code=404, detail=f"Job '{job_id}' not found")
|
|
209
|
+
|
|
210
|
+
return _snapshot_jobs[job_id]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@router.get("/{name}")
|
|
214
|
+
async def get_snapshot(name: str):
|
|
215
|
+
"""Get snapshot details."""
|
|
216
|
+
store = get_snapshot_store()
|
|
217
|
+
snapshot = store.load(name)
|
|
218
|
+
|
|
219
|
+
if not snapshot:
|
|
220
|
+
raise HTTPException(status_code=404, detail=f"Snapshot '{name}' not found")
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
"name": snapshot.name,
|
|
224
|
+
"created_at": snapshot.created_at.isoformat(),
|
|
225
|
+
"account_id": snapshot.account_id,
|
|
226
|
+
"regions": snapshot.regions,
|
|
227
|
+
"resource_count": snapshot.resource_count,
|
|
228
|
+
"service_counts": snapshot.service_counts,
|
|
229
|
+
"is_active": snapshot.is_active,
|
|
230
|
+
"metadata": snapshot.metadata,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@router.delete("/{name}")
|
|
235
|
+
async def delete_snapshot(name: str):
|
|
236
|
+
"""Delete a snapshot."""
|
|
237
|
+
store = get_snapshot_store()
|
|
238
|
+
|
|
239
|
+
if not store.exists(name):
|
|
240
|
+
raise HTTPException(status_code=404, detail=f"Snapshot '{name}' not found")
|
|
241
|
+
|
|
242
|
+
success = store.delete(name)
|
|
243
|
+
if not success:
|
|
244
|
+
raise HTTPException(status_code=500, detail="Failed to delete snapshot")
|
|
245
|
+
|
|
246
|
+
return {"message": f"Snapshot '{name}' deleted"}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@router.post("/{name}/activate")
|
|
250
|
+
async def activate_snapshot(name: str):
|
|
251
|
+
"""Set snapshot as active baseline."""
|
|
252
|
+
store = get_snapshot_store()
|
|
253
|
+
|
|
254
|
+
if not store.exists(name):
|
|
255
|
+
raise HTTPException(status_code=404, detail=f"Snapshot '{name}' not found")
|
|
256
|
+
|
|
257
|
+
store.set_active(name)
|
|
258
|
+
return {"message": f"Snapshot '{name}' is now active"}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class RenameRequest(BaseModel):
|
|
262
|
+
"""Request model for renaming."""
|
|
263
|
+
|
|
264
|
+
new_name: str
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@router.post("/{name}/rename")
|
|
268
|
+
async def rename_snapshot(name: str, request: RenameRequest):
|
|
269
|
+
"""Rename a snapshot."""
|
|
270
|
+
store = get_snapshot_store()
|
|
271
|
+
|
|
272
|
+
if not store.exists(name):
|
|
273
|
+
raise HTTPException(status_code=404, detail=f"Snapshot '{name}' not found")
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
success = store.rename(name, request.new_name)
|
|
277
|
+
if not success:
|
|
278
|
+
raise HTTPException(status_code=500, detail="Failed to rename snapshot")
|
|
279
|
+
|
|
280
|
+
return {"message": f"Snapshot renamed from '{name}' to '{request.new_name}'", "new_name": request.new_name}
|
|
281
|
+
except ValueError as e:
|
|
282
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@router.get("/{name}/resources")
|
|
286
|
+
async def get_snapshot_resources(
|
|
287
|
+
name: str,
|
|
288
|
+
type: Optional[str] = Query(None, description="Filter by resource type"),
|
|
289
|
+
region: Optional[str] = Query(None, description="Filter by region"),
|
|
290
|
+
limit: int = Query(100, le=1000),
|
|
291
|
+
offset: int = Query(0, ge=0),
|
|
292
|
+
):
|
|
293
|
+
"""Get resources in a snapshot."""
|
|
294
|
+
snapshot_store = get_snapshot_store()
|
|
295
|
+
|
|
296
|
+
if not snapshot_store.exists(name):
|
|
297
|
+
raise HTTPException(status_code=404, detail=f"Snapshot '{name}' not found")
|
|
298
|
+
|
|
299
|
+
resource_store = get_resource_store()
|
|
300
|
+
resources = resource_store.search(
|
|
301
|
+
snapshot_name=name,
|
|
302
|
+
resource_type=type,
|
|
303
|
+
region=region,
|
|
304
|
+
limit=limit,
|
|
305
|
+
offset=offset,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"snapshot": name,
|
|
310
|
+
"count": len(resources),
|
|
311
|
+
"limit": limit,
|
|
312
|
+
"offset": offset,
|
|
313
|
+
"resources": resources,
|
|
314
|
+
}
|