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
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
|
src/models/inventory.py
ADDED
|
@@ -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
|