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
src/models/group.py ADDED
@@ -0,0 +1,318 @@
1
+ """Resource Group model for baseline comparison across accounts."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, List, Optional
6
+
7
+
8
+ @dataclass
9
+ class GroupMember:
10
+ """A member of a resource group, identified by name and type.
11
+
12
+ Attributes:
13
+ resource_name: Resource name (extracted from ARN or logical ID)
14
+ resource_type: Resource type (e.g., s3:bucket, lambda:function)
15
+ original_arn: Original ARN from source snapshot (reference only)
16
+ match_strategy: How to match this member - 'logical_id' uses CloudFormation
17
+ logical-id tag for stable matching, 'physical_name' uses name
18
+ """
19
+
20
+ resource_name: str
21
+ resource_type: str
22
+ original_arn: Optional[str] = None
23
+ match_strategy: str = "physical_name" # 'logical_id' or 'physical_name'
24
+
25
+ def to_dict(self) -> Dict[str, Any]:
26
+ """Serialize to dictionary."""
27
+ return {
28
+ "resource_name": self.resource_name,
29
+ "resource_type": self.resource_type,
30
+ "original_arn": self.original_arn,
31
+ "match_strategy": self.match_strategy,
32
+ }
33
+
34
+ @classmethod
35
+ def from_dict(cls, data: Dict[str, Any]) -> "GroupMember":
36
+ """Deserialize from dictionary."""
37
+ return cls(
38
+ resource_name=data["resource_name"],
39
+ resource_type=data["resource_type"],
40
+ original_arn=data.get("original_arn"),
41
+ match_strategy=data.get("match_strategy", "physical_name"),
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class ResourceGroup:
47
+ """A named group of resources for baseline comparison.
48
+
49
+ Groups store resources by name + type to enable cross-account comparison
50
+ where ARNs differ (due to account IDs) but resource names are identical.
51
+
52
+ Attributes:
53
+ name: Unique group name
54
+ description: Human-readable description
55
+ source_snapshot: Name of snapshot used to create the group
56
+ members: List of group members
57
+ resource_count: Number of resources in the group
58
+ is_favorite: Whether the group is marked as favorite
59
+ created_at: Group creation timestamp
60
+ last_updated: Last modification timestamp
61
+ id: Database ID (set after save)
62
+ """
63
+
64
+ name: str
65
+ description: str = ""
66
+ source_snapshot: Optional[str] = None
67
+ members: List[GroupMember] = field(default_factory=list)
68
+ resource_count: int = 0
69
+ is_favorite: bool = False
70
+ created_at: Optional[datetime] = field(default_factory=lambda: datetime.now(timezone.utc))
71
+ last_updated: Optional[datetime] = field(default_factory=lambda: datetime.now(timezone.utc))
72
+ id: Optional[int] = None
73
+
74
+ def to_dict(self) -> Dict[str, Any]:
75
+ """Serialize to dictionary for storage.
76
+
77
+ Returns:
78
+ Dictionary representation suitable for storage
79
+ """
80
+ return {
81
+ "id": self.id,
82
+ "name": self.name,
83
+ "description": self.description,
84
+ "source_snapshot": self.source_snapshot,
85
+ "members": [m.to_dict() for m in self.members],
86
+ "resource_count": self.resource_count,
87
+ "is_favorite": self.is_favorite,
88
+ "created_at": self.created_at.isoformat() if self.created_at else None,
89
+ "last_updated": self.last_updated.isoformat() if self.last_updated else None,
90
+ }
91
+
92
+ @classmethod
93
+ def from_dict(cls, data: Dict[str, Any]) -> "ResourceGroup":
94
+ """Deserialize from dictionary.
95
+
96
+ Args:
97
+ data: Dictionary loaded from storage
98
+
99
+ Returns:
100
+ ResourceGroup instance
101
+ """
102
+ # Handle datetime fields
103
+ created_at = data.get("created_at")
104
+ if isinstance(created_at, str):
105
+ created_at = datetime.fromisoformat(created_at)
106
+
107
+ last_updated = data.get("last_updated")
108
+ if isinstance(last_updated, str):
109
+ last_updated = datetime.fromisoformat(last_updated)
110
+
111
+ # Handle members
112
+ members_data = data.get("members", [])
113
+ members = [GroupMember.from_dict(m) for m in members_data]
114
+
115
+ return cls(
116
+ id=data.get("id"),
117
+ name=data["name"],
118
+ description=data.get("description", ""),
119
+ source_snapshot=data.get("source_snapshot"),
120
+ members=members,
121
+ resource_count=data.get("resource_count", len(members)),
122
+ is_favorite=data.get("is_favorite", False),
123
+ created_at=created_at,
124
+ last_updated=last_updated,
125
+ )
126
+
127
+ def add_member(
128
+ self,
129
+ resource_name: str,
130
+ resource_type: str,
131
+ original_arn: Optional[str] = None,
132
+ match_strategy: str = "physical_name",
133
+ ) -> bool:
134
+ """Add a resource to the group.
135
+
136
+ Args:
137
+ resource_name: Resource name (or logical ID if match_strategy is 'logical_id')
138
+ resource_type: Resource type
139
+ original_arn: Optional original ARN
140
+ match_strategy: 'logical_id' for CloudFormation logical IDs, 'physical_name' for names
141
+
142
+ Returns:
143
+ True if added, False if already exists
144
+ """
145
+ # Check if already exists
146
+ for member in self.members:
147
+ if member.resource_name == resource_name and member.resource_type == resource_type:
148
+ return False
149
+
150
+ self.members.append(GroupMember(resource_name, resource_type, original_arn, match_strategy))
151
+ self.resource_count = len(self.members)
152
+ self.last_updated = datetime.now(timezone.utc)
153
+ return True
154
+
155
+ def remove_member(self, resource_name: str, resource_type: str) -> bool:
156
+ """Remove a resource from the group.
157
+
158
+ Args:
159
+ resource_name: Resource name
160
+ resource_type: Resource type
161
+
162
+ Returns:
163
+ True if removed, False if not found
164
+ """
165
+ for i, member in enumerate(self.members):
166
+ if member.resource_name == resource_name and member.resource_type == resource_type:
167
+ del self.members[i]
168
+ self.resource_count = len(self.members)
169
+ self.last_updated = datetime.now(timezone.utc)
170
+ return True
171
+ return False
172
+
173
+ def has_member(self, resource_name: str, resource_type: str) -> bool:
174
+ """Check if a resource is in the group.
175
+
176
+ Args:
177
+ resource_name: Resource name
178
+ resource_type: Resource type
179
+
180
+ Returns:
181
+ True if resource is in the group
182
+ """
183
+ for member in self.members:
184
+ if member.resource_name == resource_name and member.resource_type == resource_type:
185
+ return True
186
+ return False
187
+
188
+
189
+ def extract_resource_name(arn: str, resource_type: str) -> str:
190
+ """Extract resource name from ARN based on resource type.
191
+
192
+ ARN formats vary by service:
193
+ - S3: arn:aws:s3:::bucket-name
194
+ - Lambda: arn:aws:lambda:region:account:function:name
195
+ - IAM: arn:aws:iam::account:role/role-name
196
+ - EC2: arn:aws:ec2:region:account:instance/i-xxxx
197
+
198
+ Args:
199
+ arn: AWS ARN string
200
+ resource_type: Resource type (e.g., s3:bucket, iam:role)
201
+
202
+ Returns:
203
+ Extracted resource name
204
+ """
205
+ parts = arn.split(":")
206
+
207
+ # Handle different ARN formats based on service
208
+ if resource_type.startswith("s3:"):
209
+ # S3: arn:aws:s3:::bucket-name
210
+ return parts[-1]
211
+
212
+ elif resource_type.startswith("lambda:"):
213
+ # Lambda: arn:aws:lambda:region:account:function:name
214
+ return parts[-1]
215
+
216
+ elif resource_type.startswith("iam:"):
217
+ # IAM: arn:aws:iam::account:role/role-name
218
+ # or arn:aws:iam::account:user/user-name
219
+ # or arn:aws:iam::account:policy/policy-name
220
+ resource_part = parts[-1]
221
+ if "/" in resource_part:
222
+ return resource_part.split("/")[-1]
223
+ return resource_part
224
+
225
+ elif resource_type.startswith("dynamodb:"):
226
+ # DynamoDB: arn:aws:dynamodb:region:account:table/table-name
227
+ resource_part = parts[-1]
228
+ if "/" in resource_part:
229
+ return resource_part.split("/")[-1]
230
+ return resource_part
231
+
232
+ elif resource_type.startswith("sns:"):
233
+ # SNS: arn:aws:sns:region:account:topic-name
234
+ return parts[-1]
235
+
236
+ elif resource_type.startswith("sqs:"):
237
+ # SQS: arn:aws:sqs:region:account:queue-name
238
+ return parts[-1]
239
+
240
+ elif resource_type.startswith("ec2:"):
241
+ # EC2: arn:aws:ec2:region:account:instance/i-xxxx
242
+ # or arn:aws:ec2:region:account:vpc/vpc-xxxx
243
+ resource_part = parts[-1]
244
+ if "/" in resource_part:
245
+ return resource_part.split("/")[-1]
246
+ return resource_part
247
+
248
+ elif resource_type.startswith("rds:"):
249
+ # RDS: arn:aws:rds:region:account:db:db-instance-name
250
+ # or arn:aws:rds:region:account:cluster:cluster-name
251
+ return parts[-1]
252
+
253
+ elif resource_type.startswith("secretsmanager:"):
254
+ # Secrets Manager: arn:aws:secretsmanager:region:account:secret:name-suffix
255
+ return parts[-1].split("-")[0] if "-" in parts[-1] else parts[-1]
256
+
257
+ elif resource_type.startswith("kms:"):
258
+ # KMS: arn:aws:kms:region:account:key/key-id
259
+ # or arn:aws:kms:region:account:alias/alias-name
260
+ resource_part = parts[-1]
261
+ if "/" in resource_part:
262
+ return resource_part.split("/")[-1]
263
+ return resource_part
264
+
265
+ elif resource_type.startswith("ecs:"):
266
+ # ECS: arn:aws:ecs:region:account:cluster/cluster-name
267
+ # or arn:aws:ecs:region:account:service/cluster-name/service-name
268
+ resource_part = parts[-1]
269
+ if "/" in resource_part:
270
+ return resource_part.split("/")[-1]
271
+ return resource_part
272
+
273
+ elif resource_type.startswith("eks:"):
274
+ # EKS: arn:aws:eks:region:account:cluster/cluster-name
275
+ resource_part = parts[-1]
276
+ if "/" in resource_part:
277
+ return resource_part.split("/")[-1]
278
+ return resource_part
279
+
280
+ elif resource_type.startswith("cloudwatch:"):
281
+ # CloudWatch: arn:aws:cloudwatch:region:account:alarm:alarm-name
282
+ return parts[-1]
283
+
284
+ elif resource_type.startswith("logs:"):
285
+ # CloudWatch Logs: arn:aws:logs:region:account:log-group:group-name
286
+ return parts[-1]
287
+
288
+ elif resource_type.startswith("events:"):
289
+ # EventBridge: arn:aws:events:region:account:rule/rule-name
290
+ resource_part = parts[-1]
291
+ if "/" in resource_part:
292
+ return resource_part.split("/")[-1]
293
+ return resource_part
294
+
295
+ elif resource_type.startswith("apigateway:"):
296
+ # API Gateway: arn:aws:apigateway:region::/restapis/api-id
297
+ resource_part = parts[-1]
298
+ if "/" in resource_part:
299
+ return resource_part.split("/")[-1]
300
+ return resource_part
301
+
302
+ elif resource_type.startswith("elasticache:"):
303
+ # ElastiCache: arn:aws:elasticache:region:account:cluster:cluster-id
304
+ return parts[-1]
305
+
306
+ elif resource_type.startswith("ssm:"):
307
+ # SSM Parameter: arn:aws:ssm:region:account:parameter/param-name
308
+ resource_part = parts[-1]
309
+ if "/" in resource_part:
310
+ # Handle hierarchical parameter names like /env/app/key
311
+ return resource_part.replace("parameter/", "").replace("parameter", "")
312
+ return resource_part
313
+
314
+ # Default: take last segment after / or :
315
+ resource_part = parts[-1]
316
+ if "/" in resource_part:
317
+ return resource_part.split("/")[-1]
318
+ return resource_part
@@ -0,0 +1,133 @@
1
+ """Inventory model for organizing snapshots by account and purpose."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ @dataclass
10
+ class Inventory:
11
+ """Named container for organizing snapshots by account and purpose.
12
+
13
+ Attributes:
14
+ name: Unique identifier within account (alphanumeric + hyphens + underscores, 1-50 chars)
15
+ account_id: AWS account ID (12 digits)
16
+ include_tags: Tag filters (resource MUST have ALL)
17
+ exclude_tags: Tag filters (resource MUST NOT have ANY)
18
+ snapshots: List of snapshot filenames in this inventory
19
+ active_snapshot: Filename of active baseline snapshot
20
+ description: Human-readable description
21
+ created_at: Inventory creation timestamp (timezone-aware UTC)
22
+ last_updated: Last modification timestamp (timezone-aware UTC, auto-updated)
23
+ """
24
+
25
+ name: str
26
+ account_id: str
27
+ include_tags: Dict[str, str] = field(default_factory=dict)
28
+ exclude_tags: Dict[str, str] = field(default_factory=dict)
29
+ snapshots: List[str] = field(default_factory=list)
30
+ active_snapshot: Optional[str] = None
31
+ description: str = ""
32
+ created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
33
+ last_updated: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
34
+
35
+ def to_dict(self) -> Dict[str, Any]:
36
+ """Serialize to dictionary for YAML storage.
37
+
38
+ Returns:
39
+ Dictionary representation suitable for YAML serialization
40
+ """
41
+ return {
42
+ "name": self.name,
43
+ "account_id": self.account_id,
44
+ "description": self.description,
45
+ "include_tags": self.include_tags,
46
+ "exclude_tags": self.exclude_tags,
47
+ "snapshots": self.snapshots,
48
+ "active_snapshot": self.active_snapshot,
49
+ "created_at": self.created_at.isoformat(),
50
+ "last_updated": self.last_updated.isoformat(),
51
+ }
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: Dict[str, Any]) -> "Inventory":
55
+ """Deserialize from dictionary (YAML load).
56
+
57
+ Args:
58
+ data: Dictionary loaded from YAML
59
+
60
+ Returns:
61
+ Inventory instance
62
+ """
63
+ # Handle datetime fields being either string or datetime (PyYAML can auto-parse)
64
+ created_at = data["created_at"]
65
+ if isinstance(created_at, str):
66
+ created_at = datetime.fromisoformat(created_at)
67
+
68
+ last_updated = data["last_updated"]
69
+ if isinstance(last_updated, str):
70
+ last_updated = datetime.fromisoformat(last_updated)
71
+
72
+ return cls(
73
+ name=data["name"],
74
+ account_id=data["account_id"],
75
+ description=data.get("description", ""),
76
+ include_tags=data.get("include_tags", {}),
77
+ exclude_tags=data.get("exclude_tags", {}),
78
+ snapshots=data.get("snapshots", []),
79
+ active_snapshot=data.get("active_snapshot"),
80
+ created_at=created_at,
81
+ last_updated=last_updated,
82
+ )
83
+
84
+ def add_snapshot(self, snapshot_filename: str, set_active: bool = False) -> None:
85
+ """Add snapshot to inventory, optionally marking as active.
86
+
87
+ Args:
88
+ snapshot_filename: Name of snapshot file to add
89
+ set_active: Whether to mark this snapshot as active baseline
90
+ """
91
+ if snapshot_filename not in self.snapshots:
92
+ self.snapshots.append(snapshot_filename)
93
+ if set_active:
94
+ self.active_snapshot = snapshot_filename
95
+ self.last_updated = datetime.now(timezone.utc)
96
+
97
+ def remove_snapshot(self, snapshot_filename: str) -> None:
98
+ """Remove snapshot from inventory, clearing active if it was active.
99
+
100
+ Args:
101
+ snapshot_filename: Name of snapshot file to remove
102
+ """
103
+ if snapshot_filename in self.snapshots:
104
+ self.snapshots.remove(snapshot_filename)
105
+ if self.active_snapshot == snapshot_filename:
106
+ self.active_snapshot = None
107
+ self.last_updated = datetime.now(timezone.utc)
108
+
109
+ def validate(self) -> List[str]:
110
+ """Validate inventory data, return list of errors.
111
+
112
+ Returns:
113
+ List of validation error messages (empty if valid)
114
+ """
115
+ errors = []
116
+
117
+ # Validate name format (alphanumeric + hyphens + underscores only)
118
+ if not self.name or not re.match(r"^[a-zA-Z0-9_-]+$", self.name):
119
+ errors.append("Name must contain only alphanumeric characters, hyphens, and underscores")
120
+
121
+ # Validate name length
122
+ if len(self.name) > 50:
123
+ errors.append("Name must be 50 characters or less")
124
+
125
+ # Validate account ID format (12 digits)
126
+ if not self.account_id or not re.match(r"^\d{12}$", self.account_id):
127
+ errors.append("Account ID must be 12 digits")
128
+
129
+ # Validate active snapshot exists in snapshots list
130
+ if self.active_snapshot and self.active_snapshot not in self.snapshots:
131
+ errors.append("Active snapshot must exist in snapshots list")
132
+
133
+ return errors
@@ -0,0 +1,123 @@
1
+ """Protection rule model.
2
+
3
+ Configuration defining which resources should never be deleted.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from enum import Enum
10
+ from typing import Optional
11
+
12
+
13
+ class RuleType(Enum):
14
+ """Protection rule type."""
15
+
16
+ TAG = "tag"
17
+ TYPE = "type"
18
+ AGE = "age"
19
+ COST = "cost"
20
+ NATIVE = "native"
21
+
22
+
23
+ @dataclass
24
+ class ProtectionRule:
25
+ """Protection rule entity.
26
+
27
+ Configuration defining which resources should never be deleted. Rules are
28
+ evaluated against resources before deletion, with higher priority rules
29
+ taking precedence.
30
+
31
+ Rule types and patterns:
32
+ - TAG: Match resources by tag key/value
33
+ patterns: {tag_key: str, tag_values: list, match_mode: str}
34
+ - TYPE: Match resources by AWS type
35
+ patterns: {resource_types: list, match_mode: str}
36
+ - AGE: Protect resources younger than threshold
37
+ patterns: {environment: str}, threshold_value: days
38
+ - COST: Protect resources exceeding cost threshold
39
+ patterns: {action: str}, threshold_value: USD/month
40
+ - NATIVE: Check AWS native protection flags
41
+ patterns: {protection_types: list}
42
+
43
+ Validation rules:
44
+ - patterns cannot be empty
45
+ - priority must be 1-100
46
+ - AGE and COST rules require threshold_value >= 0
47
+
48
+ Attributes:
49
+ rule_id: Unique identifier
50
+ rule_type: Rule type (tag, type, age, cost, native)
51
+ enabled: Whether rule is active
52
+ priority: Rule precedence (1=highest)
53
+ patterns: Type-specific match patterns
54
+ threshold_value: Numeric threshold for age/cost rules (optional)
55
+ description: Human-readable explanation (optional)
56
+ """
57
+
58
+ rule_id: str
59
+ rule_type: RuleType
60
+ enabled: bool
61
+ priority: int
62
+ patterns: dict = field(default_factory=dict)
63
+ threshold_value: Optional[float] = None
64
+ description: Optional[str] = None
65
+
66
+ def validate(self) -> bool:
67
+ """Validate rule configuration.
68
+
69
+ Returns:
70
+ True if validation passes
71
+
72
+ Raises:
73
+ ValueError: If any validation rule fails
74
+ """
75
+ # Threshold requirements
76
+ if self.rule_type in [RuleType.AGE, RuleType.COST]:
77
+ if self.threshold_value is None or self.threshold_value < 0:
78
+ raise ValueError(f"{self.rule_type.value} rule requires positive threshold")
79
+
80
+ # Pattern validation
81
+ if not self.patterns:
82
+ raise ValueError("Patterns cannot be empty")
83
+
84
+ # Priority range
85
+ if not (1 <= self.priority <= 100):
86
+ raise ValueError("Priority must be 1-100")
87
+
88
+ return True
89
+
90
+ def matches(self, resource: dict) -> bool:
91
+ """Check if resource matches this rule.
92
+
93
+ Args:
94
+ resource: Resource metadata dictionary
95
+
96
+ Returns:
97
+ True if resource matches this protection rule
98
+ """
99
+ if not self.enabled:
100
+ return False
101
+
102
+ if self.rule_type == RuleType.TAG:
103
+ tag_key = self.patterns.get("tag_key")
104
+ tag_values = self.patterns.get("tag_values", [])
105
+ resource_tag_value = resource.get("tags", {}).get(tag_key)
106
+ return resource_tag_value in tag_values
107
+
108
+ elif self.rule_type == RuleType.TYPE:
109
+ resource_types = self.patterns.get("resource_types", [])
110
+ return resource.get("resource_type") in resource_types
111
+
112
+ elif self.rule_type == RuleType.AGE:
113
+ resource_age_days = resource.get("age_days", 0)
114
+ return resource_age_days < self.threshold_value
115
+
116
+ elif self.rule_type == RuleType.COST:
117
+ resource_cost = resource.get("estimated_monthly_cost", 0)
118
+ return resource_cost >= self.threshold_value
119
+
120
+ elif self.rule_type == RuleType.NATIVE:
121
+ return resource.get("has_native_protection", False)
122
+
123
+ return False