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.
- aws_inventory_manager-0.13.2.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.13.2.dist-info/METADATA +1226 -0
- aws_inventory_manager-0.13.2.dist-info/RECORD +145 -0
- aws_inventory_manager-0.13.2.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.13.2.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.13.2.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 +3626 -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/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 +451 -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/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 +749 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +413 -0
- src/storage/schema.py +288 -0
- src/storage/snapshot_store.py +346 -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 +379 -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 +949 -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 +2251 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- src/web/templates/pages/snapshots.html +429 -0
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
|
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
|