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.
- aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
- aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
- aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.17.12.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 +4046 -0
- src/cloudtrail/__init__.py +5 -0
- src/cloudtrail/query.py +642 -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/matching/__init__.py +6 -0
- src/matching/config.py +52 -0
- src/matching/normalizer.py +450 -0
- src/matching/prompts.py +33 -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 +453 -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/glue.py +199 -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 +763 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +416 -0
- src/storage/schema.py +339 -0
- src/storage/snapshot_store.py +363 -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 +393 -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 +955 -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 +2429 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- src/web/templates/pages/snapshots.html +429 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""Resource Group storage operations for SQLite backend."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..models.group import GroupMember, ResourceGroup, extract_resource_name
|
|
8
|
+
from .database import Database
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GroupStore:
|
|
14
|
+
"""CRUD and query operations for resource groups in SQLite database."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, db: Database):
|
|
17
|
+
"""Initialize group store.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
db: Database connection manager
|
|
21
|
+
"""
|
|
22
|
+
self.db = db
|
|
23
|
+
|
|
24
|
+
def save(self, group: ResourceGroup) -> int:
|
|
25
|
+
"""Save or update resource group in database.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
group: ResourceGroup to save
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Database ID of saved group
|
|
32
|
+
"""
|
|
33
|
+
now = datetime.now(timezone.utc)
|
|
34
|
+
|
|
35
|
+
with self.db.transaction() as cursor:
|
|
36
|
+
# Check if group exists
|
|
37
|
+
existing = self.db.fetchone(
|
|
38
|
+
"SELECT id FROM resource_groups WHERE name = ?",
|
|
39
|
+
(group.name,),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if existing:
|
|
43
|
+
# Update existing
|
|
44
|
+
cursor.execute(
|
|
45
|
+
"""
|
|
46
|
+
UPDATE resource_groups SET
|
|
47
|
+
description = ?,
|
|
48
|
+
source_snapshot = ?,
|
|
49
|
+
resource_count = ?,
|
|
50
|
+
is_favorite = ?,
|
|
51
|
+
last_updated = ?
|
|
52
|
+
WHERE id = ?
|
|
53
|
+
""",
|
|
54
|
+
(
|
|
55
|
+
group.description,
|
|
56
|
+
group.source_snapshot,
|
|
57
|
+
len(group.members),
|
|
58
|
+
group.is_favorite,
|
|
59
|
+
now.isoformat(),
|
|
60
|
+
existing["id"],
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
group_id = existing["id"]
|
|
64
|
+
|
|
65
|
+
# Update members - clear and re-add
|
|
66
|
+
cursor.execute("DELETE FROM resource_group_members WHERE group_id = ?", (group_id,))
|
|
67
|
+
self._insert_members(cursor, group_id, group.members)
|
|
68
|
+
|
|
69
|
+
logger.debug(f"Updated group '{group.name}' (id={group_id})")
|
|
70
|
+
else:
|
|
71
|
+
# Insert new
|
|
72
|
+
cursor.execute(
|
|
73
|
+
"""
|
|
74
|
+
INSERT INTO resource_groups (
|
|
75
|
+
name, description, source_snapshot, resource_count,
|
|
76
|
+
is_favorite, created_at, last_updated
|
|
77
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
78
|
+
""",
|
|
79
|
+
(
|
|
80
|
+
group.name,
|
|
81
|
+
group.description,
|
|
82
|
+
group.source_snapshot,
|
|
83
|
+
len(group.members),
|
|
84
|
+
group.is_favorite,
|
|
85
|
+
now.isoformat(),
|
|
86
|
+
now.isoformat(),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
group_id = cursor.lastrowid
|
|
90
|
+
|
|
91
|
+
# Insert members
|
|
92
|
+
self._insert_members(cursor, group_id, group.members)
|
|
93
|
+
|
|
94
|
+
logger.debug(f"Saved group '{group.name}' (id={group_id})")
|
|
95
|
+
|
|
96
|
+
return group_id
|
|
97
|
+
|
|
98
|
+
def _insert_members(self, cursor, group_id: int, members: List[GroupMember]) -> None:
|
|
99
|
+
"""Insert group members.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
cursor: Database cursor
|
|
103
|
+
group_id: Group ID
|
|
104
|
+
members: List of members to insert
|
|
105
|
+
"""
|
|
106
|
+
for member in members:
|
|
107
|
+
cursor.execute(
|
|
108
|
+
"""
|
|
109
|
+
INSERT OR IGNORE INTO resource_group_members
|
|
110
|
+
(group_id, resource_name, resource_type, original_arn, match_strategy)
|
|
111
|
+
VALUES (?, ?, ?, ?, ?)
|
|
112
|
+
""",
|
|
113
|
+
(group_id, member.resource_name, member.resource_type, member.original_arn, member.match_strategy),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def load(self, name: str) -> Optional[ResourceGroup]:
|
|
117
|
+
"""Load resource group by name.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
name: Group name
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
ResourceGroup object or None if not found
|
|
124
|
+
"""
|
|
125
|
+
row = self.db.fetchone(
|
|
126
|
+
"SELECT * FROM resource_groups WHERE name = ?",
|
|
127
|
+
(name,),
|
|
128
|
+
)
|
|
129
|
+
if not row:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
return self._row_to_group(row, include_members=True)
|
|
133
|
+
|
|
134
|
+
def _row_to_group(self, row: Dict[str, Any], include_members: bool = False) -> ResourceGroup:
|
|
135
|
+
"""Convert database row to ResourceGroup object.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
row: Database row dict
|
|
139
|
+
include_members: Whether to load members
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
ResourceGroup object
|
|
143
|
+
"""
|
|
144
|
+
group_id = row["id"]
|
|
145
|
+
|
|
146
|
+
# Parse timestamps
|
|
147
|
+
created_at = datetime.fromisoformat(row["created_at"])
|
|
148
|
+
if created_at.tzinfo is None:
|
|
149
|
+
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
150
|
+
|
|
151
|
+
last_updated = datetime.fromisoformat(row["last_updated"])
|
|
152
|
+
if last_updated.tzinfo is None:
|
|
153
|
+
last_updated = last_updated.replace(tzinfo=timezone.utc)
|
|
154
|
+
|
|
155
|
+
# Load members if requested
|
|
156
|
+
members = []
|
|
157
|
+
if include_members:
|
|
158
|
+
member_rows = self.db.fetchall(
|
|
159
|
+
"SELECT * FROM resource_group_members WHERE group_id = ? ORDER BY resource_type, resource_name",
|
|
160
|
+
(group_id,),
|
|
161
|
+
)
|
|
162
|
+
members = [
|
|
163
|
+
GroupMember(
|
|
164
|
+
resource_name=r["resource_name"],
|
|
165
|
+
resource_type=r["resource_type"],
|
|
166
|
+
original_arn=r["original_arn"],
|
|
167
|
+
match_strategy=r.get("match_strategy", "physical_name"),
|
|
168
|
+
)
|
|
169
|
+
for r in member_rows
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
return ResourceGroup(
|
|
173
|
+
id=group_id,
|
|
174
|
+
name=row["name"],
|
|
175
|
+
description=row["description"] or "",
|
|
176
|
+
source_snapshot=row["source_snapshot"],
|
|
177
|
+
members=members,
|
|
178
|
+
resource_count=row["resource_count"],
|
|
179
|
+
is_favorite=bool(row["is_favorite"]),
|
|
180
|
+
created_at=created_at,
|
|
181
|
+
last_updated=last_updated,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
def list_all(self) -> List[Dict[str, Any]]:
|
|
185
|
+
"""List all groups (metadata only, no members).
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
List of group metadata dictionaries
|
|
189
|
+
"""
|
|
190
|
+
rows = self.db.fetchall(
|
|
191
|
+
"SELECT * FROM resource_groups ORDER BY is_favorite DESC, name"
|
|
192
|
+
)
|
|
193
|
+
results = []
|
|
194
|
+
for row in rows:
|
|
195
|
+
created_at = row["created_at"]
|
|
196
|
+
last_updated = row["last_updated"]
|
|
197
|
+
# Convert datetime to ISO string for JSON serialization
|
|
198
|
+
if hasattr(created_at, "isoformat"):
|
|
199
|
+
created_at = created_at.isoformat()
|
|
200
|
+
if hasattr(last_updated, "isoformat"):
|
|
201
|
+
last_updated = last_updated.isoformat()
|
|
202
|
+
results.append({
|
|
203
|
+
"id": row["id"],
|
|
204
|
+
"name": row["name"],
|
|
205
|
+
"description": row["description"] or "",
|
|
206
|
+
"source_snapshot": row["source_snapshot"],
|
|
207
|
+
"resource_count": row["resource_count"],
|
|
208
|
+
"is_favorite": bool(row["is_favorite"]),
|
|
209
|
+
"created_at": created_at,
|
|
210
|
+
"last_updated": last_updated,
|
|
211
|
+
})
|
|
212
|
+
return results
|
|
213
|
+
|
|
214
|
+
def delete(self, name: str) -> bool:
|
|
215
|
+
"""Delete group by name.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
name: Group name
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
True if deleted, False if not found
|
|
222
|
+
"""
|
|
223
|
+
with self.db.transaction() as cursor:
|
|
224
|
+
cursor.execute("DELETE FROM resource_groups WHERE name = ?", (name,))
|
|
225
|
+
deleted = cursor.rowcount > 0
|
|
226
|
+
|
|
227
|
+
if deleted:
|
|
228
|
+
logger.debug(f"Deleted group '{name}'")
|
|
229
|
+
return deleted
|
|
230
|
+
|
|
231
|
+
def exists(self, name: str) -> bool:
|
|
232
|
+
"""Check if group exists.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
name: Group name
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
True if exists
|
|
239
|
+
"""
|
|
240
|
+
row = self.db.fetchone(
|
|
241
|
+
"SELECT 1 FROM resource_groups WHERE name = ?",
|
|
242
|
+
(name,),
|
|
243
|
+
)
|
|
244
|
+
return row is not None
|
|
245
|
+
|
|
246
|
+
def get_id(self, name: str) -> Optional[int]:
|
|
247
|
+
"""Get database ID for group.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
name: Group name
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Database ID or None
|
|
254
|
+
"""
|
|
255
|
+
row = self.db.fetchone(
|
|
256
|
+
"SELECT id FROM resource_groups WHERE name = ?",
|
|
257
|
+
(name,),
|
|
258
|
+
)
|
|
259
|
+
return row["id"] if row else None
|
|
260
|
+
|
|
261
|
+
def toggle_favorite(self, name: str) -> Optional[bool]:
|
|
262
|
+
"""Toggle favorite status of a group.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
name: Group name
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
New favorite status, or None if not found
|
|
269
|
+
"""
|
|
270
|
+
row = self.db.fetchone(
|
|
271
|
+
"SELECT id, is_favorite FROM resource_groups WHERE name = ?",
|
|
272
|
+
(name,),
|
|
273
|
+
)
|
|
274
|
+
if not row:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
new_favorite = not row["is_favorite"]
|
|
278
|
+
with self.db.transaction() as cursor:
|
|
279
|
+
cursor.execute(
|
|
280
|
+
"UPDATE resource_groups SET is_favorite = ?, last_updated = ? WHERE id = ?",
|
|
281
|
+
(new_favorite, datetime.now(timezone.utc).isoformat(), row["id"]),
|
|
282
|
+
)
|
|
283
|
+
return new_favorite
|
|
284
|
+
|
|
285
|
+
# Member management methods
|
|
286
|
+
|
|
287
|
+
def add_members(self, group_name: str, members: List[GroupMember]) -> int:
|
|
288
|
+
"""Add members to a group.
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
group_name: Group name
|
|
292
|
+
members: List of members to add
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Number of members added
|
|
296
|
+
"""
|
|
297
|
+
group_id = self.get_id(group_name)
|
|
298
|
+
if not group_id:
|
|
299
|
+
raise ValueError(f"Group '{group_name}' not found")
|
|
300
|
+
|
|
301
|
+
added = 0
|
|
302
|
+
with self.db.transaction() as cursor:
|
|
303
|
+
for member in members:
|
|
304
|
+
try:
|
|
305
|
+
cursor.execute(
|
|
306
|
+
"""
|
|
307
|
+
INSERT INTO resource_group_members
|
|
308
|
+
(group_id, resource_name, resource_type, original_arn, match_strategy)
|
|
309
|
+
VALUES (?, ?, ?, ?, ?)
|
|
310
|
+
""",
|
|
311
|
+
(group_id, member.resource_name, member.resource_type, member.original_arn, member.match_strategy),
|
|
312
|
+
)
|
|
313
|
+
added += 1
|
|
314
|
+
except Exception:
|
|
315
|
+
# Unique constraint violation - member already exists
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
# Update resource count
|
|
319
|
+
cursor.execute(
|
|
320
|
+
"""
|
|
321
|
+
UPDATE resource_groups
|
|
322
|
+
SET resource_count = (SELECT COUNT(*) FROM resource_group_members WHERE group_id = ?),
|
|
323
|
+
last_updated = ?
|
|
324
|
+
WHERE id = ?
|
|
325
|
+
""",
|
|
326
|
+
(group_id, datetime.now(timezone.utc).isoformat(), group_id),
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
return added
|
|
330
|
+
|
|
331
|
+
def add_members_from_arns(self, group_name: str, arns: List[Dict[str, str]]) -> int:
|
|
332
|
+
"""Add members to a group from a list of ARNs with types.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
group_name: Group name
|
|
336
|
+
arns: List of dicts with 'arn', 'resource_type' keys, and optional 'logical_id'
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
Number of members added
|
|
340
|
+
"""
|
|
341
|
+
members = []
|
|
342
|
+
for item in arns:
|
|
343
|
+
arn = item["arn"]
|
|
344
|
+
resource_type = item["resource_type"]
|
|
345
|
+
logical_id = item.get("logical_id")
|
|
346
|
+
|
|
347
|
+
if logical_id:
|
|
348
|
+
# Use logical ID for stable matching across resource recreations
|
|
349
|
+
resource_name = logical_id
|
|
350
|
+
match_strategy = "logical_id"
|
|
351
|
+
else:
|
|
352
|
+
# Fallback to physical name from ARN
|
|
353
|
+
resource_name = extract_resource_name(arn, resource_type)
|
|
354
|
+
match_strategy = "physical_name"
|
|
355
|
+
|
|
356
|
+
members.append(GroupMember(resource_name, resource_type, arn, match_strategy))
|
|
357
|
+
|
|
358
|
+
return self.add_members(group_name, members)
|
|
359
|
+
|
|
360
|
+
def remove_member(self, group_name: str, resource_name: str, resource_type: str) -> bool:
|
|
361
|
+
"""Remove a member from a group.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
group_name: Group name
|
|
365
|
+
resource_name: Resource name
|
|
366
|
+
resource_type: Resource type
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
True if removed, False if not found
|
|
370
|
+
"""
|
|
371
|
+
group_id = self.get_id(group_name)
|
|
372
|
+
if not group_id:
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
with self.db.transaction() as cursor:
|
|
376
|
+
cursor.execute(
|
|
377
|
+
"""
|
|
378
|
+
DELETE FROM resource_group_members
|
|
379
|
+
WHERE group_id = ? AND resource_name = ? AND resource_type = ?
|
|
380
|
+
""",
|
|
381
|
+
(group_id, resource_name, resource_type),
|
|
382
|
+
)
|
|
383
|
+
removed = cursor.rowcount > 0
|
|
384
|
+
|
|
385
|
+
if removed:
|
|
386
|
+
# Update resource count
|
|
387
|
+
cursor.execute(
|
|
388
|
+
"""
|
|
389
|
+
UPDATE resource_groups
|
|
390
|
+
SET resource_count = (SELECT COUNT(*) FROM resource_group_members WHERE group_id = ?),
|
|
391
|
+
last_updated = ?
|
|
392
|
+
WHERE id = ?
|
|
393
|
+
""",
|
|
394
|
+
(group_id, datetime.now(timezone.utc).isoformat(), group_id),
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
return removed
|
|
398
|
+
|
|
399
|
+
def get_members(
|
|
400
|
+
self,
|
|
401
|
+
group_name: str,
|
|
402
|
+
limit: int = 100,
|
|
403
|
+
offset: int = 0,
|
|
404
|
+
) -> List[GroupMember]:
|
|
405
|
+
"""Get members of a group with pagination.
|
|
406
|
+
|
|
407
|
+
Args:
|
|
408
|
+
group_name: Group name
|
|
409
|
+
limit: Maximum results
|
|
410
|
+
offset: Offset for pagination
|
|
411
|
+
|
|
412
|
+
Returns:
|
|
413
|
+
List of GroupMember objects
|
|
414
|
+
"""
|
|
415
|
+
group_id = self.get_id(group_name)
|
|
416
|
+
if not group_id:
|
|
417
|
+
return []
|
|
418
|
+
|
|
419
|
+
rows = self.db.fetchall(
|
|
420
|
+
"""
|
|
421
|
+
SELECT * FROM resource_group_members
|
|
422
|
+
WHERE group_id = ?
|
|
423
|
+
ORDER BY resource_type, resource_name
|
|
424
|
+
LIMIT ? OFFSET ?
|
|
425
|
+
""",
|
|
426
|
+
(group_id, limit, offset),
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
return [
|
|
430
|
+
GroupMember(
|
|
431
|
+
resource_name=r["resource_name"],
|
|
432
|
+
resource_type=r["resource_type"],
|
|
433
|
+
original_arn=r["original_arn"],
|
|
434
|
+
match_strategy=r.get("match_strategy", "physical_name"),
|
|
435
|
+
)
|
|
436
|
+
for r in rows
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
# Group creation and comparison methods
|
|
440
|
+
|
|
441
|
+
def create_from_snapshot(
|
|
442
|
+
self,
|
|
443
|
+
group_name: str,
|
|
444
|
+
snapshot_name: str,
|
|
445
|
+
description: str = "",
|
|
446
|
+
type_filter: Optional[str] = None,
|
|
447
|
+
region_filter: Optional[str] = None,
|
|
448
|
+
) -> int:
|
|
449
|
+
"""Create a group from all resources in a snapshot.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
group_name: Name for the new group
|
|
453
|
+
snapshot_name: Source snapshot name
|
|
454
|
+
description: Group description
|
|
455
|
+
type_filter: Optional resource type filter
|
|
456
|
+
region_filter: Optional region filter
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
Number of resources added to the group
|
|
460
|
+
"""
|
|
461
|
+
# Get snapshot ID
|
|
462
|
+
snap_row = self.db.fetchone(
|
|
463
|
+
"SELECT id FROM snapshots WHERE name = ?",
|
|
464
|
+
(snapshot_name,),
|
|
465
|
+
)
|
|
466
|
+
if not snap_row:
|
|
467
|
+
raise ValueError(f"Snapshot '{snapshot_name}' not found")
|
|
468
|
+
|
|
469
|
+
snapshot_id = snap_row["id"]
|
|
470
|
+
|
|
471
|
+
# Build query for resources
|
|
472
|
+
conditions = ["r.snapshot_id = ?"]
|
|
473
|
+
params: List[Any] = [snapshot_id]
|
|
474
|
+
|
|
475
|
+
if type_filter:
|
|
476
|
+
if ":" in type_filter:
|
|
477
|
+
conditions.append("r.resource_type = ?")
|
|
478
|
+
else:
|
|
479
|
+
conditions.append("r.resource_type LIKE ?")
|
|
480
|
+
type_filter = f"%{type_filter}%"
|
|
481
|
+
params.append(type_filter)
|
|
482
|
+
|
|
483
|
+
if region_filter:
|
|
484
|
+
conditions.append("r.region = ?")
|
|
485
|
+
params.append(region_filter)
|
|
486
|
+
|
|
487
|
+
query = f"""
|
|
488
|
+
SELECT r.arn, r.resource_type, r.name, r.canonical_name,
|
|
489
|
+
r.normalized_name, r.normalization_method
|
|
490
|
+
FROM resources r
|
|
491
|
+
WHERE {" AND ".join(conditions)}
|
|
492
|
+
ORDER BY r.resource_type, r.name
|
|
493
|
+
"""
|
|
494
|
+
|
|
495
|
+
resource_rows = self.db.fetchall(query, tuple(params))
|
|
496
|
+
|
|
497
|
+
# Create group with members
|
|
498
|
+
# Choose the best match strategy based on how the resource was normalized
|
|
499
|
+
members = []
|
|
500
|
+
for row in resource_rows:
|
|
501
|
+
physical_name = row["name"] or extract_resource_name(row["arn"], row["resource_type"])
|
|
502
|
+
normalized_name = row.get("normalized_name")
|
|
503
|
+
normalization_method = row.get("normalization_method") or "none"
|
|
504
|
+
|
|
505
|
+
# Choose match strategy based on normalization method
|
|
506
|
+
if normalization_method == "tag:logical-id":
|
|
507
|
+
# CloudFormation logical ID - most reliable
|
|
508
|
+
resource_name = row.get("canonical_name") or normalized_name
|
|
509
|
+
match_strategy = "logical_id"
|
|
510
|
+
elif normalization_method in ("tag:Name", "pattern"):
|
|
511
|
+
# Name tag or pattern extraction - use normalized name
|
|
512
|
+
resource_name = normalized_name or physical_name
|
|
513
|
+
match_strategy = "normalized"
|
|
514
|
+
else:
|
|
515
|
+
# No normalization - use physical name
|
|
516
|
+
resource_name = physical_name
|
|
517
|
+
match_strategy = "physical_name"
|
|
518
|
+
|
|
519
|
+
members.append(
|
|
520
|
+
GroupMember(
|
|
521
|
+
resource_name=resource_name,
|
|
522
|
+
resource_type=row["resource_type"],
|
|
523
|
+
original_arn=row["arn"],
|
|
524
|
+
match_strategy=match_strategy,
|
|
525
|
+
)
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
group = ResourceGroup(
|
|
529
|
+
name=group_name,
|
|
530
|
+
description=description,
|
|
531
|
+
source_snapshot=snapshot_name,
|
|
532
|
+
members=members,
|
|
533
|
+
resource_count=len(members),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
self.save(group)
|
|
537
|
+
logger.info(f"Created group '{group_name}' with {len(members)} resources from snapshot '{snapshot_name}'")
|
|
538
|
+
|
|
539
|
+
return len(members)
|
|
540
|
+
|
|
541
|
+
def compare_snapshot(
|
|
542
|
+
self,
|
|
543
|
+
group_name: str,
|
|
544
|
+
snapshot_name: str,
|
|
545
|
+
) -> Dict[str, Any]:
|
|
546
|
+
"""Compare a snapshot against a group.
|
|
547
|
+
|
|
548
|
+
Resources are matched by name + type.
|
|
549
|
+
|
|
550
|
+
Args:
|
|
551
|
+
group_name: Group name
|
|
552
|
+
snapshot_name: Snapshot to compare
|
|
553
|
+
|
|
554
|
+
Returns:
|
|
555
|
+
Comparison results with matched, missing, and extra resources
|
|
556
|
+
"""
|
|
557
|
+
# Get group members
|
|
558
|
+
group = self.load(group_name)
|
|
559
|
+
if not group:
|
|
560
|
+
raise ValueError(f"Group '{group_name}' not found")
|
|
561
|
+
|
|
562
|
+
# Get snapshot resources
|
|
563
|
+
snap_row = self.db.fetchone(
|
|
564
|
+
"SELECT id FROM snapshots WHERE name = ?",
|
|
565
|
+
(snapshot_name,),
|
|
566
|
+
)
|
|
567
|
+
if not snap_row:
|
|
568
|
+
raise ValueError(f"Snapshot '{snapshot_name}' not found")
|
|
569
|
+
|
|
570
|
+
resource_rows = self.db.fetchall(
|
|
571
|
+
"""
|
|
572
|
+
SELECT r.arn, r.resource_type, r.name, r.region
|
|
573
|
+
FROM resources r
|
|
574
|
+
WHERE r.snapshot_id = ?
|
|
575
|
+
""",
|
|
576
|
+
(snap_row["id"],),
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
# Build sets for comparison
|
|
580
|
+
group_set = {(m.resource_name, m.resource_type) for m in group.members}
|
|
581
|
+
snapshot_resources = {}
|
|
582
|
+
for row in resource_rows:
|
|
583
|
+
resource_name = row["name"] or extract_resource_name(row["arn"], row["resource_type"])
|
|
584
|
+
key = (resource_name, row["resource_type"])
|
|
585
|
+
snapshot_resources[key] = {
|
|
586
|
+
"arn": row["arn"],
|
|
587
|
+
"resource_type": row["resource_type"],
|
|
588
|
+
"name": resource_name,
|
|
589
|
+
"region": row["region"],
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
snapshot_set = set(snapshot_resources.keys())
|
|
593
|
+
|
|
594
|
+
# Calculate differences
|
|
595
|
+
matched_keys = group_set & snapshot_set
|
|
596
|
+
missing_keys = group_set - snapshot_set # In group but not in snapshot
|
|
597
|
+
extra_keys = snapshot_set - group_set # In snapshot but not in group
|
|
598
|
+
|
|
599
|
+
# Build result lists
|
|
600
|
+
matched = [snapshot_resources[k] for k in matched_keys]
|
|
601
|
+
missing = [
|
|
602
|
+
{"name": k[0], "resource_type": k[1]}
|
|
603
|
+
for k in missing_keys
|
|
604
|
+
]
|
|
605
|
+
extra = [snapshot_resources[k] for k in extra_keys]
|
|
606
|
+
|
|
607
|
+
return {
|
|
608
|
+
"group_name": group_name,
|
|
609
|
+
"snapshot_name": snapshot_name,
|
|
610
|
+
"total_in_group": len(group.members),
|
|
611
|
+
"total_in_snapshot": len(snapshot_resources),
|
|
612
|
+
"matched": len(matched),
|
|
613
|
+
"missing_from_snapshot": len(missing),
|
|
614
|
+
"not_in_group": len(extra),
|
|
615
|
+
"resources": {
|
|
616
|
+
"matched": sorted(matched, key=lambda x: (x["resource_type"], x["name"])),
|
|
617
|
+
"missing": sorted(missing, key=lambda x: (x["resource_type"], x["name"])),
|
|
618
|
+
"extra": sorted(extra, key=lambda x: (x["resource_type"], x["name"])),
|
|
619
|
+
},
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
def is_resource_in_group(
|
|
623
|
+
self,
|
|
624
|
+
group_name: str,
|
|
625
|
+
resource_name: str,
|
|
626
|
+
resource_type: str,
|
|
627
|
+
) -> bool:
|
|
628
|
+
"""Check if a resource is in a group.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
group_name: Group name
|
|
632
|
+
resource_name: Resource name
|
|
633
|
+
resource_type: Resource type
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
True if resource is in the group
|
|
637
|
+
"""
|
|
638
|
+
group_id = self.get_id(group_name)
|
|
639
|
+
if not group_id:
|
|
640
|
+
return False
|
|
641
|
+
|
|
642
|
+
row = self.db.fetchone(
|
|
643
|
+
"""
|
|
644
|
+
SELECT 1 FROM resource_group_members
|
|
645
|
+
WHERE group_id = ? AND resource_name = ? AND resource_type = ?
|
|
646
|
+
""",
|
|
647
|
+
(group_id, resource_name, resource_type),
|
|
648
|
+
)
|
|
649
|
+
return row is not None
|
|
650
|
+
|
|
651
|
+
def get_resources_not_in_group(
|
|
652
|
+
self,
|
|
653
|
+
group_name: str,
|
|
654
|
+
snapshot_name: str,
|
|
655
|
+
limit: int = 100,
|
|
656
|
+
offset: int = 0,
|
|
657
|
+
) -> List[Dict[str, Any]]:
|
|
658
|
+
"""Get resources from a snapshot that are NOT in a group.
|
|
659
|
+
|
|
660
|
+
Args:
|
|
661
|
+
group_name: Group name
|
|
662
|
+
snapshot_name: Snapshot name
|
|
663
|
+
limit: Maximum results
|
|
664
|
+
offset: Offset for pagination
|
|
665
|
+
|
|
666
|
+
Returns:
|
|
667
|
+
List of resources not in the group
|
|
668
|
+
"""
|
|
669
|
+
group_id = self.get_id(group_name)
|
|
670
|
+
if not group_id:
|
|
671
|
+
raise ValueError(f"Group '{group_name}' not found")
|
|
672
|
+
|
|
673
|
+
snap_row = self.db.fetchone(
|
|
674
|
+
"SELECT id FROM snapshots WHERE name = ?",
|
|
675
|
+
(snapshot_name,),
|
|
676
|
+
)
|
|
677
|
+
if not snap_row:
|
|
678
|
+
raise ValueError(f"Snapshot '{snapshot_name}' not found")
|
|
679
|
+
|
|
680
|
+
# Use NOT EXISTS to find resources not in group
|
|
681
|
+
# Match strategy determines how to compare:
|
|
682
|
+
# - 'logical_id': match on canonical_name (CloudFormation logical ID)
|
|
683
|
+
# - 'normalized': match on normalized_name (pattern-stripped semantic name)
|
|
684
|
+
# - 'physical_name': match on physical name or ARN
|
|
685
|
+
rows = self.db.fetchall(
|
|
686
|
+
"""
|
|
687
|
+
SELECT r.arn, r.resource_type, r.name, r.region, r.created_at,
|
|
688
|
+
r.canonical_name, r.normalized_name, r.normalization_method
|
|
689
|
+
FROM resources r
|
|
690
|
+
WHERE r.snapshot_id = ?
|
|
691
|
+
AND NOT EXISTS (
|
|
692
|
+
SELECT 1 FROM resource_group_members gm
|
|
693
|
+
WHERE gm.group_id = ?
|
|
694
|
+
AND r.resource_type = gm.resource_type
|
|
695
|
+
AND (
|
|
696
|
+
(gm.match_strategy = 'logical_id' AND r.canonical_name = gm.resource_name)
|
|
697
|
+
OR (gm.match_strategy = 'normalized' AND r.normalized_name = gm.resource_name)
|
|
698
|
+
OR (COALESCE(gm.match_strategy, 'physical_name') = 'physical_name' AND COALESCE(r.name, r.arn) = gm.resource_name)
|
|
699
|
+
)
|
|
700
|
+
)
|
|
701
|
+
ORDER BY r.resource_type, r.name
|
|
702
|
+
LIMIT ? OFFSET ?
|
|
703
|
+
""",
|
|
704
|
+
(snap_row["id"], group_id, limit, offset),
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
return [dict(r) for r in rows]
|
|
708
|
+
|
|
709
|
+
def get_resources_in_group(
|
|
710
|
+
self,
|
|
711
|
+
group_name: str,
|
|
712
|
+
snapshot_name: str,
|
|
713
|
+
limit: int = 100,
|
|
714
|
+
offset: int = 0,
|
|
715
|
+
) -> List[Dict[str, Any]]:
|
|
716
|
+
"""Get resources from a snapshot that ARE in a group.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
group_name: Group name
|
|
720
|
+
snapshot_name: Snapshot name
|
|
721
|
+
limit: Maximum results
|
|
722
|
+
offset: Offset for pagination
|
|
723
|
+
|
|
724
|
+
Returns:
|
|
725
|
+
List of resources in the group
|
|
726
|
+
"""
|
|
727
|
+
group_id = self.get_id(group_name)
|
|
728
|
+
if not group_id:
|
|
729
|
+
raise ValueError(f"Group '{group_name}' not found")
|
|
730
|
+
|
|
731
|
+
snap_row = self.db.fetchone(
|
|
732
|
+
"SELECT id FROM snapshots WHERE name = ?",
|
|
733
|
+
(snapshot_name,),
|
|
734
|
+
)
|
|
735
|
+
if not snap_row:
|
|
736
|
+
raise ValueError(f"Snapshot '{snapshot_name}' not found")
|
|
737
|
+
|
|
738
|
+
# Use INNER JOIN to find resources in group
|
|
739
|
+
# Match strategy determines how to compare:
|
|
740
|
+
# - 'logical_id': match on canonical_name (CloudFormation logical ID)
|
|
741
|
+
# - 'normalized': match on normalized_name (pattern-stripped semantic name)
|
|
742
|
+
# - 'physical_name': match on physical name or ARN
|
|
743
|
+
rows = self.db.fetchall(
|
|
744
|
+
"""
|
|
745
|
+
SELECT r.arn, r.resource_type, r.name, r.region, r.created_at,
|
|
746
|
+
r.canonical_name, r.normalized_name, r.normalization_method
|
|
747
|
+
FROM resources r
|
|
748
|
+
INNER JOIN resource_group_members gm
|
|
749
|
+
ON (
|
|
750
|
+
(gm.match_strategy = 'logical_id' AND r.canonical_name = gm.resource_name)
|
|
751
|
+
OR (gm.match_strategy = 'normalized' AND r.normalized_name = gm.resource_name)
|
|
752
|
+
OR (COALESCE(gm.match_strategy, 'physical_name') = 'physical_name' AND COALESCE(r.name, r.arn) = gm.resource_name)
|
|
753
|
+
)
|
|
754
|
+
AND r.resource_type = gm.resource_type
|
|
755
|
+
AND gm.group_id = ?
|
|
756
|
+
WHERE r.snapshot_id = ?
|
|
757
|
+
ORDER BY r.resource_type, r.name
|
|
758
|
+
LIMIT ? OFFSET ?
|
|
759
|
+
""",
|
|
760
|
+
(group_id, snap_row["id"], limit, offset),
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
return [dict(r) for r in rows]
|