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.

Files changed (145) hide show
  1. aws_inventory_manager-0.13.2.dist-info/LICENSE +21 -0
  2. aws_inventory_manager-0.13.2.dist-info/METADATA +1226 -0
  3. aws_inventory_manager-0.13.2.dist-info/RECORD +145 -0
  4. aws_inventory_manager-0.13.2.dist-info/WHEEL +5 -0
  5. aws_inventory_manager-0.13.2.dist-info/entry_points.txt +2 -0
  6. aws_inventory_manager-0.13.2.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 +3626 -0
  15. src/config_service/__init__.py +21 -0
  16. src/config_service/collector.py +346 -0
  17. src/config_service/detector.py +256 -0
  18. src/config_service/resource_type_mapping.py +328 -0
  19. src/cost/__init__.py +5 -0
  20. src/cost/analyzer.py +226 -0
  21. src/cost/explorer.py +209 -0
  22. src/cost/reporter.py +237 -0
  23. src/delta/__init__.py +5 -0
  24. src/delta/calculator.py +206 -0
  25. src/delta/differ.py +185 -0
  26. src/delta/formatters.py +272 -0
  27. src/delta/models.py +154 -0
  28. src/delta/reporter.py +234 -0
  29. src/models/__init__.py +21 -0
  30. src/models/config_diff.py +135 -0
  31. src/models/cost_report.py +87 -0
  32. src/models/deletion_operation.py +104 -0
  33. src/models/deletion_record.py +97 -0
  34. src/models/delta_report.py +122 -0
  35. src/models/efs_resource.py +80 -0
  36. src/models/elasticache_resource.py +90 -0
  37. src/models/group.py +318 -0
  38. src/models/inventory.py +133 -0
  39. src/models/protection_rule.py +123 -0
  40. src/models/report.py +288 -0
  41. src/models/resource.py +111 -0
  42. src/models/security_finding.py +102 -0
  43. src/models/snapshot.py +122 -0
  44. src/restore/__init__.py +20 -0
  45. src/restore/audit.py +175 -0
  46. src/restore/cleaner.py +461 -0
  47. src/restore/config.py +209 -0
  48. src/restore/deleter.py +976 -0
  49. src/restore/dependency.py +254 -0
  50. src/restore/safety.py +115 -0
  51. src/security/__init__.py +0 -0
  52. src/security/checks/__init__.py +0 -0
  53. src/security/checks/base.py +56 -0
  54. src/security/checks/ec2_checks.py +88 -0
  55. src/security/checks/elasticache_checks.py +149 -0
  56. src/security/checks/iam_checks.py +102 -0
  57. src/security/checks/rds_checks.py +140 -0
  58. src/security/checks/s3_checks.py +95 -0
  59. src/security/checks/secrets_checks.py +96 -0
  60. src/security/checks/sg_checks.py +142 -0
  61. src/security/cis_mapper.py +97 -0
  62. src/security/models.py +53 -0
  63. src/security/reporter.py +174 -0
  64. src/security/scanner.py +87 -0
  65. src/snapshot/__init__.py +6 -0
  66. src/snapshot/capturer.py +451 -0
  67. src/snapshot/filter.py +259 -0
  68. src/snapshot/inventory_storage.py +236 -0
  69. src/snapshot/report_formatter.py +250 -0
  70. src/snapshot/reporter.py +189 -0
  71. src/snapshot/resource_collectors/__init__.py +5 -0
  72. src/snapshot/resource_collectors/apigateway.py +140 -0
  73. src/snapshot/resource_collectors/backup.py +136 -0
  74. src/snapshot/resource_collectors/base.py +81 -0
  75. src/snapshot/resource_collectors/cloudformation.py +55 -0
  76. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  77. src/snapshot/resource_collectors/codebuild.py +69 -0
  78. src/snapshot/resource_collectors/codepipeline.py +82 -0
  79. src/snapshot/resource_collectors/dynamodb.py +65 -0
  80. src/snapshot/resource_collectors/ec2.py +240 -0
  81. src/snapshot/resource_collectors/ecs.py +215 -0
  82. src/snapshot/resource_collectors/efs_collector.py +102 -0
  83. src/snapshot/resource_collectors/eks.py +200 -0
  84. src/snapshot/resource_collectors/elasticache_collector.py +79 -0
  85. src/snapshot/resource_collectors/elb.py +126 -0
  86. src/snapshot/resource_collectors/eventbridge.py +156 -0
  87. src/snapshot/resource_collectors/iam.py +188 -0
  88. src/snapshot/resource_collectors/kms.py +111 -0
  89. src/snapshot/resource_collectors/lambda_func.py +139 -0
  90. src/snapshot/resource_collectors/rds.py +109 -0
  91. src/snapshot/resource_collectors/route53.py +86 -0
  92. src/snapshot/resource_collectors/s3.py +105 -0
  93. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  94. src/snapshot/resource_collectors/sns.py +68 -0
  95. src/snapshot/resource_collectors/sqs.py +82 -0
  96. src/snapshot/resource_collectors/ssm.py +160 -0
  97. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  98. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  99. src/snapshot/resource_collectors/waf.py +159 -0
  100. src/snapshot/storage.py +351 -0
  101. src/storage/__init__.py +21 -0
  102. src/storage/audit_store.py +419 -0
  103. src/storage/database.py +294 -0
  104. src/storage/group_store.py +749 -0
  105. src/storage/inventory_store.py +320 -0
  106. src/storage/resource_store.py +413 -0
  107. src/storage/schema.py +288 -0
  108. src/storage/snapshot_store.py +346 -0
  109. src/utils/__init__.py +12 -0
  110. src/utils/export.py +305 -0
  111. src/utils/hash.py +60 -0
  112. src/utils/logging.py +63 -0
  113. src/utils/pagination.py +41 -0
  114. src/utils/paths.py +51 -0
  115. src/utils/progress.py +41 -0
  116. src/utils/unsupported_resources.py +306 -0
  117. src/web/__init__.py +5 -0
  118. src/web/app.py +97 -0
  119. src/web/dependencies.py +69 -0
  120. src/web/routes/__init__.py +1 -0
  121. src/web/routes/api/__init__.py +18 -0
  122. src/web/routes/api/charts.py +156 -0
  123. src/web/routes/api/cleanup.py +186 -0
  124. src/web/routes/api/filters.py +253 -0
  125. src/web/routes/api/groups.py +305 -0
  126. src/web/routes/api/inventories.py +80 -0
  127. src/web/routes/api/queries.py +202 -0
  128. src/web/routes/api/resources.py +379 -0
  129. src/web/routes/api/snapshots.py +314 -0
  130. src/web/routes/api/views.py +260 -0
  131. src/web/routes/pages.py +198 -0
  132. src/web/services/__init__.py +1 -0
  133. src/web/templates/base.html +949 -0
  134. src/web/templates/components/navbar.html +31 -0
  135. src/web/templates/components/sidebar.html +104 -0
  136. src/web/templates/pages/audit_logs.html +86 -0
  137. src/web/templates/pages/cleanup.html +279 -0
  138. src/web/templates/pages/dashboard.html +227 -0
  139. src/web/templates/pages/diff.html +175 -0
  140. src/web/templates/pages/error.html +30 -0
  141. src/web/templates/pages/groups.html +721 -0
  142. src/web/templates/pages/queries.html +246 -0
  143. src/web/templates/pages/resources.html +2251 -0
  144. src/web/templates/pages/snapshot_detail.html +271 -0
  145. src/web/templates/pages/snapshots.html +429 -0
