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,305 @@
|
|
|
1
|
+
"""Resource groups API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
8
|
+
from pydantic import BaseModel, field_validator
|
|
9
|
+
|
|
10
|
+
from ...dependencies import get_database, get_group_store
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/groups")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GroupMemberRequest(BaseModel):
|
|
16
|
+
"""Request model for adding a group member."""
|
|
17
|
+
|
|
18
|
+
resource_name: str
|
|
19
|
+
resource_type: str
|
|
20
|
+
original_arn: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AddMembersFromArnsRequest(BaseModel):
|
|
24
|
+
"""Request model for adding members from ARNs."""
|
|
25
|
+
|
|
26
|
+
arns: List[Dict[str, str]] # List of {"arn": "...", "resource_type": "..."}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CreateGroupRequest(BaseModel):
|
|
30
|
+
"""Request model for creating a group."""
|
|
31
|
+
|
|
32
|
+
name: str
|
|
33
|
+
description: Optional[str] = ""
|
|
34
|
+
from_snapshot: Optional[str] = None
|
|
35
|
+
type_filter: Optional[str] = None
|
|
36
|
+
region_filter: Optional[str] = None
|
|
37
|
+
|
|
38
|
+
@field_validator("from_snapshot", "type_filter", "region_filter", mode="before")
|
|
39
|
+
@classmethod
|
|
40
|
+
def empty_string_to_none(cls, v):
|
|
41
|
+
"""Convert empty strings to None for optional fields."""
|
|
42
|
+
if v == "":
|
|
43
|
+
return None
|
|
44
|
+
return v
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class GroupResponse(BaseModel):
|
|
48
|
+
"""Response model for a group."""
|
|
49
|
+
|
|
50
|
+
id: int
|
|
51
|
+
name: str
|
|
52
|
+
description: str
|
|
53
|
+
source_snapshot: Optional[str]
|
|
54
|
+
resource_count: int
|
|
55
|
+
is_favorite: bool
|
|
56
|
+
created_at: str
|
|
57
|
+
last_updated: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GroupCompareResponse(BaseModel):
|
|
61
|
+
"""Response model for group comparison."""
|
|
62
|
+
|
|
63
|
+
group_name: str
|
|
64
|
+
snapshot_name: str
|
|
65
|
+
total_in_group: int
|
|
66
|
+
total_in_snapshot: int
|
|
67
|
+
matched: int
|
|
68
|
+
missing_from_snapshot: int
|
|
69
|
+
not_in_group: int
|
|
70
|
+
resources: Dict[str, List[Any]]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@router.get("")
|
|
74
|
+
async def list_groups(
|
|
75
|
+
favorites_only: bool = Query(False, description="Only show favorites"),
|
|
76
|
+
):
|
|
77
|
+
"""List all resource groups."""
|
|
78
|
+
store = get_group_store()
|
|
79
|
+
groups = store.list_all()
|
|
80
|
+
return {"groups": groups, "count": len(groups)}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.post("")
|
|
84
|
+
async def create_group(request: CreateGroupRequest):
|
|
85
|
+
"""Create a new resource group.
|
|
86
|
+
|
|
87
|
+
If from_snapshot is provided, the group will be populated with resources
|
|
88
|
+
from that snapshot. Otherwise, an empty group is created.
|
|
89
|
+
"""
|
|
90
|
+
store = get_group_store()
|
|
91
|
+
|
|
92
|
+
if store.exists(request.name):
|
|
93
|
+
raise HTTPException(status_code=400, detail=f"Group '{request.name}' already exists")
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
if request.from_snapshot:
|
|
97
|
+
count = store.create_from_snapshot(
|
|
98
|
+
group_name=request.name,
|
|
99
|
+
snapshot_name=request.from_snapshot,
|
|
100
|
+
description=request.description or "",
|
|
101
|
+
type_filter=request.type_filter,
|
|
102
|
+
region_filter=request.region_filter,
|
|
103
|
+
)
|
|
104
|
+
return {
|
|
105
|
+
"message": f"Group '{request.name}' created with {count} resources",
|
|
106
|
+
"name": request.name,
|
|
107
|
+
"resource_count": count,
|
|
108
|
+
}
|
|
109
|
+
else:
|
|
110
|
+
from ....models.group import ResourceGroup
|
|
111
|
+
|
|
112
|
+
group = ResourceGroup(
|
|
113
|
+
name=request.name,
|
|
114
|
+
description=request.description or "",
|
|
115
|
+
)
|
|
116
|
+
group_id = store.save(group)
|
|
117
|
+
return {
|
|
118
|
+
"message": f"Empty group '{request.name}' created",
|
|
119
|
+
"name": request.name,
|
|
120
|
+
"id": group_id,
|
|
121
|
+
"resource_count": 0,
|
|
122
|
+
}
|
|
123
|
+
except ValueError as e:
|
|
124
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@router.get("/{name}")
|
|
128
|
+
async def get_group(name: str):
|
|
129
|
+
"""Get group details."""
|
|
130
|
+
store = get_group_store()
|
|
131
|
+
group = store.load(name)
|
|
132
|
+
|
|
133
|
+
if not group:
|
|
134
|
+
raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"id": group.id,
|
|
138
|
+
"name": group.name,
|
|
139
|
+
"description": group.description,
|
|
140
|
+
"source_snapshot": group.source_snapshot,
|
|
141
|
+
"resource_count": group.resource_count,
|
|
142
|
+
"is_favorite": group.is_favorite,
|
|
143
|
+
"created_at": group.created_at.isoformat() if group.created_at else None,
|
|
144
|
+
"last_updated": group.last_updated.isoformat() if group.last_updated else None,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.delete("/{name}")
|
|
149
|
+
async def delete_group(name: str):
|
|
150
|
+
"""Delete a resource group."""
|
|
151
|
+
store = get_group_store()
|
|
152
|
+
|
|
153
|
+
if not store.exists(name):
|
|
154
|
+
raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
|
|
155
|
+
|
|
156
|
+
store.delete(name)
|
|
157
|
+
return {"message": f"Group '{name}' deleted"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.post("/{name}/favorite")
|
|
161
|
+
async def toggle_group_favorite(name: str):
|
|
162
|
+
"""Toggle the favorite status of a group."""
|
|
163
|
+
store = get_group_store()
|
|
164
|
+
|
|
165
|
+
new_favorite = store.toggle_favorite(name)
|
|
166
|
+
if new_favorite is None:
|
|
167
|
+
raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
|
|
168
|
+
|
|
169
|
+
return {"message": "Favorite toggled", "is_favorite": new_favorite}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Members endpoints
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@router.get("/{name}/members")
|
|
176
|
+
async def get_group_members(
|
|
177
|
+
name: str,
|
|
178
|
+
limit: int = Query(100, le=1000),
|
|
179
|
+
offset: int = Query(0, ge=0),
|
|
180
|
+
):
|
|
181
|
+
"""Get members of a group with pagination."""
|
|
182
|
+
store = get_group_store()
|
|
183
|
+
|
|
184
|
+
if not store.exists(name):
|
|
185
|
+
raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
|
|
186
|
+
|
|
187
|
+
members = store.get_members(name, limit=limit, offset=offset)
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
"members": [
|
|
191
|
+
{
|
|
192
|
+
"resource_name": m.resource_name,
|
|
193
|
+
"resource_type": m.resource_type,
|
|
194
|
+
"original_arn": m.original_arn,
|
|
195
|
+
}
|
|
196
|
+
for m in members
|
|
197
|
+
],
|
|
198
|
+
"count": len(members),
|
|
199
|
+
"limit": limit,
|
|
200
|
+
"offset": offset,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@router.post("/{name}/members")
|
|
205
|
+
async def add_group_members(name: str, request: AddMembersFromArnsRequest):
|
|
206
|
+
"""Add members to a group from ARNs.
|
|
207
|
+
|
|
208
|
+
Each item in 'arns' should have 'arn' and 'resource_type' keys.
|
|
209
|
+
The resource name will be extracted from the ARN.
|
|
210
|
+
"""
|
|
211
|
+
store = get_group_store()
|
|
212
|
+
|
|
213
|
+
if not store.exists(name):
|
|
214
|
+
raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
added = store.add_members_from_arns(name, request.arns)
|
|
218
|
+
return {"message": f"Added {added} members to group '{name}'", "added": added}
|
|
219
|
+
except ValueError as e:
|
|
220
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@router.delete("/{name}/members")
|
|
224
|
+
async def remove_group_member(
|
|
225
|
+
name: str,
|
|
226
|
+
resource_name: str = Query(..., description="Resource name to remove"),
|
|
227
|
+
resource_type: str = Query(..., description="Resource type"),
|
|
228
|
+
):
|
|
229
|
+
"""Remove a member from a group."""
|
|
230
|
+
store = get_group_store()
|
|
231
|
+
|
|
232
|
+
if not store.exists(name):
|
|
233
|
+
raise HTTPException(status_code=404, detail=f"Group '{name}' not found")
|
|
234
|
+
|
|
235
|
+
removed = store.remove_member(name, resource_name, resource_type)
|
|
236
|
+
|
|
237
|
+
if removed:
|
|
238
|
+
return {"message": f"Removed '{resource_name}' from group '{name}'"}
|
|
239
|
+
else:
|
|
240
|
+
return {"message": "Member not found in group", "removed": False}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Comparison endpoints
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@router.get("/{name}/compare/{snapshot}")
|
|
247
|
+
async def compare_group_to_snapshot(name: str, snapshot: str):
|
|
248
|
+
"""Compare a snapshot against a group.
|
|
249
|
+
|
|
250
|
+
Returns resources that are:
|
|
251
|
+
- matched: in both group and snapshot
|
|
252
|
+
- missing: in group but not in snapshot
|
|
253
|
+
- extra: in snapshot but not in group
|
|
254
|
+
"""
|
|
255
|
+
store = get_group_store()
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
result = store.compare_snapshot(name, snapshot)
|
|
259
|
+
return result
|
|
260
|
+
except ValueError as e:
|
|
261
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@router.get("/{name}/resources/in")
|
|
265
|
+
async def get_resources_in_group(
|
|
266
|
+
name: str,
|
|
267
|
+
snapshot: str = Query(..., description="Snapshot name"),
|
|
268
|
+
limit: int = Query(100, le=1000),
|
|
269
|
+
offset: int = Query(0, ge=0),
|
|
270
|
+
):
|
|
271
|
+
"""Get resources from a snapshot that ARE in the group."""
|
|
272
|
+
store = get_group_store()
|
|
273
|
+
|
|
274
|
+
try:
|
|
275
|
+
resources = store.get_resources_in_group(name, snapshot, limit=limit, offset=offset)
|
|
276
|
+
return {
|
|
277
|
+
"resources": resources,
|
|
278
|
+
"count": len(resources),
|
|
279
|
+
"limit": limit,
|
|
280
|
+
"offset": offset,
|
|
281
|
+
}
|
|
282
|
+
except ValueError as e:
|
|
283
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@router.get("/{name}/resources/not-in")
|
|
287
|
+
async def get_resources_not_in_group(
|
|
288
|
+
name: str,
|
|
289
|
+
snapshot: str = Query(..., description="Snapshot name"),
|
|
290
|
+
limit: int = Query(100, le=1000),
|
|
291
|
+
offset: int = Query(0, ge=0),
|
|
292
|
+
):
|
|
293
|
+
"""Get resources from a snapshot that are NOT in the group."""
|
|
294
|
+
store = get_group_store()
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
resources = store.get_resources_not_in_group(name, snapshot, limit=limit, offset=offset)
|
|
298
|
+
return {
|
|
299
|
+
"resources": resources,
|
|
300
|
+
"count": len(resources),
|
|
301
|
+
"limit": limit,
|
|
302
|
+
"offset": offset,
|
|
303
|
+
}
|
|
304
|
+
except ValueError as e:
|
|
305
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Inventory API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from ...dependencies import get_inventory_store, get_storage_path
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/inventories")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InventorySummary(BaseModel):
|
|
16
|
+
"""Inventory summary for list view."""
|
|
17
|
+
|
|
18
|
+
name: str
|
|
19
|
+
account_id: str
|
|
20
|
+
description: str
|
|
21
|
+
snapshot_count: int
|
|
22
|
+
include_tags: dict
|
|
23
|
+
exclude_tags: dict
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@router.get("")
|
|
27
|
+
async def list_inventories():
|
|
28
|
+
"""List all inventories."""
|
|
29
|
+
store = get_inventory_store()
|
|
30
|
+
inventories = store.list_all()
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"inventories": [
|
|
34
|
+
{
|
|
35
|
+
"name": inv.name,
|
|
36
|
+
"account_id": inv.account_id,
|
|
37
|
+
"description": inv.description or "",
|
|
38
|
+
"snapshot_count": len(inv.snapshots) if inv.snapshots else 0,
|
|
39
|
+
"include_tags": inv.include_tags or {},
|
|
40
|
+
"exclude_tags": inv.exclude_tags or {},
|
|
41
|
+
"created_at": inv.created_at.isoformat() if hasattr(inv.created_at, 'isoformat') else str(inv.created_at),
|
|
42
|
+
}
|
|
43
|
+
for inv in inventories
|
|
44
|
+
],
|
|
45
|
+
"count": len(inventories),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/{name}")
|
|
50
|
+
async def get_inventory(name: str, account_id: Optional[str] = None):
|
|
51
|
+
"""Get inventory details."""
|
|
52
|
+
store = get_inventory_store()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# If account_id not provided, search all inventories
|
|
56
|
+
inventory = None
|
|
57
|
+
if account_id:
|
|
58
|
+
inventory = store.load(name, account_id)
|
|
59
|
+
else:
|
|
60
|
+
# Find first matching inventory by name
|
|
61
|
+
all_invs = store.list_all()
|
|
62
|
+
inventory = next((inv for inv in all_invs if inv.name == name), None)
|
|
63
|
+
|
|
64
|
+
if not inventory:
|
|
65
|
+
raise HTTPException(status_code=404, detail=f"Inventory '{name}' not found")
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
"name": inventory.name,
|
|
69
|
+
"account_id": inventory.account_id,
|
|
70
|
+
"description": inventory.description or "",
|
|
71
|
+
"include_tags": inventory.include_tags or {},
|
|
72
|
+
"exclude_tags": inventory.exclude_tags or {},
|
|
73
|
+
"snapshots": inventory.snapshots or [],
|
|
74
|
+
"active_snapshot": inventory.active_snapshot,
|
|
75
|
+
"created_at": inventory.created_at.isoformat() if hasattr(inventory.created_at, 'isoformat') else str(inventory.created_at),
|
|
76
|
+
}
|
|
77
|
+
except HTTPException:
|
|
78
|
+
raise
|
|
79
|
+
except Exception as e:
|
|
80
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Saved queries API endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, HTTPException, Query
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from ...dependencies import get_database, get_resource_store
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/queries")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SavedQuery(BaseModel):
|
|
17
|
+
"""Saved query model."""
|
|
18
|
+
|
|
19
|
+
id: Optional[int] = None
|
|
20
|
+
name: str
|
|
21
|
+
description: Optional[str] = None
|
|
22
|
+
sql_text: str
|
|
23
|
+
category: str = "custom"
|
|
24
|
+
is_favorite: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class QueryResult(BaseModel):
|
|
28
|
+
"""Query execution result."""
|
|
29
|
+
|
|
30
|
+
columns: List[str]
|
|
31
|
+
rows: List[dict]
|
|
32
|
+
row_count: int
|
|
33
|
+
execution_time_ms: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@router.get("")
|
|
37
|
+
async def list_saved_queries(
|
|
38
|
+
category: Optional[str] = Query(None, description="Filter by category"),
|
|
39
|
+
favorites_only: bool = Query(False, description="Show only favorites"),
|
|
40
|
+
):
|
|
41
|
+
"""List saved queries."""
|
|
42
|
+
db = get_database()
|
|
43
|
+
|
|
44
|
+
sql = "SELECT * FROM saved_queries WHERE 1=1"
|
|
45
|
+
params: List = []
|
|
46
|
+
|
|
47
|
+
if category:
|
|
48
|
+
sql += " AND category = ?"
|
|
49
|
+
params.append(category)
|
|
50
|
+
|
|
51
|
+
if favorites_only:
|
|
52
|
+
sql += " AND is_favorite = 1"
|
|
53
|
+
|
|
54
|
+
sql += " ORDER BY is_favorite DESC, last_run_at DESC NULLS LAST, name"
|
|
55
|
+
|
|
56
|
+
rows = db.fetchall(sql, tuple(params))
|
|
57
|
+
return {"queries": [dict(r) for r in rows]}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@router.post("")
|
|
61
|
+
async def create_saved_query(query: SavedQuery):
|
|
62
|
+
"""Save a new query."""
|
|
63
|
+
db = get_database()
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
cursor = db.execute(
|
|
67
|
+
"""
|
|
68
|
+
INSERT INTO saved_queries (name, description, sql_text, category, is_favorite, created_at)
|
|
69
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
70
|
+
""",
|
|
71
|
+
(
|
|
72
|
+
query.name,
|
|
73
|
+
query.description,
|
|
74
|
+
query.sql_text,
|
|
75
|
+
query.category,
|
|
76
|
+
query.is_favorite,
|
|
77
|
+
datetime.utcnow().isoformat(),
|
|
78
|
+
),
|
|
79
|
+
)
|
|
80
|
+
db._conn.commit() # type: ignore
|
|
81
|
+
return {"id": cursor.lastrowid, "message": "Query saved"}
|
|
82
|
+
except Exception as e:
|
|
83
|
+
if "UNIQUE constraint" in str(e):
|
|
84
|
+
raise HTTPException(status_code=400, detail=f"Query with name '{query.name}' already exists")
|
|
85
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/{query_id}")
|
|
89
|
+
async def get_saved_query(query_id: int):
|
|
90
|
+
"""Get a saved query by ID."""
|
|
91
|
+
db = get_database()
|
|
92
|
+
row = db.fetchone("SELECT * FROM saved_queries WHERE id = ?", (query_id,))
|
|
93
|
+
|
|
94
|
+
if not row:
|
|
95
|
+
raise HTTPException(status_code=404, detail="Query not found")
|
|
96
|
+
|
|
97
|
+
return dict(row)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@router.put("/{query_id}")
|
|
101
|
+
async def update_saved_query(query_id: int, query: SavedQuery):
|
|
102
|
+
"""Update a saved query."""
|
|
103
|
+
db = get_database()
|
|
104
|
+
|
|
105
|
+
existing = db.fetchone("SELECT id FROM saved_queries WHERE id = ?", (query_id,))
|
|
106
|
+
if not existing:
|
|
107
|
+
raise HTTPException(status_code=404, detail="Query not found")
|
|
108
|
+
|
|
109
|
+
db.execute(
|
|
110
|
+
"""
|
|
111
|
+
UPDATE saved_queries
|
|
112
|
+
SET name = ?, description = ?, sql_text = ?, category = ?, is_favorite = ?
|
|
113
|
+
WHERE id = ?
|
|
114
|
+
""",
|
|
115
|
+
(query.name, query.description, query.sql_text, query.category, query.is_favorite, query_id),
|
|
116
|
+
)
|
|
117
|
+
db._conn.commit() # type: ignore
|
|
118
|
+
return {"message": "Query updated"}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.delete("/{query_id}")
|
|
122
|
+
async def delete_saved_query(query_id: int):
|
|
123
|
+
"""Delete a saved query."""
|
|
124
|
+
db = get_database()
|
|
125
|
+
|
|
126
|
+
existing = db.fetchone("SELECT id FROM saved_queries WHERE id = ?", (query_id,))
|
|
127
|
+
if not existing:
|
|
128
|
+
raise HTTPException(status_code=404, detail="Query not found")
|
|
129
|
+
|
|
130
|
+
db.execute("DELETE FROM saved_queries WHERE id = ?", (query_id,))
|
|
131
|
+
db._conn.commit() # type: ignore
|
|
132
|
+
return {"message": "Query deleted"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@router.post("/{query_id}/run")
|
|
136
|
+
async def run_saved_query(
|
|
137
|
+
query_id: int,
|
|
138
|
+
limit: int = Query(100, le=1000),
|
|
139
|
+
):
|
|
140
|
+
"""Execute a saved query."""
|
|
141
|
+
db = get_database()
|
|
142
|
+
|
|
143
|
+
row = db.fetchone("SELECT * FROM saved_queries WHERE id = ?", (query_id,))
|
|
144
|
+
if not row:
|
|
145
|
+
raise HTTPException(status_code=404, detail="Query not found")
|
|
146
|
+
|
|
147
|
+
sql_text = row["sql_text"]
|
|
148
|
+
|
|
149
|
+
# Update last_run_at and run_count
|
|
150
|
+
db.execute(
|
|
151
|
+
"""
|
|
152
|
+
UPDATE saved_queries
|
|
153
|
+
SET last_run_at = ?, run_count = run_count + 1
|
|
154
|
+
WHERE id = ?
|
|
155
|
+
""",
|
|
156
|
+
(datetime.utcnow().isoformat(), query_id),
|
|
157
|
+
)
|
|
158
|
+
db._conn.commit() # type: ignore
|
|
159
|
+
|
|
160
|
+
# Execute the query
|
|
161
|
+
return await _execute_sql(sql_text, limit)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@router.post("/execute")
|
|
165
|
+
async def execute_sql(
|
|
166
|
+
sql: str,
|
|
167
|
+
limit: int = Query(100, le=1000),
|
|
168
|
+
):
|
|
169
|
+
"""Execute an ad-hoc SQL query."""
|
|
170
|
+
return await _execute_sql(sql, limit)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
async def _execute_sql(sql: str, limit: int) -> dict:
|
|
174
|
+
"""Execute SQL and return results."""
|
|
175
|
+
import time
|
|
176
|
+
|
|
177
|
+
store = get_resource_store()
|
|
178
|
+
|
|
179
|
+
# Add limit if not present
|
|
180
|
+
sql_lower = sql.lower().strip()
|
|
181
|
+
if "limit" not in sql_lower:
|
|
182
|
+
sql = f"{sql.rstrip(';')} LIMIT {limit}"
|
|
183
|
+
|
|
184
|
+
start = time.time()
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
results = store.query_raw(sql)
|
|
188
|
+
elapsed = (time.time() - start) * 1000
|
|
189
|
+
|
|
190
|
+
if results:
|
|
191
|
+
columns = list(results[0].keys())
|
|
192
|
+
else:
|
|
193
|
+
columns = []
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"columns": columns,
|
|
197
|
+
"rows": results,
|
|
198
|
+
"row_count": len(results),
|
|
199
|
+
"execution_time_ms": round(elapsed, 2),
|
|
200
|
+
}
|
|
201
|
+
except Exception as e:
|
|
202
|
+
raise HTTPException(status_code=400, detail=str(e))
|