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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Delta report models for tracking resource changes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from ..delta.models import DriftReport
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ResourceChange:
|
|
15
|
+
"""Represents a modified resource in a delta report."""
|
|
16
|
+
|
|
17
|
+
resource: Any # Current Resource instance
|
|
18
|
+
baseline_resource: Any # Reference Resource instance (keeping field name for compatibility)
|
|
19
|
+
change_type: str # 'modified'
|
|
20
|
+
old_config_hash: str
|
|
21
|
+
new_config_hash: str
|
|
22
|
+
changes_summary: Optional[str] = None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
25
|
+
"""Convert to dictionary for serialization."""
|
|
26
|
+
return {
|
|
27
|
+
"arn": self.resource.arn,
|
|
28
|
+
"resource_type": self.resource.resource_type,
|
|
29
|
+
"name": self.resource.name,
|
|
30
|
+
"region": self.resource.region,
|
|
31
|
+
"change_type": self.change_type,
|
|
32
|
+
"tags": self.resource.tags,
|
|
33
|
+
"old_config_hash": self.old_config_hash,
|
|
34
|
+
"new_config_hash": self.new_config_hash,
|
|
35
|
+
"changes_summary": self.changes_summary,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class DeltaReport:
|
|
41
|
+
"""Represents differences between two snapshots."""
|
|
42
|
+
|
|
43
|
+
generated_at: datetime
|
|
44
|
+
baseline_snapshot_name: str # Reference snapshot name (keeping field name for compatibility)
|
|
45
|
+
current_snapshot_name: str
|
|
46
|
+
added_resources: List[Any] = field(default_factory=list) # List[Resource]
|
|
47
|
+
deleted_resources: List[Any] = field(default_factory=list) # List[Resource]
|
|
48
|
+
modified_resources: List[ResourceChange] = field(default_factory=list)
|
|
49
|
+
baseline_resource_count: int = 0 # Reference snapshot count (keeping field name for compatibility)
|
|
50
|
+
current_resource_count: int = 0
|
|
51
|
+
drift_report: Optional[DriftReport] = None # Configuration drift details (when --show-diff is used)
|
|
52
|
+
|
|
53
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
54
|
+
"""Convert to dictionary for serialization."""
|
|
55
|
+
result = {
|
|
56
|
+
"generated_at": self.generated_at.isoformat(),
|
|
57
|
+
"baseline_snapshot_name": self.baseline_snapshot_name,
|
|
58
|
+
"current_snapshot_name": self.current_snapshot_name,
|
|
59
|
+
"added_resources": [r.to_dict() for r in self.added_resources],
|
|
60
|
+
"deleted_resources": [r.to_dict() for r in self.deleted_resources],
|
|
61
|
+
"modified_resources": [r.to_dict() for r in self.modified_resources],
|
|
62
|
+
"baseline_resource_count": self.baseline_resource_count,
|
|
63
|
+
"current_resource_count": self.current_resource_count,
|
|
64
|
+
"summary": {
|
|
65
|
+
"added": len(self.added_resources),
|
|
66
|
+
"deleted": len(self.deleted_resources),
|
|
67
|
+
"modified": len(self.modified_resources),
|
|
68
|
+
"unchanged": self.unchanged_count,
|
|
69
|
+
"total_changes": self.total_changes,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Include drift details if available
|
|
74
|
+
if self.drift_report is not None:
|
|
75
|
+
result["drift_details"] = self.drift_report.to_dict()
|
|
76
|
+
|
|
77
|
+
return result
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def total_changes(self) -> int:
|
|
81
|
+
"""Total number of changes detected."""
|
|
82
|
+
return len(self.added_resources) + len(self.deleted_resources) + len(self.modified_resources)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def unchanged_count(self) -> int:
|
|
86
|
+
"""Number of unchanged resources."""
|
|
87
|
+
# Resources that existed in reference snapshot and still exist unchanged
|
|
88
|
+
return self.baseline_resource_count - len(self.deleted_resources) - len(self.modified_resources)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def has_changes(self) -> bool:
|
|
92
|
+
"""Whether any changes were detected."""
|
|
93
|
+
return self.total_changes > 0
|
|
94
|
+
|
|
95
|
+
def group_by_service(self) -> Dict[str, Dict[str, List]]:
|
|
96
|
+
"""Group changes by service type.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Dictionary mapping service type to changes dict with 'added', 'deleted', 'modified' lists
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
grouped: Dict[str, Dict[str, List[Any]]] = {}
|
|
103
|
+
|
|
104
|
+
for resource in self.added_resources:
|
|
105
|
+
service = resource.resource_type
|
|
106
|
+
if service not in grouped:
|
|
107
|
+
grouped[service] = {"added": [], "deleted": [], "modified": []}
|
|
108
|
+
grouped[service]["added"].append(resource)
|
|
109
|
+
|
|
110
|
+
for resource in self.deleted_resources:
|
|
111
|
+
service = resource.resource_type
|
|
112
|
+
if service not in grouped:
|
|
113
|
+
grouped[service] = {"added": [], "deleted": [], "modified": []}
|
|
114
|
+
grouped[service]["deleted"].append(resource)
|
|
115
|
+
|
|
116
|
+
for change in self.modified_resources:
|
|
117
|
+
service = change.resource.resource_type
|
|
118
|
+
if service not in grouped:
|
|
119
|
+
grouped[service] = {"added": [], "deleted": [], "modified": []}
|
|
120
|
+
grouped[service]["modified"].append(change)
|
|
121
|
+
|
|
122
|
+
return grouped
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""EFS resource model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Any, Dict, Optional
|
|
9
|
+
|
|
10
|
+
from ..utils.hash import compute_config_hash
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class EFSFileSystem:
|
|
15
|
+
"""Represents an AWS EFS file system."""
|
|
16
|
+
|
|
17
|
+
file_system_id: str
|
|
18
|
+
arn: str
|
|
19
|
+
encryption_enabled: bool
|
|
20
|
+
kms_key_id: Optional[str]
|
|
21
|
+
performance_mode: str # "generalPurpose" or "maxIO"
|
|
22
|
+
lifecycle_state: str # "available", "creating", "deleting", "deleted"
|
|
23
|
+
tags: Dict[str, str]
|
|
24
|
+
region: str
|
|
25
|
+
created_at: datetime
|
|
26
|
+
|
|
27
|
+
def validate(self) -> bool:
|
|
28
|
+
"""Validate EFS file system data.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
True if valid, raises ValueError if invalid
|
|
32
|
+
"""
|
|
33
|
+
# Validate file_system_id format (must start with fs-)
|
|
34
|
+
if not re.match(r"^fs-[a-fA-F0-9]+$", self.file_system_id):
|
|
35
|
+
raise ValueError(f"Invalid file_system_id format: {self.file_system_id}. Must match pattern: fs-*")
|
|
36
|
+
|
|
37
|
+
# Validate performance_mode
|
|
38
|
+
valid_performance_modes = ["generalPurpose", "maxIO"]
|
|
39
|
+
if self.performance_mode not in valid_performance_modes:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"Invalid performance_mode: {self.performance_mode}. "
|
|
42
|
+
f"Must be one of: {', '.join(valid_performance_modes)}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Validate lifecycle_state
|
|
46
|
+
valid_states = ["available", "creating", "deleting", "deleted"]
|
|
47
|
+
if self.lifecycle_state not in valid_states:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Invalid lifecycle_state: {self.lifecycle_state}. " f"Must be one of: {', '.join(valid_states)}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
def to_resource_dict(self) -> Dict[str, Any]:
|
|
55
|
+
"""Convert to Resource-compatible dictionary.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dictionary that can be used to create a Resource object
|
|
59
|
+
"""
|
|
60
|
+
# Build raw_config with all EFS-specific attributes
|
|
61
|
+
raw_config = {
|
|
62
|
+
"file_system_id": self.file_system_id,
|
|
63
|
+
"arn": self.arn,
|
|
64
|
+
"encryption_enabled": self.encryption_enabled,
|
|
65
|
+
"kms_key_id": self.kms_key_id,
|
|
66
|
+
"performance_mode": self.performance_mode,
|
|
67
|
+
"lifecycle_state": self.lifecycle_state,
|
|
68
|
+
"region": self.region,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
"arn": self.arn,
|
|
73
|
+
"resource_type": "efs:file-system",
|
|
74
|
+
"name": self.file_system_id,
|
|
75
|
+
"region": self.region,
|
|
76
|
+
"tags": self.tags,
|
|
77
|
+
"created_at": self.created_at,
|
|
78
|
+
"raw_config": raw_config,
|
|
79
|
+
"config_hash": compute_config_hash(raw_config),
|
|
80
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""ElastiCache resource model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict
|
|
7
|
+
|
|
8
|
+
from ..utils.hash import compute_config_hash
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ElastiCacheCluster:
|
|
13
|
+
"""Represents an AWS ElastiCache cluster (Redis or Memcached).
|
|
14
|
+
|
|
15
|
+
This model captures both Redis and Memcached clusters with their
|
|
16
|
+
encryption settings, node configuration, and metadata.
|
|
17
|
+
|
|
18
|
+
Attributes:
|
|
19
|
+
cluster_id: Unique identifier for the cluster (max 50 chars)
|
|
20
|
+
arn: Amazon Resource Name for the cluster
|
|
21
|
+
engine: Cache engine type (redis or memcached)
|
|
22
|
+
node_type: Cache node type (e.g., cache.t3.micro)
|
|
23
|
+
num_cache_nodes: Number of cache nodes in the cluster
|
|
24
|
+
engine_version: Engine version string
|
|
25
|
+
encryption_at_rest: Whether data at rest is encrypted
|
|
26
|
+
encryption_in_transit: Whether data in transit is encrypted
|
|
27
|
+
tags: Resource tags as key-value pairs
|
|
28
|
+
region: AWS region
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
cluster_id: str
|
|
32
|
+
arn: str
|
|
33
|
+
engine: str
|
|
34
|
+
node_type: str
|
|
35
|
+
num_cache_nodes: int
|
|
36
|
+
engine_version: str
|
|
37
|
+
encryption_at_rest: bool
|
|
38
|
+
encryption_in_transit: bool
|
|
39
|
+
tags: Dict[str, str] = field(default_factory=dict)
|
|
40
|
+
region: str = "us-east-1"
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
"""Validate ElastiCache cluster data after initialization."""
|
|
44
|
+
# Validate cluster_id length (AWS limit is 50 characters)
|
|
45
|
+
if len(self.cluster_id) > 50:
|
|
46
|
+
raise ValueError("cluster_id must be 50 characters or less")
|
|
47
|
+
|
|
48
|
+
# Validate engine type
|
|
49
|
+
if self.engine not in ("redis", "memcached"):
|
|
50
|
+
raise ValueError("engine must be 'redis' or 'memcached'")
|
|
51
|
+
|
|
52
|
+
# Memcached does not support encryption at rest
|
|
53
|
+
if self.engine == "memcached" and self.encryption_at_rest:
|
|
54
|
+
raise ValueError("Memcached does not support encryption at rest")
|
|
55
|
+
|
|
56
|
+
# Validate num_cache_nodes
|
|
57
|
+
if self.num_cache_nodes < 1:
|
|
58
|
+
raise ValueError("num_cache_nodes must be at least 1")
|
|
59
|
+
|
|
60
|
+
def to_resource_dict(self) -> Dict[str, Any]:
|
|
61
|
+
"""Convert ElastiCache cluster to Resource dictionary.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Dictionary with Resource fields suitable for creating a Resource object
|
|
65
|
+
"""
|
|
66
|
+
# Build raw_config that matches ElastiCache API response structure
|
|
67
|
+
raw_config = {
|
|
68
|
+
"CacheClusterId": self.cluster_id,
|
|
69
|
+
"ARN": self.arn,
|
|
70
|
+
"Engine": self.engine,
|
|
71
|
+
"CacheNodeType": self.node_type,
|
|
72
|
+
"NumCacheNodes": self.num_cache_nodes,
|
|
73
|
+
"EngineVersion": self.engine_version,
|
|
74
|
+
"AtRestEncryptionEnabled": self.encryption_at_rest,
|
|
75
|
+
"TransitEncryptionEnabled": self.encryption_in_transit,
|
|
76
|
+
"CacheClusterStatus": "available",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Compute config hash from raw_config
|
|
80
|
+
config_hash = compute_config_hash(raw_config)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"arn": self.arn,
|
|
84
|
+
"resource_type": "elasticache:cluster",
|
|
85
|
+
"name": self.cluster_id,
|
|
86
|
+
"region": self.region,
|
|
87
|
+
"tags": self.tags,
|
|
88
|
+
"config_hash": config_hash,
|
|
89
|
+
"raw_config": raw_config,
|
|
90
|
+
}
|
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
|