aws-inventory-manager 0.17.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
  2. aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
  3. aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
  4. aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
  5. aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
  6. aws_inventory_manager-0.17.12.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +12 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +4046 -0
  15. src/cloudtrail/__init__.py +5 -0
  16. src/cloudtrail/query.py +642 -0
  17. src/config_service/__init__.py +21 -0
  18. src/config_service/collector.py +346 -0
  19. src/config_service/detector.py +256 -0
  20. src/config_service/resource_type_mapping.py +328 -0
  21. src/cost/__init__.py +5 -0
  22. src/cost/analyzer.py +226 -0
  23. src/cost/explorer.py +209 -0
  24. src/cost/reporter.py +237 -0
  25. src/delta/__init__.py +5 -0
  26. src/delta/calculator.py +206 -0
  27. src/delta/differ.py +185 -0
  28. src/delta/formatters.py +272 -0
  29. src/delta/models.py +154 -0
  30. src/delta/reporter.py +234 -0
  31. src/matching/__init__.py +6 -0
  32. src/matching/config.py +52 -0
  33. src/matching/normalizer.py +450 -0
  34. src/matching/prompts.py +33 -0
  35. src/models/__init__.py +21 -0
  36. src/models/config_diff.py +135 -0
  37. src/models/cost_report.py +87 -0
  38. src/models/deletion_operation.py +104 -0
  39. src/models/deletion_record.py +97 -0
  40. src/models/delta_report.py +122 -0
  41. src/models/efs_resource.py +80 -0
  42. src/models/elasticache_resource.py +90 -0
  43. src/models/group.py +318 -0
  44. src/models/inventory.py +133 -0
  45. src/models/protection_rule.py +123 -0
  46. src/models/report.py +288 -0
  47. src/models/resource.py +111 -0
  48. src/models/security_finding.py +102 -0
  49. src/models/snapshot.py +122 -0
  50. src/restore/__init__.py +20 -0
  51. src/restore/audit.py +175 -0
  52. src/restore/cleaner.py +461 -0
  53. src/restore/config.py +209 -0
  54. src/restore/deleter.py +976 -0
  55. src/restore/dependency.py +254 -0
  56. src/restore/safety.py +115 -0
  57. src/security/__init__.py +0 -0
  58. src/security/checks/__init__.py +0 -0
  59. src/security/checks/base.py +56 -0
  60. src/security/checks/ec2_checks.py +88 -0
  61. src/security/checks/elasticache_checks.py +149 -0
  62. src/security/checks/iam_checks.py +102 -0
  63. src/security/checks/rds_checks.py +140 -0
  64. src/security/checks/s3_checks.py +95 -0
  65. src/security/checks/secrets_checks.py +96 -0
  66. src/security/checks/sg_checks.py +142 -0
  67. src/security/cis_mapper.py +97 -0
  68. src/security/models.py +53 -0
  69. src/security/reporter.py +174 -0
  70. src/security/scanner.py +87 -0
  71. src/snapshot/__init__.py +6 -0
  72. src/snapshot/capturer.py +453 -0
  73. src/snapshot/filter.py +259 -0
  74. src/snapshot/inventory_storage.py +236 -0
  75. src/snapshot/report_formatter.py +250 -0
  76. src/snapshot/reporter.py +189 -0
  77. src/snapshot/resource_collectors/__init__.py +5 -0
  78. src/snapshot/resource_collectors/apigateway.py +140 -0
  79. src/snapshot/resource_collectors/backup.py +136 -0
  80. src/snapshot/resource_collectors/base.py +81 -0
  81. src/snapshot/resource_collectors/cloudformation.py +55 -0
  82. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  83. src/snapshot/resource_collectors/codebuild.py +69 -0
  84. src/snapshot/resource_collectors/codepipeline.py +82 -0
  85. src/snapshot/resource_collectors/dynamodb.py +65 -0
  86. src/snapshot/resource_collectors/ec2.py +240 -0
  87. src/snapshot/resource_collectors/ecs.py +215 -0
  88. src/snapshot/resource_collectors/efs_collector.py +102 -0
  89. src/snapshot/resource_collectors/eks.py +200 -0
  90. src/snapshot/resource_collectors/elasticache_collector.py +79 -0
  91. src/snapshot/resource_collectors/elb.py +126 -0
  92. src/snapshot/resource_collectors/eventbridge.py +156 -0
  93. src/snapshot/resource_collectors/glue.py +199 -0
  94. src/snapshot/resource_collectors/iam.py +188 -0
  95. src/snapshot/resource_collectors/kms.py +111 -0
  96. src/snapshot/resource_collectors/lambda_func.py +139 -0
  97. src/snapshot/resource_collectors/rds.py +109 -0
  98. src/snapshot/resource_collectors/route53.py +86 -0
  99. src/snapshot/resource_collectors/s3.py +105 -0
  100. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  101. src/snapshot/resource_collectors/sns.py +68 -0
  102. src/snapshot/resource_collectors/sqs.py +82 -0
  103. src/snapshot/resource_collectors/ssm.py +160 -0
  104. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  105. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  106. src/snapshot/resource_collectors/waf.py +159 -0
  107. src/snapshot/storage.py +351 -0
  108. src/storage/__init__.py +21 -0
  109. src/storage/audit_store.py +419 -0
  110. src/storage/database.py +294 -0
  111. src/storage/group_store.py +763 -0
  112. src/storage/inventory_store.py +320 -0
  113. src/storage/resource_store.py +416 -0
  114. src/storage/schema.py +339 -0
  115. src/storage/snapshot_store.py +363 -0
  116. src/utils/__init__.py +12 -0
  117. src/utils/export.py +305 -0
  118. src/utils/hash.py +60 -0
  119. src/utils/logging.py +63 -0
  120. src/utils/pagination.py +41 -0
  121. src/utils/paths.py +51 -0
  122. src/utils/progress.py +41 -0
  123. src/utils/unsupported_resources.py +306 -0
  124. src/web/__init__.py +5 -0
  125. src/web/app.py +97 -0
  126. src/web/dependencies.py +69 -0
  127. src/web/routes/__init__.py +1 -0
  128. src/web/routes/api/__init__.py +18 -0
  129. src/web/routes/api/charts.py +156 -0
  130. src/web/routes/api/cleanup.py +186 -0
  131. src/web/routes/api/filters.py +253 -0
  132. src/web/routes/api/groups.py +305 -0
  133. src/web/routes/api/inventories.py +80 -0
  134. src/web/routes/api/queries.py +202 -0
  135. src/web/routes/api/resources.py +393 -0
  136. src/web/routes/api/snapshots.py +314 -0
  137. src/web/routes/api/views.py +260 -0
  138. src/web/routes/pages.py +198 -0
  139. src/web/services/__init__.py +1 -0
  140. src/web/templates/base.html +955 -0
  141. src/web/templates/components/navbar.html +31 -0
  142. src/web/templates/components/sidebar.html +104 -0
  143. src/web/templates/pages/audit_logs.html +86 -0
  144. src/web/templates/pages/cleanup.html +279 -0
  145. src/web/templates/pages/dashboard.html +227 -0
  146. src/web/templates/pages/diff.html +175 -0
  147. src/web/templates/pages/error.html +30 -0
  148. src/web/templates/pages/groups.html +721 -0
  149. src/web/templates/pages/queries.html +246 -0
  150. src/web/templates/pages/resources.html +2429 -0
  151. src/web/templates/pages/snapshot_detail.html +271 -0
  152. src/web/templates/pages/snapshots.html +429 -0
