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,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