reverse-diagrams 1.3.4__py3-none-any.whl → 2.0.0__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.
- reverse_diagrams-2.0.0.dist-info/METADATA +706 -0
- reverse_diagrams-2.0.0.dist-info/RECORD +35 -0
- {reverse_diagrams-1.3.4.dist-info → reverse_diagrams-2.0.0.dist-info}/WHEEL +1 -1
- src/aws/client_manager.py +217 -0
- src/aws/describe_identity_store.py +8 -0
- src/aws/describe_organization.py +324 -445
- src/aws/describe_sso.py +170 -143
- src/aws/exceptions.py +26 -0
- src/banner/banner.py +43 -40
- src/config.py +153 -0
- src/models.py +242 -0
- src/plugins/__init__.py +12 -0
- src/plugins/base.py +292 -0
- src/plugins/builtin/__init__.py +12 -0
- src/plugins/builtin/ec2_plugin.py +228 -0
- src/plugins/builtin/identity_center_plugin.py +496 -0
- src/plugins/builtin/organizations_plugin.py +376 -0
- src/plugins/registry.py +126 -0
- src/reports/console_view.py +57 -19
- src/reports/save_results.py +210 -15
- src/reverse_diagrams.py +331 -38
- src/utils/__init__.py +1 -0
- src/utils/cache.py +274 -0
- src/utils/concurrent.py +361 -0
- src/utils/progress.py +257 -0
- src/version.py +1 -1
- reverse_diagrams-1.3.4.dist-info/METADATA +0 -247
- reverse_diagrams-1.3.4.dist-info/RECORD +0 -21
- src/reports/tes.py +0 -366
- {reverse_diagrams-1.3.4.dist-info → reverse_diagrams-2.0.0.dist-info}/entry_points.txt +0 -0
- {reverse_diagrams-1.3.4.dist-info → reverse_diagrams-2.0.0.dist-info}/licenses/LICENSE +0 -0
src/models.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Data models with validation for AWS resources."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import List, Optional, Dict, Any
|
|
4
|
+
from enum import Enum
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PrincipalType(Enum):
|
|
9
|
+
"""Principal types for account assignments."""
|
|
10
|
+
USER = "USER"
|
|
11
|
+
GROUP = "GROUP"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AccountStatus(Enum):
|
|
15
|
+
"""AWS account status."""
|
|
16
|
+
ACTIVE = "ACTIVE"
|
|
17
|
+
SUSPENDED = "SUSPENDED"
|
|
18
|
+
PENDING_CLOSURE = "PENDING_CLOSURE"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AWSAccount:
|
|
23
|
+
"""AWS Account model with validation."""
|
|
24
|
+
id: str
|
|
25
|
+
name: str
|
|
26
|
+
email: str
|
|
27
|
+
status: AccountStatus = AccountStatus.ACTIVE
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
"""Validate account data after initialization."""
|
|
31
|
+
if not self.id or not self.id.isdigit() or len(self.id) != 12:
|
|
32
|
+
raise ValueError(f"Invalid AWS account ID: {self.id}. Must be 12 digits.")
|
|
33
|
+
|
|
34
|
+
if not self.name or len(self.name.strip()) == 0:
|
|
35
|
+
raise ValueError("Account name cannot be empty.")
|
|
36
|
+
|
|
37
|
+
if not self._is_valid_email(self.email):
|
|
38
|
+
raise ValueError(f"Invalid email format: {self.email}")
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _is_valid_email(email: str) -> bool:
|
|
42
|
+
"""Validate email format."""
|
|
43
|
+
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
|
44
|
+
return bool(re.match(pattern, email))
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class OrganizationalUnit:
|
|
49
|
+
"""Organizational Unit model."""
|
|
50
|
+
id: str
|
|
51
|
+
name: str
|
|
52
|
+
parent_id: Optional[str] = None
|
|
53
|
+
accounts: List[AWSAccount] = None
|
|
54
|
+
child_ous: List['OrganizationalUnit'] = None
|
|
55
|
+
|
|
56
|
+
def __post_init__(self):
|
|
57
|
+
"""Initialize collections if None."""
|
|
58
|
+
if self.accounts is None:
|
|
59
|
+
self.accounts = []
|
|
60
|
+
if self.child_ous is None:
|
|
61
|
+
self.child_ous = []
|
|
62
|
+
|
|
63
|
+
if not self.id or not self.id.startswith('ou-'):
|
|
64
|
+
raise ValueError(f"Invalid OU ID format: {self.id}")
|
|
65
|
+
|
|
66
|
+
if not self.name or len(self.name.strip()) == 0:
|
|
67
|
+
raise ValueError("OU name cannot be empty.")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class AWSOrganization:
|
|
72
|
+
"""AWS Organization model."""
|
|
73
|
+
id: str
|
|
74
|
+
master_account_id: str
|
|
75
|
+
feature_set: str
|
|
76
|
+
root_id: str
|
|
77
|
+
organizational_units: List[OrganizationalUnit] = None
|
|
78
|
+
accounts: List[AWSAccount] = None
|
|
79
|
+
|
|
80
|
+
def __post_init__(self):
|
|
81
|
+
"""Initialize collections if None."""
|
|
82
|
+
if self.organizational_units is None:
|
|
83
|
+
self.organizational_units = []
|
|
84
|
+
if self.accounts is None:
|
|
85
|
+
self.accounts = []
|
|
86
|
+
|
|
87
|
+
if not self.master_account_id.isdigit() or len(self.master_account_id) != 12:
|
|
88
|
+
raise ValueError(f"Invalid master account ID: {self.master_account_id}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class IdentityStoreUser:
|
|
93
|
+
"""Identity Store User model."""
|
|
94
|
+
user_id: str
|
|
95
|
+
username: str
|
|
96
|
+
display_name: Optional[str] = None
|
|
97
|
+
email: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
def __post_init__(self):
|
|
100
|
+
"""Validate user data."""
|
|
101
|
+
if not self.user_id:
|
|
102
|
+
raise ValueError("User ID cannot be empty.")
|
|
103
|
+
|
|
104
|
+
if not self.username:
|
|
105
|
+
raise ValueError("Username cannot be empty.")
|
|
106
|
+
|
|
107
|
+
if self.email and not AWSAccount._is_valid_email(self.email):
|
|
108
|
+
raise ValueError(f"Invalid email format: {self.email}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class IdentityStoreGroup:
|
|
113
|
+
"""Identity Store Group model."""
|
|
114
|
+
group_id: str
|
|
115
|
+
display_name: str
|
|
116
|
+
description: Optional[str] = None
|
|
117
|
+
members: List[IdentityStoreUser] = None
|
|
118
|
+
|
|
119
|
+
def __post_init__(self):
|
|
120
|
+
"""Initialize members if None."""
|
|
121
|
+
if self.members is None:
|
|
122
|
+
self.members = []
|
|
123
|
+
|
|
124
|
+
if not self.group_id:
|
|
125
|
+
raise ValueError("Group ID cannot be empty.")
|
|
126
|
+
|
|
127
|
+
if not self.display_name:
|
|
128
|
+
raise ValueError("Group display name cannot be empty.")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass
|
|
132
|
+
class PermissionSet:
|
|
133
|
+
"""Permission Set model."""
|
|
134
|
+
arn: str
|
|
135
|
+
name: str
|
|
136
|
+
description: Optional[str] = None
|
|
137
|
+
session_duration: Optional[str] = None
|
|
138
|
+
|
|
139
|
+
def __post_init__(self):
|
|
140
|
+
"""Validate permission set data."""
|
|
141
|
+
if not self.arn or not self.arn.startswith('arn:aws:sso:::permissionSet/'):
|
|
142
|
+
raise ValueError(f"Invalid permission set ARN: {self.arn}")
|
|
143
|
+
|
|
144
|
+
if not self.name:
|
|
145
|
+
raise ValueError("Permission set name cannot be empty.")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@dataclass
|
|
149
|
+
class AccountAssignment:
|
|
150
|
+
"""Account Assignment model."""
|
|
151
|
+
account_id: str
|
|
152
|
+
permission_set_arn: str
|
|
153
|
+
principal_type: PrincipalType
|
|
154
|
+
principal_id: str
|
|
155
|
+
principal_name: Optional[str] = None
|
|
156
|
+
permission_set_name: Optional[str] = None
|
|
157
|
+
|
|
158
|
+
def __post_init__(self):
|
|
159
|
+
"""Validate assignment data."""
|
|
160
|
+
if not self.account_id.isdigit() or len(self.account_id) != 12:
|
|
161
|
+
raise ValueError(f"Invalid account ID: {self.account_id}")
|
|
162
|
+
|
|
163
|
+
if not self.permission_set_arn.startswith('arn:aws:sso:::permissionSet/'):
|
|
164
|
+
raise ValueError(f"Invalid permission set ARN: {self.permission_set_arn}")
|
|
165
|
+
|
|
166
|
+
if not self.principal_id:
|
|
167
|
+
raise ValueError("Principal ID cannot be empty.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class DiagramConfig:
|
|
172
|
+
"""Configuration for diagram generation."""
|
|
173
|
+
title: str
|
|
174
|
+
direction: str = "TB" # Top to Bottom
|
|
175
|
+
show_labels: bool = True
|
|
176
|
+
output_format: str = "png"
|
|
177
|
+
output_path: Optional[str] = None
|
|
178
|
+
|
|
179
|
+
def __post_init__(self):
|
|
180
|
+
"""Validate diagram configuration."""
|
|
181
|
+
valid_directions = ["TB", "BT", "LR", "RL"]
|
|
182
|
+
if self.direction not in valid_directions:
|
|
183
|
+
raise ValueError(f"Invalid direction: {self.direction}. Must be one of {valid_directions}")
|
|
184
|
+
|
|
185
|
+
valid_formats = ["png", "svg", "pdf"]
|
|
186
|
+
if self.output_format not in valid_formats:
|
|
187
|
+
raise ValueError(f"Invalid output format: {self.output_format}. Must be one of {valid_formats}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def validate_aws_response(response: Dict[str, Any], required_keys: List[str]) -> None:
|
|
191
|
+
"""
|
|
192
|
+
Validate AWS API response contains required keys.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
response: AWS API response dictionary
|
|
196
|
+
required_keys: List of required keys
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValueError: If required keys are missing
|
|
200
|
+
"""
|
|
201
|
+
missing_keys = [key for key in required_keys if key not in response]
|
|
202
|
+
if missing_keys:
|
|
203
|
+
raise ValueError(f"AWS response missing required keys: {missing_keys}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def sanitize_name_for_diagram(name: str) -> str:
|
|
207
|
+
"""
|
|
208
|
+
Sanitize name for use in diagram generation.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
name: Original name
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Sanitized name safe for diagram use
|
|
215
|
+
"""
|
|
216
|
+
if not name:
|
|
217
|
+
return "Unknown"
|
|
218
|
+
|
|
219
|
+
# Remove special characters and limit length
|
|
220
|
+
sanitized = re.sub(r'[^\w\s-]', '', name)
|
|
221
|
+
sanitized = re.sub(r'\s+', ' ', sanitized).strip()
|
|
222
|
+
|
|
223
|
+
# Limit length and add line breaks for long names
|
|
224
|
+
if len(sanitized) > 20:
|
|
225
|
+
words = sanitized.split()
|
|
226
|
+
lines = []
|
|
227
|
+
current_line = ""
|
|
228
|
+
|
|
229
|
+
for word in words:
|
|
230
|
+
if len(current_line + " " + word) <= 20:
|
|
231
|
+
current_line += " " + word if current_line else word
|
|
232
|
+
else:
|
|
233
|
+
if current_line:
|
|
234
|
+
lines.append(current_line)
|
|
235
|
+
current_line = word
|
|
236
|
+
|
|
237
|
+
if current_line:
|
|
238
|
+
lines.append(current_line)
|
|
239
|
+
|
|
240
|
+
sanitized = "\\n".join(lines[:3]) # Max 3 lines
|
|
241
|
+
|
|
242
|
+
return sanitized
|
src/plugins/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Plugin system for Reverse Diagrams."""
|
|
2
|
+
|
|
3
|
+
from .base import AWSServicePlugin, PluginManager
|
|
4
|
+
from .registry import get_plugin_manager, register_plugin, discover_plugins
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
'AWSServicePlugin',
|
|
8
|
+
'PluginManager',
|
|
9
|
+
'get_plugin_manager',
|
|
10
|
+
'register_plugin',
|
|
11
|
+
'discover_plugins'
|
|
12
|
+
]
|
src/plugins/base.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""Base plugin system for AWS service integrations."""
|
|
2
|
+
import logging
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Dict, Any, List, Optional, Type
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..aws.client_manager import AWSClientManager
|
|
9
|
+
from ..models import DiagramConfig
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class PluginMetadata:
|
|
16
|
+
"""Metadata for a plugin."""
|
|
17
|
+
name: str
|
|
18
|
+
version: str
|
|
19
|
+
description: str
|
|
20
|
+
author: str
|
|
21
|
+
aws_services: List[str] # AWS services this plugin supports
|
|
22
|
+
dependencies: List[str] = None # Additional dependencies
|
|
23
|
+
|
|
24
|
+
def __post_init__(self):
|
|
25
|
+
if self.dependencies is None:
|
|
26
|
+
self.dependencies = []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AWSServicePlugin(ABC):
|
|
30
|
+
"""Base class for AWS service plugins."""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
"""Initialize the plugin."""
|
|
34
|
+
self._metadata: Optional[PluginMetadata] = None
|
|
35
|
+
self._client_manager: Optional[AWSClientManager] = None
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def metadata(self) -> PluginMetadata:
|
|
40
|
+
"""Get plugin metadata."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def collect_data(self, client_manager: AWSClientManager, region: str, **kwargs) -> Dict[str, Any]:
|
|
45
|
+
"""
|
|
46
|
+
Collect data from AWS services.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
client_manager: AWS client manager
|
|
50
|
+
region: AWS region
|
|
51
|
+
**kwargs: Additional parameters
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Collected data dictionary
|
|
55
|
+
"""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
@abstractmethod
|
|
59
|
+
def generate_diagram_code(self, data: Dict[str, Any], config: DiagramConfig) -> str:
|
|
60
|
+
"""
|
|
61
|
+
Generate diagram code from collected data.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
data: Data collected from AWS
|
|
65
|
+
config: Diagram configuration
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Python code for generating diagram
|
|
69
|
+
"""
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
def validate_data(self, data: Dict[str, Any]) -> bool:
|
|
73
|
+
"""
|
|
74
|
+
Validate collected data.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
data: Data to validate
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if data is valid, False otherwise
|
|
81
|
+
"""
|
|
82
|
+
return bool(data) # Default implementation
|
|
83
|
+
|
|
84
|
+
def get_required_permissions(self) -> List[str]:
|
|
85
|
+
"""
|
|
86
|
+
Get list of required AWS permissions.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
List of IAM permission strings
|
|
90
|
+
"""
|
|
91
|
+
return [] # Default implementation
|
|
92
|
+
|
|
93
|
+
def setup(self, client_manager: AWSClientManager) -> None:
|
|
94
|
+
"""
|
|
95
|
+
Setup the plugin with client manager.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
client_manager: AWS client manager
|
|
99
|
+
"""
|
|
100
|
+
self._client_manager = client_manager
|
|
101
|
+
logger.debug(f"Plugin {self.metadata.name} setup complete")
|
|
102
|
+
|
|
103
|
+
def cleanup(self) -> None:
|
|
104
|
+
"""Cleanup plugin resources."""
|
|
105
|
+
self._client_manager = None
|
|
106
|
+
logger.debug(f"Plugin {self.metadata.name} cleanup complete")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PluginManager:
|
|
110
|
+
"""Manager for AWS service plugins."""
|
|
111
|
+
|
|
112
|
+
def __init__(self):
|
|
113
|
+
"""Initialize plugin manager."""
|
|
114
|
+
self._plugins: Dict[str, AWSServicePlugin] = {}
|
|
115
|
+
self._plugin_classes: Dict[str, Type[AWSServicePlugin]] = {}
|
|
116
|
+
|
|
117
|
+
def register_plugin(self, plugin_class: Type[AWSServicePlugin]) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Register a plugin class.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
plugin_class: Plugin class to register
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
# Create instance to get metadata
|
|
126
|
+
plugin_instance = plugin_class()
|
|
127
|
+
metadata = plugin_instance.metadata
|
|
128
|
+
|
|
129
|
+
if metadata.name in self._plugin_classes:
|
|
130
|
+
logger.warning(f"Plugin {metadata.name} is already registered, overriding")
|
|
131
|
+
|
|
132
|
+
self._plugin_classes[metadata.name] = plugin_class
|
|
133
|
+
logger.debug(f"Registered plugin: {metadata.name} v{metadata.version}")
|
|
134
|
+
|
|
135
|
+
except Exception as e:
|
|
136
|
+
logger.error(f"Failed to register plugin {plugin_class.__name__}: {e}")
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
def load_plugin(self, plugin_name: str, client_manager: AWSClientManager) -> AWSServicePlugin:
|
|
140
|
+
"""
|
|
141
|
+
Load and setup a plugin.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
plugin_name: Name of the plugin to load
|
|
145
|
+
client_manager: AWS client manager
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Loaded plugin instance
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: If plugin is not found
|
|
152
|
+
"""
|
|
153
|
+
if plugin_name not in self._plugin_classes:
|
|
154
|
+
raise ValueError(f"Plugin {plugin_name} not found. Available: {list(self._plugin_classes.keys())}")
|
|
155
|
+
|
|
156
|
+
if plugin_name in self._plugins:
|
|
157
|
+
logger.debug(f"Plugin {plugin_name} already loaded")
|
|
158
|
+
return self._plugins[plugin_name]
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
plugin_class = self._plugin_classes[plugin_name]
|
|
162
|
+
plugin_instance = plugin_class()
|
|
163
|
+
plugin_instance.setup(client_manager)
|
|
164
|
+
|
|
165
|
+
self._plugins[plugin_name] = plugin_instance
|
|
166
|
+
logger.debug(f"Loaded plugin: {plugin_name}")
|
|
167
|
+
|
|
168
|
+
return plugin_instance
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Failed to load plugin {plugin_name}: {e}")
|
|
172
|
+
raise
|
|
173
|
+
|
|
174
|
+
def unload_plugin(self, plugin_name: str) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Unload a plugin.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
plugin_name: Name of the plugin to unload
|
|
180
|
+
"""
|
|
181
|
+
if plugin_name in self._plugins:
|
|
182
|
+
plugin = self._plugins[plugin_name]
|
|
183
|
+
plugin.cleanup()
|
|
184
|
+
del self._plugins[plugin_name]
|
|
185
|
+
logger.debug(f"Unloaded plugin: {plugin_name}")
|
|
186
|
+
|
|
187
|
+
def get_plugin(self, plugin_name: str) -> Optional[AWSServicePlugin]:
|
|
188
|
+
"""
|
|
189
|
+
Get a loaded plugin.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
plugin_name: Name of the plugin
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
Plugin instance if loaded, None otherwise
|
|
196
|
+
"""
|
|
197
|
+
return self._plugins.get(plugin_name)
|
|
198
|
+
|
|
199
|
+
def list_available_plugins(self) -> List[PluginMetadata]:
|
|
200
|
+
"""
|
|
201
|
+
List all available plugins.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of plugin metadata
|
|
205
|
+
"""
|
|
206
|
+
metadata_list = []
|
|
207
|
+
for plugin_class in self._plugin_classes.values():
|
|
208
|
+
try:
|
|
209
|
+
instance = plugin_class()
|
|
210
|
+
metadata_list.append(instance.metadata)
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.warning(f"Failed to get metadata for plugin {plugin_class.__name__}: {e}")
|
|
213
|
+
|
|
214
|
+
return metadata_list
|
|
215
|
+
|
|
216
|
+
def list_loaded_plugins(self) -> List[str]:
|
|
217
|
+
"""
|
|
218
|
+
List names of loaded plugins.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
List of loaded plugin names
|
|
222
|
+
"""
|
|
223
|
+
return list(self._plugins.keys())
|
|
224
|
+
|
|
225
|
+
def get_plugins_for_service(self, aws_service: str) -> List[AWSServicePlugin]:
|
|
226
|
+
"""
|
|
227
|
+
Get all loaded plugins that support a specific AWS service.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
aws_service: AWS service name
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
List of plugins supporting the service
|
|
234
|
+
"""
|
|
235
|
+
matching_plugins = []
|
|
236
|
+
|
|
237
|
+
for plugin in self._plugins.values():
|
|
238
|
+
if aws_service in plugin.metadata.aws_services:
|
|
239
|
+
matching_plugins.append(plugin)
|
|
240
|
+
|
|
241
|
+
return matching_plugins
|
|
242
|
+
|
|
243
|
+
def cleanup_all(self) -> None:
|
|
244
|
+
"""Cleanup all loaded plugins."""
|
|
245
|
+
for plugin_name in list(self._plugins.keys()):
|
|
246
|
+
self.unload_plugin(plugin_name)
|
|
247
|
+
|
|
248
|
+
logger.debug("All plugins cleaned up")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def discover_plugins_in_directory(directory: Path) -> List[Type[AWSServicePlugin]]:
|
|
252
|
+
"""
|
|
253
|
+
Discover plugins in a directory.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
directory: Directory to search for plugins
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
List of discovered plugin classes
|
|
260
|
+
"""
|
|
261
|
+
discovered_plugins = []
|
|
262
|
+
|
|
263
|
+
if not directory.exists() or not directory.is_dir():
|
|
264
|
+
logger.debug(f"Plugin directory {directory} does not exist")
|
|
265
|
+
return discovered_plugins
|
|
266
|
+
|
|
267
|
+
# Look for Python files in the directory
|
|
268
|
+
for plugin_file in directory.glob("*.py"):
|
|
269
|
+
if plugin_file.name.startswith("_"):
|
|
270
|
+
continue # Skip private files
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
# Import the module dynamically
|
|
274
|
+
import importlib.util
|
|
275
|
+
spec = importlib.util.spec_from_file_location(plugin_file.stem, plugin_file)
|
|
276
|
+
if spec and spec.loader:
|
|
277
|
+
module = importlib.util.module_from_spec(spec)
|
|
278
|
+
spec.loader.exec_module(module)
|
|
279
|
+
|
|
280
|
+
# Look for plugin classes
|
|
281
|
+
for attr_name in dir(module):
|
|
282
|
+
attr = getattr(module, attr_name)
|
|
283
|
+
if (isinstance(attr, type) and
|
|
284
|
+
issubclass(attr, AWSServicePlugin) and
|
|
285
|
+
attr != AWSServicePlugin):
|
|
286
|
+
discovered_plugins.append(attr)
|
|
287
|
+
logger.debug(f"Discovered plugin class: {attr.__name__} in {plugin_file}")
|
|
288
|
+
|
|
289
|
+
except Exception as e:
|
|
290
|
+
logger.warning(f"Failed to load plugin from {plugin_file}: {e}")
|
|
291
|
+
|
|
292
|
+
return discovered_plugins
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Built-in plugins for Reverse Diagrams."""
|
|
2
|
+
|
|
3
|
+
# Import all built-in plugins to ensure they're available for discovery
|
|
4
|
+
from .ec2_plugin import EC2Plugin
|
|
5
|
+
from .organizations_plugin import OrganizationsPlugin
|
|
6
|
+
from .identity_center_plugin import IdentityCenterPlugin
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
'EC2Plugin',
|
|
10
|
+
'OrganizationsPlugin',
|
|
11
|
+
'IdentityCenterPlugin'
|
|
12
|
+
]
|