@@ -0,0 +1,749 @@
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
+ FROM resources r
490
+ WHERE {" AND ".join(conditions)}
491
+ ORDER BY r.resource_type, r.name
492
+ """
493
+
494
+ resource_rows = self.db.fetchall(query, tuple(params))
495
+
496
+ # Create group with members
497
+ # Use canonical_name (logical ID) when available for stable matching
498
+ members = []
499
+ for row in resource_rows:
500
+ physical_name = row["name"] or extract_resource_name(row["arn"], row["resource_type"])
501
+ canonical_name = row.get("canonical_name")
502
+
503
+ # If canonical_name differs from physical_name, it's a CloudFormation logical ID
504
+ if canonical_name and canonical_name != physical_name:
505
+ resource_name = canonical_name
506
+ match_strategy = "logical_id"
507
+ else:
508
+ resource_name = physical_name
509
+ match_strategy = "physical_name"
510
+
511
+ members.append(
512
+ GroupMember(
513
+ resource_name=resource_name,
514
+ resource_type=row["resource_type"],
515
+ original_arn=row["arn"],
516
+ match_strategy=match_strategy,
517
+ )
518
+ )
519
+
520
+ group = ResourceGroup(
521
+ name=group_name,
522
+ description=description,
523
+ source_snapshot=snapshot_name,
524
+ members=members,
525
+ resource_count=len(members),
526
+ )
527
+
528
+ self.save(group)
529
+ logger.info(f"Created group '{group_name}' with {len(members)} resources from snapshot '{snapshot_name}'")
530
+
531
+ return len(members)
532
+
533
+ def compare_snapshot(
534
+ self,
535
+ group_name: str,
536
+ snapshot_name: str,
537
+ ) -> Dict[str, Any]:
538
+ """Compare a snapshot against a group.
539
+
540
+ Resources are matched by name + type.
541
+
542
+ Args:
543
+ group_name: Group name
544
+ snapshot_name: Snapshot to compare
545
+
546
+ Returns:
547
+ Comparison results with matched, missing, and extra resources
548
+ """
549
+ # Get group members
550
+ group = self.load(group_name)
551
+ if not group:
552
+ raise ValueError(f"Group '{group_name}' not found")
553
+
554
+ # Get snapshot resources
555
+ snap_row = self.db.fetchone(
556
+ "SELECT id FROM snapshots WHERE name = ?",
557
+ (snapshot_name,),
558
+ )
559
+ if not snap_row:
560
+ raise ValueError(f"Snapshot '{snapshot_name}' not found")
561
+
562
+ resource_rows = self.db.fetchall(
563
+ """
564
+ SELECT r.arn, r.resource_type, r.name, r.region
565
+ FROM resources r
566
+ WHERE r.snapshot_id = ?
567
+ """,
568
+ (snap_row["id"],),
569
+ )
570
+
571
+ # Build sets for comparison
572
+ group_set = {(m.resource_name, m.resource_type) for m in group.members}
573
+ snapshot_resources = {}
574
+ for row in resource_rows:
575
+ resource_name = row["name"] or extract_resource_name(row["arn"], row["resource_type"])
576
+ key = (resource_name, row["resource_type"])
577
+ snapshot_resources[key] = {
578
+ "arn": row["arn"],
579
+ "resource_type": row["resource_type"],
580
+ "name": resource_name,
581
+ "region": row["region"],
582
+ }
583
+
584
+ snapshot_set = set(snapshot_resources.keys())
585
+
586
+ # Calculate differences
587
+ matched_keys = group_set & snapshot_set
588
+ missing_keys = group_set - snapshot_set # In group but not in snapshot
589
+ extra_keys = snapshot_set - group_set # In snapshot but not in group
590
+
591
+ # Build result lists
592
+ matched = [snapshot_resources[k] for k in matched_keys]
593
+ missing = [
594
+ {"name": k[0], "resource_type": k[1]}
595
+ for k in missing_keys
596
+ ]
597
+ extra = [snapshot_resources[k] for k in extra_keys]
598
+
599
+ return {
600
+ "group_name": group_name,
601
+ "snapshot_name": snapshot_name,
602
+ "total_in_group": len(group.members),
603
+ "total_in_snapshot": len(snapshot_resources),
604
+ "matched": len(matched),
605
+ "missing_from_snapshot": len(missing),
606
+ "not_in_group": len(extra),
607
+ "resources": {
608
+ "matched": sorted(matched, key=lambda x: (x["resource_type"], x["name"])),
609
+ "missing": sorted(missing, key=lambda x: (x["resource_type"], x["name"])),
610
+ "extra": sorted(extra, key=lambda x: (x["resource_type"], x["name"])),
611
+ },
612
+ }
613
+
614
+ def is_resource_in_group(
615
+ self,
616
+ group_name: str,
617
+ resource_name: str,
618
+ resource_type: str,
619
+ ) -> bool:
620
+ """Check if a resource is in a group.
621
+
622
+ Args:
623
+ group_name: Group name
624
+ resource_name: Resource name
625
+ resource_type: Resource type
626
+
627
+ Returns:
628
+ True if resource is in the group
629
+ """
630
+ group_id = self.get_id(group_name)
631
+ if not group_id:
632
+ return False
633
+
634
+ row = self.db.fetchone(
635
+ """
636
+ SELECT 1 FROM resource_group_members
637
+ WHERE group_id = ? AND resource_name = ? AND resource_type = ?
638
+ """,
639
+ (group_id, resource_name, resource_type),
640
+ )
641
+ return row is not None
642
+
643
+ def get_resources_not_in_group(
644
+ self,
645
+ group_name: str,
646
+ snapshot_name: str,
647
+ limit: int = 100,
648
+ offset: int = 0,
649
+ ) -> List[Dict[str, Any]]:
650
+ """Get resources from a snapshot that are NOT in a group.
651
+
652
+ Args:
653
+ group_name: Group name
654
+ snapshot_name: Snapshot name
655
+ limit: Maximum results
656
+ offset: Offset for pagination
657
+
658
+ Returns:
659
+ List of resources not in the group
660
+ """
661
+ group_id = self.get_id(group_name)
662
+ if not group_id:
663
+ raise ValueError(f"Group '{group_name}' not found")
664
+
665
+ snap_row = self.db.fetchone(
666
+ "SELECT id FROM snapshots WHERE name = ?",
667
+ (snapshot_name,),
668
+ )
669
+ if not snap_row:
670
+ raise ValueError(f"Snapshot '{snapshot_name}' not found")
671
+
672
+ # Use NOT EXISTS to find resources not in group
673
+ # Match strategy determines how to compare:
674
+ # - 'logical_id': match on canonical_name (CloudFormation logical ID)
675
+ # - 'physical_name': match on physical name or ARN
676
+ rows = self.db.fetchall(
677
+ """
678
+ SELECT r.arn, r.resource_type, r.name, r.region, r.created_at, r.canonical_name
679
+ FROM resources r
680
+ WHERE r.snapshot_id = ?
681
+ AND NOT EXISTS (
682
+ SELECT 1 FROM resource_group_members gm
683
+ WHERE gm.group_id = ?
684
+ AND r.resource_type = gm.resource_type
685
+ AND (
686
+ (gm.match_strategy = 'logical_id' AND r.canonical_name = gm.resource_name)
687
+ OR (COALESCE(gm.match_strategy, 'physical_name') = 'physical_name' AND COALESCE(r.name, r.arn) = gm.resource_name)
688
+ )
689
+ )
690
+ ORDER BY r.resource_type, r.name
691
+ LIMIT ? OFFSET ?
692
+ """,
693
+ (snap_row["id"], group_id, limit, offset),
694
+ )
695
+
696
+ return [dict(r) for r in rows]
697
+
698
+ def get_resources_in_group(
699
+ self,
700
+ group_name: str,
701
+ snapshot_name: str,
702
+ limit: int = 100,
703
+ offset: int = 0,
704
+ ) -> List[Dict[str, Any]]:
705
+ """Get resources from a snapshot that ARE in a group.
706
+
707
+ Args:
708
+ group_name: Group name
709
+ snapshot_name: Snapshot name
710
+ limit: Maximum results
711
+ offset: Offset for pagination
712
+
713
+ Returns:
714
+ List of resources in the group
715
+ """
716
+ group_id = self.get_id(group_name)
717
+ if not group_id:
718
+ raise ValueError(f"Group '{group_name}' not found")
719
+
720
+ snap_row = self.db.fetchone(
721
+ "SELECT id FROM snapshots WHERE name = ?",
722
+ (snapshot_name,),
723
+ )
724
+ if not snap_row:
725
+ raise ValueError(f"Snapshot '{snapshot_name}' not found")
726
+
727
+ # Use INNER JOIN to find resources in group
728
+ # Match strategy determines how to compare:
729
+ # - 'logical_id': match on canonical_name (CloudFormation logical ID)
730
+ # - 'physical_name': match on physical name or ARN
731
+ rows = self.db.fetchall(
732
+ """
733
+ SELECT r.arn, r.resource_type, r.name, r.region, r.created_at, r.canonical_name
734
+ FROM resources r
735
+ INNER JOIN resource_group_members gm
736
+ ON (
737
+ (gm.match_strategy = 'logical_id' AND r.canonical_name = gm.resource_name)
738
+ OR (COALESCE(gm.match_strategy, 'physical_name') = 'physical_name' AND COALESCE(r.name, r.arn) = gm.resource_name)
739
+ )
740
+ AND r.resource_type = gm.resource_type
741
+ AND gm.group_id = ?
742
+ WHERE r.snapshot_id = ?
743
+ ORDER BY r.resource_type, r.name
744
+ LIMIT ? OFFSET ?
745
+ """,
746
+ (group_id, snap_row["id"], limit, offset),
747
+ )
748
+
749
+ return [dict(r) for r in rows]