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,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]