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,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
src/models/report.py ADDED
@@ -0,0 +1,288 @@
1
+ """
2
+ Data models for snapshot resource reporting.
3
+
4
+ This module defines the data structures used for generating and displaying
5
+ snapshot resource reports, including metadata, summaries, filtered views,
6
+ and detailed resource information.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from typing import Dict, List, Literal, Optional, Tuple
14
+
15
+
16
+ @dataclass
17
+ class SnapshotMetadata:
18
+ """
19
+ Snapshot identification information for report header.
20
+
21
+ Attributes:
22
+ name: Snapshot name (e.g., "baseline-2025-01-29")
23
+ created_at: Snapshot creation timestamp
24
+ account_id: AWS account ID
25
+ regions: List of AWS regions included in snapshot
26
+ inventory_name: Parent inventory name
27
+ total_resource_count: Total number of resources in snapshot
28
+ """
29
+
30
+ name: str
31
+ created_at: datetime
32
+ account_id: str
33
+ regions: List[str]
34
+ inventory_name: str
35
+ total_resource_count: int
36
+
37
+ @property
38
+ def region_summary(self) -> str:
39
+ """Human-readable region list (e.g., 'us-east-1, us-west-2 (2 regions)')."""
40
+ if len(self.regions) <= 3:
41
+ return ", ".join(self.regions)
42
+ else:
43
+ return f"{', '.join(self.regions[:3])} ... ({len(self.regions)} regions)"
44
+
45
+
46
+ @dataclass
47
+ class ResourceSummary:
48
+ """
49
+ Aggregated resource counts for summary view.
50
+
51
+ Attributes:
52
+ total_count: Total number of resources
53
+ by_service: Count per AWS service (e.g., {"EC2": 100, "S3": 50})
54
+ by_region: Count per AWS region (e.g., {"us-east-1": 80, "us-west-2": 70})
55
+ by_type: Count per resource type (e.g., {"AWS::EC2::Instance": 50})
56
+ """
57
+
58
+ total_count: int = 0
59
+ by_service: Dict[str, int] = field(default_factory=dict)
60
+ by_region: Dict[str, int] = field(default_factory=dict)
61
+ by_type: Dict[str, int] = field(default_factory=dict)
62
+
63
+ @property
64
+ def service_count(self) -> int:
65
+ """Number of distinct AWS services."""
66
+ return len(self.by_service)
67
+
68
+ @property
69
+ def region_count(self) -> int:
70
+ """Number of distinct AWS regions."""
71
+ return len(self.by_region)
72
+
73
+ @property
74
+ def type_count(self) -> int:
75
+ """Number of distinct resource types."""
76
+ return len(self.by_type)
77
+
78
+ def top_services(self, limit: int = 5) -> List[Tuple[str, int]]:
79
+ """Return top N services by resource count."""
80
+ return sorted(self.by_service.items(), key=lambda x: x[1], reverse=True)[:limit]
81
+
82
+ def top_regions(self, limit: int = 5) -> List[Tuple[str, int]]:
83
+ """Return top N regions by resource count."""
84
+ return sorted(self.by_region.items(), key=lambda x: x[1], reverse=True)[:limit]
85
+
86
+
87
+ @dataclass
88
+ class FilteredResource:
89
+ """
90
+ Minimal resource info for filtered view (non-detailed).
91
+
92
+ Attributes:
93
+ arn: AWS Resource Name (unique identifier)
94
+ resource_type: CloudFormation resource type (e.g., "AWS::EC2::Instance")
95
+ name: Resource name or identifier
96
+ region: AWS region (e.g., "us-east-1")
97
+ """
98
+
99
+ arn: str
100
+ resource_type: str
101
+ name: str
102
+ region: str
103
+
104
+ @property
105
+ def service(self) -> str:
106
+ """Extract service name from resource type (e.g., "EC2" from "AWS::EC2::Instance")."""
107
+ parts = self.resource_type.split("::")
108
+ return parts[1] if len(parts) >= 2 else "Unknown"
109
+
110
+ @property
111
+ def short_type(self) -> str:
112
+ """Short resource type (e.g., "Instance" from "AWS::EC2::Instance")."""
113
+ parts = self.resource_type.split("::")
114
+ return parts[-1] if parts else self.resource_type
115
+
116
+
117
+ @dataclass
118
+ class DetailedResource:
119
+ """
120
+ Full resource details for detailed view.
121
+
122
+ Attributes:
123
+ arn: AWS Resource Name
124
+ resource_type: CloudFormation resource type
125
+ name: Resource name or identifier
126
+ region: AWS region
127
+ tags: Key-value tag pairs
128
+ created_at: Resource creation timestamp (if available)
129
+ config_hash: Configuration hash for change detection
130
+ """
131
+
132
+ arn: str
133
+ resource_type: str
134
+ name: str
135
+ region: str
136
+ tags: Dict[str, str]
137
+ created_at: Optional[datetime]
138
+ config_hash: str
139
+
140
+ @property
141
+ def service(self) -> str:
142
+ """Extract service name from resource type."""
143
+ parts = self.resource_type.split("::")
144
+ return parts[1] if len(parts) >= 2 else "Unknown"
145
+
146
+ @property
147
+ def age_days(self) -> Optional[int]:
148
+ """Calculate resource age in days (if creation date available)."""
149
+ if self.created_at:
150
+ from datetime import timezone
151
+
152
+ # Handle string dates (convert to datetime)
153
+ created_at = self.created_at
154
+ if isinstance(created_at, str):
155
+ try:
156
+ created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
157
+ except ValueError:
158
+ return None
159
+
160
+ # Ensure we have a datetime object
161
+ if not isinstance(created_at, datetime):
162
+ return None
163
+
164
+ # Ensure we're comparing timezone-aware datetimes
165
+ now_utc = datetime.now(timezone.utc)
166
+ created = created_at if created_at.tzinfo else created_at.replace(tzinfo=timezone.utc)
167
+ return (now_utc - created).days
168
+ return None
169
+
170
+ @property
171
+ def tag_count(self) -> int:
172
+ """Number of tags applied to resource."""
173
+ return len(self.tags)
174
+
175
+ def has_tag(self, key: str, value: Optional[str] = None) -> bool:
176
+ """Check if resource has specific tag (optionally with value)."""
177
+ if key not in self.tags:
178
+ return False
179
+ if value is not None:
180
+ return self.tags[key] == value
181
+ return True
182
+
183
+
184
+ @dataclass
185
+ class FilterCriteria:
186
+ """
187
+ Filter specification for narrowing report results.
188
+
189
+ Attributes:
190
+ resource_types: List of resource types to include (flexible matching)
191
+ regions: List of AWS regions to include (exact matching)
192
+ match_mode: Matching strategy ("flexible" or "exact")
193
+ """
194
+
195
+ resource_types: Optional[List[str]] = None
196
+ regions: Optional[List[str]] = None
197
+ match_mode: Literal["flexible", "exact"] = "flexible"
198
+
199
+ def __post_init__(self) -> None:
200
+ """Normalize filter values (lowercase for case-insensitive matching)."""
201
+ if self.resource_types:
202
+ self.resource_types = [rt.lower() for rt in self.resource_types]
203
+ if self.regions:
204
+ self.regions = [r.lower() for r in self.regions]
205
+
206
+ @property
207
+ def has_filters(self) -> bool:
208
+ """Check if any filters are applied."""
209
+ return bool(self.resource_types or self.regions)
210
+
211
+ @property
212
+ def filter_count(self) -> int:
213
+ """Total number of filter criteria."""
214
+ count = 0
215
+ if self.resource_types:
216
+ count += len(self.resource_types)
217
+ if self.regions:
218
+ count += len(self.regions)
219
+ return count
220
+
221
+ def matches_resource(self, resource: FilteredResource) -> bool:
222
+ """
223
+ Check if resource matches filter criteria.
224
+
225
+ Uses flexible matching for resource types (see research.md Task 2).
226
+ Uses exact matching for regions (case-insensitive).
227
+ """
228
+ # Region filter (exact match, case-insensitive)
229
+ if self.regions and resource.region.lower() not in self.regions:
230
+ return False
231
+
232
+ # Resource type filter (flexible matching)
233
+ if self.resource_types:
234
+ type_match = any(
235
+ self._match_resource_type(resource.resource_type, filter_type) for filter_type in self.resource_types
236
+ )
237
+ if not type_match:
238
+ return False
239
+
240
+ return True
241
+
242
+ def _match_resource_type(self, resource_type: str, filter_value: str) -> bool:
243
+ """Three-tier matching: exact → prefix → contains (see research.md)."""
244
+ resource_lower = resource_type.lower()
245
+ filter_lower = filter_value.lower()
246
+
247
+ # Tier 1: Exact match
248
+ if resource_lower == filter_lower:
249
+ return True
250
+
251
+ # Tier 2: Service prefix match
252
+ service_prefix = f"aws::{filter_lower}::"
253
+ if resource_lower.startswith(service_prefix):
254
+ return True
255
+
256
+ # Tier 3: Contains match
257
+ if filter_lower in resource_lower:
258
+ return True
259
+
260
+ return False
261
+
262
+
263
+ @dataclass
264
+ class ResourceReport:
265
+ """
266
+ Complete report data structure.
267
+
268
+ Attributes:
269
+ snapshot_metadata: Information about the snapshot being reported
270
+ summary: Aggregated resource counts by service/region/type
271
+ filtered_resources: Minimal resource list (if filtering applied)
272
+ detailed_resources: Full resource details (if detailed view requested)
273
+ """
274
+
275
+ snapshot_metadata: SnapshotMetadata
276
+ summary: ResourceSummary
277
+ filtered_resources: Optional[List[FilteredResource]] = None
278
+ detailed_resources: Optional[List[DetailedResource]] = None
279
+
280
+ @property
281
+ def has_filters(self) -> bool:
282
+ """Check if report includes filtered results."""
283
+ return self.filtered_resources is not None
284
+
285
+ @property
286
+ def has_details(self) -> bool:
287
+ """Check if report includes detailed resource info."""
288
+ return self.detailed_resources is not None
src/models/resource.py ADDED
@@ -0,0 +1,111 @@
1
+ """Resource data model representing a single AWS resource."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import datetime
6
+ from typing import Any, Dict, Optional
7
+
8
+
9
+ @dataclass
10
+ class Resource:
11
+ """Represents a single AWS resource captured in a snapshot."""
12
+
13
+ arn: str
14
+ resource_type: str
15
+ name: str
16
+ region: str
17
+ config_hash: str
18
+ raw_config: Optional[Dict[str, Any]] = None # Optional for backward compatibility with v1.0 snapshots
19
+ tags: Dict[str, str] = field(default_factory=dict)
20
+ created_at: Optional[datetime] = None
21
+ source: str = "direct_api" # Collection source: "config" or "direct_api"
22
+
23
+ def to_dict(self) -> Dict[str, Any]:
24
+ """Convert resource to dictionary for serialization.
25
+
26
+ Note: raw_config is included in v1.1+ snapshots for drift analysis support.
27
+ """
28
+ return {
29
+ "arn": self.arn,
30
+ "type": self.resource_type,
31
+ "name": self.name,
32
+ "region": self.region,
33
+ "tags": self.tags,
34
+ "config_hash": self.config_hash,
35
+ "created_at": self.created_at.isoformat() if self.created_at else None,
36
+ "raw_config": self.raw_config if self.raw_config is not None else {},
37
+ "source": self.source,
38
+ }
39
+
40
+ @classmethod
41
+ def from_dict(cls, data: Dict[str, Any]) -> "Resource":
42
+ """Create resource from dictionary.
43
+
44
+ Supports both v1.0 (without raw_config) and v1.1+ (with raw_config) snapshots.
45
+ """
46
+ created_at = None
47
+ if data.get("created_at"):
48
+ if isinstance(data["created_at"], str):
49
+ created_at = datetime.fromisoformat(data["created_at"])
50
+ else:
51
+ created_at = data["created_at"] # Already a datetime
52
+
53
+ return cls(
54
+ arn=data["arn"],
55
+ resource_type=data["type"],
56
+ name=data["name"],
57
+ region=data["region"],
58
+ config_hash=data["config_hash"],
59
+ raw_config=data.get("raw_config"), # Optional for backward compatibility
60
+ tags=data.get("tags", {}),
61
+ created_at=created_at,
62
+ source=data.get("source", "direct_api"), # Default for older snapshots
63
+ )
64
+
65
+ def validate(self) -> bool:
66
+ """Validate resource data integrity.
67
+
68
+ Returns:
69
+ True if valid, raises ValueError if invalid
70
+ """
71
+ # Validate ARN format
72
+ arn_pattern = r"^arn:aws:[a-z0-9-]+:[a-z0-9-]*:[0-9]*:.*$"
73
+ if not re.match(arn_pattern, self.arn):
74
+ raise ValueError(f"Invalid ARN format: {self.arn}")
75
+
76
+ # Validate config_hash is 64-character hex string (SHA256)
77
+ if not re.match(r"^[a-fA-F0-9]{64}$", self.config_hash):
78
+ raise ValueError(f"Invalid config_hash: {self.config_hash}. Must be 64-character SHA256 hex string.")
79
+
80
+ # Validate region format
81
+ valid_regions = ["global"] + [
82
+ "us-east-1",
83
+ "us-east-2",
84
+ "us-west-1",
85
+ "us-west-2",
86
+ "eu-west-1",
87
+ "eu-west-2",
88
+ "eu-west-3",
89
+ "eu-central-1",
90
+ "ap-southeast-1",
91
+ "ap-southeast-2",
92
+ "ap-northeast-1",
93
+ "ap-northeast-2",
94
+ "ca-central-1",
95
+ "sa-east-1",
96
+ "ap-south-1",
97
+ ]
98
+ # Basic validation - starts with region pattern or is 'global'
99
+ if self.region != "global" and not any(self.region.startswith(r[:6]) for r in valid_regions if r != "global"):
100
+ # Allow it anyway - AWS adds new regions regularly
101
+ pass
102
+
103
+ return True
104
+
105
+ @property
106
+ def service(self) -> str:
107
+ """Extract service name from resource type.
108
+
109
+ Example: 'iam:role' -> 'iam'
110
+ """
111
+ return self.resource_type.split(":")[0] if ":" in self.resource_type else self.resource_type