@@ -0,0 +1,393 @@
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 (comma-separated for multiple)"),
304
+ region: Optional[str] = Query(None, description="Filter by region (comma-separated for multiple)"),
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
+ # Parse comma-separated types and regions
316
+ types_list = [t.strip() for t in type.split(",")] if type else []
317
+ regions_list = [r.strip() for r in region.split(",")] if region else []
318
+
319
+ # For single value, use API filter; for multiple, we'll post-filter
320
+ api_type = types_list[0] if len(types_list) == 1 else None
321
+ api_region = regions_list[0] if len(regions_list) == 1 else None
322
+
323
+ # Get all matching resources
324
+ resources = store.search(
325
+ arn_pattern=q,
326
+ resource_type=api_type,
327
+ region=api_region,
328
+ snapshot_name=snapshot,
329
+ tag_key=tag_key,
330
+ tag_value=tag_value,
331
+ limit=10000,
332
+ offset=0,
333
+ )
334
+
335
+ # Post-filter for multiple types/regions
336
+ if len(types_list) > 1:
337
+ resources = [r for r in resources if r.get("resource_type") in types_list]
338
+ if len(regions_list) > 1:
339
+ resources = [r for r in resources if r.get("region") in regions_list]
340
+
341
+ if not resources:
342
+ raise HTTPException(status_code=404, detail="No resources found matching criteria")
343
+
344
+ # Enrich resources with full data
345
+ enriched_resources: List[Dict[str, Any]] = []
346
+
347
+ for resource in resources:
348
+ enriched = dict(resource)
349
+
350
+ # Get tags if requested
351
+ if include_tags:
352
+ tags = store.get_tags_for_resource(
353
+ resource["arn"],
354
+ snapshot_name=resource.get("snapshot_name"),
355
+ )
356
+ enriched["tags"] = tags
357
+
358
+ # Get raw config if requested
359
+ if include_config:
360
+ # Fetch raw_config from database
361
+ row = db.fetchone(
362
+ """
363
+ SELECT r.raw_config
364
+ FROM resources r
365
+ JOIN snapshots s ON r.snapshot_id = s.id
366
+ WHERE r.arn = ? AND s.name = ?
367
+ """,
368
+ (resource["arn"], resource.get("snapshot_name")),
369
+ )
370
+ if row and row["raw_config"]:
371
+ try:
372
+ enriched["config"] = json.loads(row["raw_config"])
373
+ except json.JSONDecodeError:
374
+ enriched["config"] = row["raw_config"]
375
+
376
+ enriched_resources.append(enriched)
377
+
378
+ # Generate YAML
379
+ yaml_content = yaml.dump(
380
+ {"resources": enriched_resources, "count": len(enriched_resources)},
381
+ default_flow_style=False,
382
+ allow_unicode=True,
383
+ sort_keys=False,
384
+ )
385
+
386
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
387
+ filename = f"resources_export_{timestamp}.yaml"
388
+
389
+ return StreamingResponse(
390
+ iter([yaml_content]),
391
+ media_type="application/x-yaml",
392
+ headers={"Content-Disposition": f"attachment; filename={filename}"},
393
+ )
@@ -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
+ }