terraformgraph 1.0.2__py3-none-any.whl → 1.0.4__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.
- terraformgraph/__init__.py +1 -1
- terraformgraph/__main__.py +1 -1
- terraformgraph/aggregator.py +941 -300
- terraformgraph/config/aggregation_rules.yaml +276 -1
- terraformgraph/config_loader.py +9 -8
- terraformgraph/icons.py +504 -521
- terraformgraph/layout.py +580 -116
- terraformgraph/main.py +251 -48
- terraformgraph/parser.py +328 -86
- terraformgraph/renderer.py +1887 -170
- terraformgraph/terraform_tools.py +355 -0
- terraformgraph/variable_resolver.py +180 -0
- terraformgraph-1.0.4.dist-info/METADATA +386 -0
- terraformgraph-1.0.4.dist-info/RECORD +19 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/licenses/LICENSE +1 -1
- terraformgraph-1.0.2.dist-info/METADATA +0 -163
- terraformgraph-1.0.2.dist-info/RECORD +0 -17
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/WHEEL +0 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/entry_points.txt +0 -0
- {terraformgraph-1.0.2.dist-info → terraformgraph-1.0.4.dist-info}/top_level.txt +0 -0
terraformgraph/aggregator.py
CHANGED
|
@@ -5,301 +5,106 @@ Aggregates low-level Terraform resources into high-level logical services
|
|
|
5
5
|
for cleaner architecture diagrams.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import re
|
|
8
9
|
from dataclasses import dataclass, field
|
|
9
|
-
from
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
|
|
10
12
|
|
|
11
13
|
from .config_loader import ConfigLoader
|
|
12
14
|
from .parser import ParseResult, TerraformResource
|
|
13
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .terraform_tools import TerraformStateResult
|
|
18
|
+
from .variable_resolver import VariableResolver
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# VPC Structure Data Models (Task 5)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Subnet:
|
|
26
|
+
"""Represents a subnet within a VPC."""
|
|
27
|
+
|
|
28
|
+
resource_id: str
|
|
29
|
+
name: str
|
|
30
|
+
subnet_type: str # 'public', 'private', 'database', 'unknown'
|
|
31
|
+
availability_zone: str
|
|
32
|
+
cidr_block: Optional[str] = None
|
|
33
|
+
aws_id: Optional[str] = None # AWS subnet ID (e.g., 'subnet-xxx') from state
|
|
34
|
+
route_table_name: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class AvailabilityZone:
|
|
39
|
+
"""Represents an availability zone containing subnets."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
short_name: str # e.g., '1a', '1b'
|
|
43
|
+
subnets: List[Subnet] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class VPCEndpoint:
|
|
48
|
+
"""Represents a VPC endpoint."""
|
|
49
|
+
|
|
50
|
+
resource_id: str
|
|
51
|
+
name: str
|
|
52
|
+
endpoint_type: str # 'gateway' or 'interface'
|
|
53
|
+
service: str # e.g., 's3', 'dynamodb', 'ecr.api'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class VPCStructure:
|
|
58
|
+
"""Represents the complete VPC structure with AZs and endpoints."""
|
|
59
|
+
|
|
60
|
+
vpc_id: str
|
|
61
|
+
name: str
|
|
62
|
+
availability_zones: List[AvailabilityZone] = field(default_factory=list)
|
|
63
|
+
endpoints: List[VPCEndpoint] = field(default_factory=list)
|
|
64
|
+
|
|
14
65
|
|
|
15
66
|
@dataclass
|
|
16
67
|
class LogicalService:
|
|
17
68
|
"""A high-level logical service aggregating multiple resources."""
|
|
69
|
+
|
|
18
70
|
service_type: str # e.g., 'alb', 'ecs', 's3', 'sqs'
|
|
19
71
|
name: str
|
|
20
72
|
icon_resource_type: str # The Terraform type to use for the icon
|
|
21
73
|
resources: List[TerraformResource] = field(default_factory=list)
|
|
22
74
|
count: int = 1 # How many instances (e.g., 24 SQS queues)
|
|
23
75
|
is_vpc_resource: bool = False
|
|
24
|
-
|
|
76
|
+
subnet_ids: List[str] = field(
|
|
77
|
+
default_factory=list
|
|
78
|
+
) # Subnet resource IDs this service belongs to
|
|
79
|
+
resource_id: Optional[str] = None # For de-grouped VPC services, uses resource's full_id
|
|
25
80
|
|
|
26
81
|
@property
|
|
27
82
|
def id(self) -> str:
|
|
83
|
+
# Use resource_id for de-grouped services (unique per resource)
|
|
84
|
+
if self.resource_id:
|
|
85
|
+
return self.resource_id
|
|
28
86
|
return f"{self.service_type}.{self.name}"
|
|
29
87
|
|
|
30
88
|
|
|
31
89
|
@dataclass
|
|
32
90
|
class LogicalConnection:
|
|
33
91
|
"""A connection between logical services."""
|
|
92
|
+
|
|
34
93
|
source_id: str
|
|
35
94
|
target_id: str
|
|
36
95
|
label: Optional[str] = None
|
|
37
|
-
connection_type: str =
|
|
96
|
+
connection_type: str = "default" # 'default', 'data_flow', 'trigger', 'encrypt'
|
|
38
97
|
|
|
39
98
|
|
|
40
99
|
@dataclass
|
|
41
100
|
class AggregatedResult:
|
|
42
101
|
"""Result of aggregating resources into logical services."""
|
|
102
|
+
|
|
43
103
|
services: List[LogicalService] = field(default_factory=list)
|
|
44
104
|
connections: List[LogicalConnection] = field(default_factory=list)
|
|
45
105
|
vpc_services: List[LogicalService] = field(default_factory=list)
|
|
46
106
|
global_services: List[LogicalService] = field(default_factory=list)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
# Define which resource types should be aggregated together
|
|
50
|
-
AGGREGATION_RULES = {
|
|
51
|
-
# Load Balancing: ALB + listeners + target groups = one ALB
|
|
52
|
-
'alb': {
|
|
53
|
-
'primary': ['aws_lb'],
|
|
54
|
-
'aggregate': ['aws_lb_listener', 'aws_lb_target_group', 'aws_lb_target_group_attachment'],
|
|
55
|
-
'icon': 'aws_lb',
|
|
56
|
-
'display_name': 'Load Balancer',
|
|
57
|
-
'is_vpc': True,
|
|
58
|
-
},
|
|
59
|
-
# ECS: cluster + services + task definitions = one ECS
|
|
60
|
-
'ecs': {
|
|
61
|
-
'primary': ['aws_ecs_cluster'],
|
|
62
|
-
'aggregate': ['aws_ecs_service', 'aws_ecs_task_definition'],
|
|
63
|
-
'icon': 'aws_ecs_cluster',
|
|
64
|
-
'display_name': 'ECS Cluster',
|
|
65
|
-
'is_vpc': True,
|
|
66
|
-
},
|
|
67
|
-
# VPC: vpc + subnets + gateways + route tables = one VPC
|
|
68
|
-
'vpc': {
|
|
69
|
-
'primary': ['aws_vpc'],
|
|
70
|
-
'aggregate': ['aws_subnet', 'aws_internet_gateway', 'aws_nat_gateway',
|
|
71
|
-
'aws_route_table', 'aws_route', 'aws_route_table_association',
|
|
72
|
-
'aws_eip', 'aws_vpc_endpoint', 'aws_db_subnet_group'],
|
|
73
|
-
'icon': 'aws_vpc',
|
|
74
|
-
'display_name': 'VPC',
|
|
75
|
-
'is_vpc': True,
|
|
76
|
-
},
|
|
77
|
-
# Security Groups: aggregate all SGs
|
|
78
|
-
'security': {
|
|
79
|
-
'primary': ['aws_security_group'],
|
|
80
|
-
'aggregate': ['aws_security_group_rule'],
|
|
81
|
-
'icon': 'aws_security_group',
|
|
82
|
-
'display_name': 'Security Groups',
|
|
83
|
-
'is_vpc': True,
|
|
84
|
-
},
|
|
85
|
-
# S3: buckets (aggregate policies, versioning, etc.)
|
|
86
|
-
's3': {
|
|
87
|
-
'primary': ['aws_s3_bucket'],
|
|
88
|
-
'aggregate': ['aws_s3_bucket_policy', 'aws_s3_bucket_versioning',
|
|
89
|
-
'aws_s3_bucket_lifecycle_configuration', 'aws_s3_bucket_notification',
|
|
90
|
-
'aws_s3_bucket_cors_configuration', 'aws_s3_bucket_public_access_block',
|
|
91
|
-
'aws_s3_bucket_ownership_controls', 'aws_s3_bucket_server_side_encryption_configuration'],
|
|
92
|
-
'icon': 'aws_s3_bucket',
|
|
93
|
-
'display_name': 'S3 Buckets',
|
|
94
|
-
'is_vpc': False,
|
|
95
|
-
},
|
|
96
|
-
# SQS: aggregate all queues
|
|
97
|
-
'sqs': {
|
|
98
|
-
'primary': ['aws_sqs_queue'],
|
|
99
|
-
'aggregate': ['aws_sqs_queue_policy'],
|
|
100
|
-
'icon': 'aws_sqs_queue',
|
|
101
|
-
'display_name': 'SQS Queues',
|
|
102
|
-
'is_vpc': False,
|
|
103
|
-
},
|
|
104
|
-
# SNS: aggregate topics
|
|
105
|
-
'sns': {
|
|
106
|
-
'primary': ['aws_sns_topic'],
|
|
107
|
-
'aggregate': ['aws_sns_topic_policy', 'aws_sns_topic_subscription'],
|
|
108
|
-
'icon': 'aws_sns_topic',
|
|
109
|
-
'display_name': 'SNS Topics',
|
|
110
|
-
'is_vpc': False,
|
|
111
|
-
},
|
|
112
|
-
# Cognito: user pool + clients + domain
|
|
113
|
-
'cognito': {
|
|
114
|
-
'primary': ['aws_cognito_user_pool'],
|
|
115
|
-
'aggregate': ['aws_cognito_user_pool_client', 'aws_cognito_user_pool_domain',
|
|
116
|
-
'aws_cognito_identity_pool', 'aws_cognito_identity_pool_roles_attachment',
|
|
117
|
-
'aws_cognito_log_delivery_configuration'],
|
|
118
|
-
'icon': 'aws_cognito_user_pool',
|
|
119
|
-
'display_name': 'Cognito',
|
|
120
|
-
'is_vpc': False,
|
|
121
|
-
},
|
|
122
|
-
# KMS: keys + aliases
|
|
123
|
-
'kms': {
|
|
124
|
-
'primary': ['aws_kms_key'],
|
|
125
|
-
'aggregate': ['aws_kms_alias'],
|
|
126
|
-
'icon': 'aws_kms_key',
|
|
127
|
-
'display_name': 'KMS Keys',
|
|
128
|
-
'is_vpc': False,
|
|
129
|
-
},
|
|
130
|
-
# Secrets Manager
|
|
131
|
-
'secrets': {
|
|
132
|
-
'primary': ['aws_secretsmanager_secret'],
|
|
133
|
-
'aggregate': ['aws_secretsmanager_secret_version'],
|
|
134
|
-
'icon': 'aws_secretsmanager_secret',
|
|
135
|
-
'display_name': 'Secrets Manager',
|
|
136
|
-
'is_vpc': False,
|
|
137
|
-
},
|
|
138
|
-
# Route53
|
|
139
|
-
'route53': {
|
|
140
|
-
'primary': ['aws_route53_zone'],
|
|
141
|
-
'aggregate': ['aws_route53_record'],
|
|
142
|
-
'icon': 'aws_route53_zone',
|
|
143
|
-
'display_name': 'Route 53',
|
|
144
|
-
'is_vpc': False,
|
|
145
|
-
},
|
|
146
|
-
# ACM Certificates
|
|
147
|
-
'acm': {
|
|
148
|
-
'primary': ['aws_acm_certificate'],
|
|
149
|
-
'aggregate': ['aws_acm_certificate_validation'],
|
|
150
|
-
'icon': 'aws_acm_certificate',
|
|
151
|
-
'display_name': 'Certificates',
|
|
152
|
-
'is_vpc': False,
|
|
153
|
-
},
|
|
154
|
-
# CloudWatch
|
|
155
|
-
'cloudwatch': {
|
|
156
|
-
'primary': ['aws_cloudwatch_log_group', 'aws_cloudwatch_metric_alarm'],
|
|
157
|
-
'aggregate': ['aws_cloudwatch_log_resource_policy', 'aws_cloudwatch_log_delivery',
|
|
158
|
-
'aws_cloudwatch_log_delivery_source', 'aws_cloudwatch_log_delivery_destination',
|
|
159
|
-
'aws_cloudwatch_dashboard'],
|
|
160
|
-
'icon': 'aws_cloudwatch_metric_alarm',
|
|
161
|
-
'display_name': 'CloudWatch',
|
|
162
|
-
'is_vpc': False,
|
|
163
|
-
},
|
|
164
|
-
# EventBridge
|
|
165
|
-
'eventbridge': {
|
|
166
|
-
'primary': ['aws_cloudwatch_event_rule', 'aws_cloudwatch_event_bus'],
|
|
167
|
-
'aggregate': ['aws_cloudwatch_event_target', 'aws_cloudwatch_event_archive'],
|
|
168
|
-
'icon': 'aws_cloudwatch_event_rule',
|
|
169
|
-
'display_name': 'EventBridge',
|
|
170
|
-
'is_vpc': False,
|
|
171
|
-
},
|
|
172
|
-
# WAF
|
|
173
|
-
'waf': {
|
|
174
|
-
'primary': ['aws_wafv2_web_acl'],
|
|
175
|
-
'aggregate': ['aws_wafv2_web_acl_association', 'aws_wafv2_rule_group'],
|
|
176
|
-
'icon': 'aws_wafv2_web_acl',
|
|
177
|
-
'display_name': 'WAF',
|
|
178
|
-
'is_vpc': False,
|
|
179
|
-
},
|
|
180
|
-
# IAM
|
|
181
|
-
'iam': {
|
|
182
|
-
'primary': ['aws_iam_role'],
|
|
183
|
-
'aggregate': ['aws_iam_policy', 'aws_iam_role_policy', 'aws_iam_role_policy_attachment',
|
|
184
|
-
'aws_iam_instance_profile'],
|
|
185
|
-
'icon': 'aws_iam_role',
|
|
186
|
-
'display_name': 'IAM Roles',
|
|
187
|
-
'is_vpc': False,
|
|
188
|
-
},
|
|
189
|
-
# ECR
|
|
190
|
-
'ecr': {
|
|
191
|
-
'primary': ['aws_ecr_repository'],
|
|
192
|
-
'aggregate': [],
|
|
193
|
-
'icon': 'aws_ecr_repository',
|
|
194
|
-
'display_name': 'ECR',
|
|
195
|
-
'is_vpc': False,
|
|
196
|
-
},
|
|
197
|
-
# DynamoDB
|
|
198
|
-
'dynamodb': {
|
|
199
|
-
'primary': ['aws_dynamodb_table'],
|
|
200
|
-
'aggregate': [],
|
|
201
|
-
'icon': 'aws_dynamodb_table',
|
|
202
|
-
'display_name': 'DynamoDB',
|
|
203
|
-
'is_vpc': False,
|
|
204
|
-
},
|
|
205
|
-
# SES
|
|
206
|
-
'ses': {
|
|
207
|
-
'primary': ['aws_ses_domain_identity'],
|
|
208
|
-
'aggregate': ['aws_ses_domain_dkim', 'aws_ses_domain_mail_from',
|
|
209
|
-
'aws_ses_identity_notification_topic', 'aws_ses_configuration_set'],
|
|
210
|
-
'icon': 'aws_ses_domain_identity',
|
|
211
|
-
'display_name': 'SES',
|
|
212
|
-
'is_vpc': False,
|
|
213
|
-
},
|
|
214
|
-
# CloudFront
|
|
215
|
-
'cloudfront': {
|
|
216
|
-
'primary': ['aws_cloudfront_distribution'],
|
|
217
|
-
'aggregate': ['aws_cloudfront_origin_access_control'],
|
|
218
|
-
'icon': 'aws_cloudfront_distribution',
|
|
219
|
-
'display_name': 'CloudFront',
|
|
220
|
-
'is_vpc': False,
|
|
221
|
-
},
|
|
222
|
-
# Bedrock
|
|
223
|
-
'bedrock': {
|
|
224
|
-
'primary': ['aws_bedrockagent_knowledge_base'],
|
|
225
|
-
'aggregate': [],
|
|
226
|
-
'icon': 'aws_bedrockagent_knowledge_base',
|
|
227
|
-
'display_name': 'Bedrock KB',
|
|
228
|
-
'is_vpc': False,
|
|
229
|
-
},
|
|
230
|
-
# Budgets
|
|
231
|
-
'budgets': {
|
|
232
|
-
'primary': ['aws_budgets_budget'],
|
|
233
|
-
'aggregate': [],
|
|
234
|
-
'icon': 'aws_budgets_budget',
|
|
235
|
-
'display_name': 'Budgets',
|
|
236
|
-
'is_vpc': False,
|
|
237
|
-
},
|
|
238
|
-
# EC2 (standalone instances like DevOps agent)
|
|
239
|
-
'ec2': {
|
|
240
|
-
'primary': ['aws_instance'],
|
|
241
|
-
'aggregate': ['aws_launch_template'],
|
|
242
|
-
'icon': 'aws_instance',
|
|
243
|
-
'display_name': 'EC2',
|
|
244
|
-
'is_vpc': True,
|
|
245
|
-
},
|
|
246
|
-
# MongoDB Atlas (external)
|
|
247
|
-
'mongodb': {
|
|
248
|
-
'primary': ['mongodbatlas_cluster'],
|
|
249
|
-
'aggregate': ['mongodbatlas_network_peering', 'mongodbatlas_project_ip_access_list'],
|
|
250
|
-
'icon': 'aws_dynamodb_table', # Use DynamoDB icon as fallback
|
|
251
|
-
'display_name': 'MongoDB Atlas',
|
|
252
|
-
'is_vpc': False,
|
|
253
|
-
},
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
# High-level connections between service types
|
|
257
|
-
LOGICAL_CONNECTIONS = [
|
|
258
|
-
# Internet -> WAF -> CloudFront -> ALB
|
|
259
|
-
('cloudfront', 'alb', 'HTTPS', 'data_flow'),
|
|
260
|
-
('waf', 'alb', 'protects', 'default'),
|
|
261
|
-
('waf', 'cognito', 'protects', 'default'),
|
|
262
|
-
|
|
263
|
-
# ALB -> ECS
|
|
264
|
-
('alb', 'ecs', 'routes to', 'data_flow'),
|
|
265
|
-
|
|
266
|
-
# ECS -> various services
|
|
267
|
-
('ecs', 'sqs', 'sends/receives', 'data_flow'),
|
|
268
|
-
('ecs', 's3', 'reads/writes', 'data_flow'),
|
|
269
|
-
('ecs', 'dynamodb', 'queries', 'data_flow'),
|
|
270
|
-
('ecs', 'secrets', 'reads', 'default'),
|
|
271
|
-
('ecs', 'bedrock', 'invokes', 'data_flow'),
|
|
272
|
-
|
|
273
|
-
# S3 -> SQS (notifications)
|
|
274
|
-
('s3', 'sqs', 'triggers', 'trigger'),
|
|
275
|
-
|
|
276
|
-
# SNS for alerts
|
|
277
|
-
('cloudwatch', 'sns', 'alerts', 'trigger'),
|
|
278
|
-
('sqs', 'sns', 'DLQ alerts', 'trigger'),
|
|
279
|
-
|
|
280
|
-
# Encryption
|
|
281
|
-
('kms', 's3', 'encrypts', 'encrypt'),
|
|
282
|
-
('kms', 'sqs', 'encrypts', 'encrypt'),
|
|
283
|
-
('kms', 'sns', 'encrypts', 'encrypt'),
|
|
284
|
-
('kms', 'secrets', 'encrypts', 'encrypt'),
|
|
285
|
-
|
|
286
|
-
# DNS
|
|
287
|
-
('route53', 'alb', 'resolves', 'default'),
|
|
288
|
-
('route53', 'cloudfront', 'resolves', 'default'),
|
|
289
|
-
|
|
290
|
-
# Certificates
|
|
291
|
-
('acm', 'alb', 'TLS', 'default'),
|
|
292
|
-
('acm', 'cloudfront', 'TLS', 'default'),
|
|
293
|
-
|
|
294
|
-
# Cognito auth
|
|
295
|
-
('cognito', 'alb', 'authenticates', 'default'),
|
|
296
|
-
|
|
297
|
-
# ECR -> ECS
|
|
298
|
-
('ecr', 'ecs', 'images', 'data_flow'),
|
|
299
|
-
|
|
300
|
-
# External
|
|
301
|
-
('ecs', 'mongodb', 'queries', 'data_flow'),
|
|
302
|
-
]
|
|
107
|
+
vpc_structure: Optional[VPCStructure] = None
|
|
303
108
|
|
|
304
109
|
|
|
305
110
|
class ResourceAggregator:
|
|
@@ -318,11 +123,13 @@ class ResourceAggregator:
|
|
|
318
123
|
for service_name, config in flat_rules.items():
|
|
319
124
|
# Map YAML format (primary/secondary/in_vpc) to internal format
|
|
320
125
|
result[service_name] = {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
126
|
+
"primary": config.get("primary", []),
|
|
127
|
+
"aggregate": config.get(
|
|
128
|
+
"secondary", []
|
|
129
|
+
), # secondary in YAML -> aggregate internally
|
|
130
|
+
"icon": config.get("primary", [""])[0] if config.get("primary") else "",
|
|
131
|
+
"display_name": service_name.replace("_", " ").title(),
|
|
132
|
+
"is_vpc": config.get("in_vpc", False),
|
|
326
133
|
}
|
|
327
134
|
return result
|
|
328
135
|
|
|
@@ -330,67 +137,901 @@ class ResourceAggregator:
|
|
|
330
137
|
"""Build a mapping from resource type to aggregation rule."""
|
|
331
138
|
self._type_to_rule: Dict[str, str] = {}
|
|
332
139
|
for rule_name, rule in self._aggregation_rules.items():
|
|
333
|
-
for res_type in rule[
|
|
140
|
+
for res_type in rule["primary"]:
|
|
334
141
|
self._type_to_rule[res_type] = rule_name
|
|
335
|
-
for res_type in rule[
|
|
142
|
+
for res_type in rule["aggregate"]:
|
|
336
143
|
self._type_to_rule[res_type] = rule_name
|
|
337
144
|
|
|
338
|
-
def
|
|
339
|
-
|
|
145
|
+
def _extract_subnet_ids(
|
|
146
|
+
self,
|
|
147
|
+
resources: List[TerraformResource],
|
|
148
|
+
state_result: Optional["TerraformStateResult"] = None,
|
|
149
|
+
) -> List[str]:
|
|
150
|
+
"""Extract unique subnet IDs from a list of resources.
|
|
151
|
+
|
|
152
|
+
Checks both HCL attributes and terraform state for subnet information.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
resources: List of TerraformResource objects
|
|
156
|
+
state_result: Optional terraform state with resolved values
|
|
157
|
+
|
|
158
|
+
Returns:
|
|
159
|
+
List of unique subnet resource IDs (e.g., ['aws_subnet.public', 'aws_subnet.private'])
|
|
160
|
+
"""
|
|
161
|
+
subnet_ids: Set[str] = set()
|
|
162
|
+
|
|
163
|
+
# Build state index if available
|
|
164
|
+
state_index: Dict[str, Dict[str, Any]] = {}
|
|
165
|
+
if state_result:
|
|
166
|
+
from .terraform_tools import map_state_to_resource_id
|
|
167
|
+
|
|
168
|
+
for state_res in state_result.resources:
|
|
169
|
+
resource_id = map_state_to_resource_id(state_res.address)
|
|
170
|
+
state_index[resource_id] = state_res.values
|
|
171
|
+
|
|
172
|
+
for resource in resources:
|
|
173
|
+
# Check state values first (more accurate)
|
|
174
|
+
if resource.full_id in state_index:
|
|
175
|
+
state_values = state_index[resource.full_id]
|
|
176
|
+
# Check for subnet_id (single)
|
|
177
|
+
subnet_id = state_values.get("subnet_id")
|
|
178
|
+
if subnet_id and isinstance(subnet_id, str):
|
|
179
|
+
# State contains actual subnet ID (vpc-xxx), need to map back
|
|
180
|
+
# For now, store the raw value - we'll match by ID later
|
|
181
|
+
subnet_ids.add(f"_state_subnet:{subnet_id}")
|
|
182
|
+
|
|
183
|
+
# Check for subnet_ids (list)
|
|
184
|
+
subnet_id_list = state_values.get("subnet_ids")
|
|
185
|
+
if subnet_id_list and isinstance(subnet_id_list, list):
|
|
186
|
+
for sid in subnet_id_list:
|
|
187
|
+
if isinstance(sid, str):
|
|
188
|
+
subnet_ids.add(f"_state_subnet:{sid}")
|
|
189
|
+
|
|
190
|
+
# Check for subnets (list) - used by ALB/NLB resources
|
|
191
|
+
subnets_list = state_values.get("subnets")
|
|
192
|
+
if subnets_list and isinstance(subnets_list, list):
|
|
193
|
+
for sid in subnets_list:
|
|
194
|
+
if isinstance(sid, str):
|
|
195
|
+
subnet_ids.add(f"_state_subnet:{sid}")
|
|
196
|
+
|
|
197
|
+
# Check HCL attributes for references like aws_subnet.public.id
|
|
198
|
+
# Search in common attribute names and nested structures
|
|
199
|
+
self._extract_subnet_refs_from_attrs(resource.attributes, subnet_ids)
|
|
200
|
+
|
|
201
|
+
return list(subnet_ids)
|
|
202
|
+
|
|
203
|
+
def _extract_subnet_refs_from_attrs(
|
|
204
|
+
self,
|
|
205
|
+
attrs: Any,
|
|
206
|
+
subnet_ids: Set[str],
|
|
207
|
+
depth: int = 0,
|
|
208
|
+
) -> None:
|
|
209
|
+
"""Recursively extract subnet references from attributes.
|
|
210
|
+
|
|
211
|
+
Searches through nested dicts and lists for aws_subnet references.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
attrs: Attribute value (dict, list, or string)
|
|
215
|
+
subnet_ids: Set to add found subnet IDs to
|
|
216
|
+
depth: Current recursion depth (max 5 to prevent infinite loops)
|
|
217
|
+
"""
|
|
218
|
+
if depth > 5:
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
if isinstance(attrs, dict):
|
|
222
|
+
# Check specific keys that commonly contain subnet info
|
|
223
|
+
for key in ("subnet_id", "subnet_ids", "subnets", "network_configuration"):
|
|
224
|
+
if key in attrs:
|
|
225
|
+
self._extract_subnet_refs_from_attrs(attrs[key], subnet_ids, depth + 1)
|
|
226
|
+
# Also check all values for nested structures
|
|
227
|
+
for value in attrs.values():
|
|
228
|
+
if isinstance(value, (dict, list)):
|
|
229
|
+
self._extract_subnet_refs_from_attrs(value, subnet_ids, depth + 1)
|
|
230
|
+
elif isinstance(attrs, list):
|
|
231
|
+
for item in attrs:
|
|
232
|
+
self._extract_subnet_refs_from_attrs(item, subnet_ids, depth + 1)
|
|
233
|
+
elif isinstance(attrs, str):
|
|
234
|
+
# Look for aws_subnet.name references
|
|
235
|
+
for match in re.finditer(r"aws_subnet\.(\w+)", attrs):
|
|
236
|
+
subnet_name = match.group(1)
|
|
237
|
+
subnet_ids.add(f"aws_subnet.{subnet_name}")
|
|
238
|
+
|
|
239
|
+
def _get_resource_display_name(
|
|
240
|
+
self,
|
|
241
|
+
resource: TerraformResource,
|
|
242
|
+
resolver: Optional["VariableResolver"] = None,
|
|
243
|
+
) -> str:
|
|
244
|
+
"""Extract display name for a single resource.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
resource: TerraformResource to get display name for
|
|
248
|
+
resolver: Optional VariableResolver for resolving interpolations
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Human-readable display name for the resource
|
|
252
|
+
"""
|
|
253
|
+
# Try to get name from attributes or use resource_name
|
|
254
|
+
attr_name = resource.attributes.get("name", "")
|
|
255
|
+
fallback_name = resource.resource_name
|
|
256
|
+
|
|
257
|
+
display_name = fallback_name
|
|
258
|
+
|
|
259
|
+
# If attribute name contains unresolved variables, use resource_name
|
|
260
|
+
if isinstance(attr_name, str) and attr_name:
|
|
261
|
+
# Resolve any variable interpolations
|
|
262
|
+
if resolver:
|
|
263
|
+
resolved_name = resolver.resolve(attr_name)
|
|
264
|
+
# If still contains ${, fall back to resource name
|
|
265
|
+
if "${" not in resolved_name:
|
|
266
|
+
display_name = resolved_name
|
|
267
|
+
else:
|
|
268
|
+
# If it doesn't contain ${, use attr_name
|
|
269
|
+
if "${" not in attr_name:
|
|
270
|
+
display_name = attr_name
|
|
271
|
+
|
|
272
|
+
# Clean up underscore-based names to be more readable
|
|
273
|
+
display_name = display_name.replace("_", " ").title()
|
|
274
|
+
|
|
275
|
+
# Truncate long names
|
|
276
|
+
if len(display_name) > 20:
|
|
277
|
+
display_name = display_name[:17] + "..."
|
|
278
|
+
|
|
279
|
+
return display_name
|
|
280
|
+
|
|
281
|
+
def aggregate(
|
|
282
|
+
self,
|
|
283
|
+
parse_result: ParseResult,
|
|
284
|
+
terraform_dir: Optional[Union[str, Path]] = None,
|
|
285
|
+
state_result: Optional["TerraformStateResult"] = None,
|
|
286
|
+
) -> AggregatedResult:
|
|
287
|
+
"""Aggregate parsed resources into logical services.
|
|
288
|
+
|
|
289
|
+
Args:
|
|
290
|
+
parse_result: ParseResult containing Terraform resources
|
|
291
|
+
terraform_dir: Optional path to Terraform directory for variable resolution
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
AggregatedResult with logical services and optional VPC structure
|
|
295
|
+
"""
|
|
340
296
|
result = AggregatedResult()
|
|
341
297
|
|
|
298
|
+
# Initialize variable resolver if terraform_dir is provided
|
|
299
|
+
resolver = None
|
|
300
|
+
if terraform_dir is not None:
|
|
301
|
+
from .variable_resolver import VariableResolver
|
|
302
|
+
|
|
303
|
+
resolver = VariableResolver(terraform_dir)
|
|
304
|
+
|
|
342
305
|
# Group resources by aggregation rule
|
|
343
306
|
rule_resources: Dict[str, List[TerraformResource]] = {}
|
|
344
|
-
unmatched: List[TerraformResource] = []
|
|
345
307
|
|
|
346
308
|
for resource in parse_result.resources:
|
|
347
309
|
rule_name = self._type_to_rule.get(resource.resource_type)
|
|
348
310
|
if rule_name:
|
|
349
311
|
rule_resources.setdefault(rule_name, []).append(resource)
|
|
350
|
-
else:
|
|
351
|
-
unmatched.append(resource)
|
|
352
312
|
|
|
353
313
|
# Create logical services from grouped resources
|
|
354
314
|
for rule_name, resources in rule_resources.items():
|
|
355
315
|
rule = self._aggregation_rules[rule_name]
|
|
356
316
|
|
|
357
317
|
# Count primary resources
|
|
358
|
-
|
|
359
|
-
if
|
|
318
|
+
primary_resources = [r for r in resources if r.resource_type in rule["primary"]]
|
|
319
|
+
if not primary_resources:
|
|
360
320
|
continue # Skip if no primary resources
|
|
361
321
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
resources=resources,
|
|
367
|
-
count=primary_count,
|
|
368
|
-
is_vpc_resource=rule['is_vpc'],
|
|
369
|
-
)
|
|
322
|
+
# De-group ALL resources - create one LogicalService per primary resource
|
|
323
|
+
for resource in primary_resources:
|
|
324
|
+
# Extract subnet_ids for this specific resource
|
|
325
|
+
subnet_ids = self._extract_subnet_ids([resource], state_result)
|
|
370
326
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
327
|
+
# Get display name for this specific resource
|
|
328
|
+
display_name = self._get_resource_display_name(resource, resolver)
|
|
329
|
+
|
|
330
|
+
service = LogicalService(
|
|
331
|
+
service_type=rule_name,
|
|
332
|
+
name=display_name,
|
|
333
|
+
icon_resource_type=rule["icon"],
|
|
334
|
+
resources=[resource], # Single resource
|
|
335
|
+
count=1,
|
|
336
|
+
is_vpc_resource=rule["is_vpc"],
|
|
337
|
+
subnet_ids=subnet_ids,
|
|
338
|
+
resource_id=resource.full_id, # Unique ID for this resource
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
result.services.append(service)
|
|
342
|
+
if rule["is_vpc"]:
|
|
343
|
+
result.vpc_services.append(service)
|
|
344
|
+
else:
|
|
345
|
+
result.global_services.append(service)
|
|
376
346
|
|
|
377
347
|
# Create logical connections based on which services exist
|
|
378
|
-
|
|
348
|
+
# Build a mapping from service_type to list of services (supports de-grouped services)
|
|
349
|
+
services_by_type: Dict[str, List[LogicalService]] = {}
|
|
350
|
+
for s in result.services:
|
|
351
|
+
services_by_type.setdefault(s.service_type, []).append(s)
|
|
352
|
+
|
|
353
|
+
# Build index: resource_full_id -> service for mapping parsed relationships
|
|
354
|
+
resource_to_service: Dict[str, LogicalService] = {}
|
|
355
|
+
for service in result.services:
|
|
356
|
+
for resource in service.resources:
|
|
357
|
+
resource_to_service[resource.full_id] = service
|
|
358
|
+
|
|
359
|
+
# Create connections from parsed 'references' relationships (specific per-resource)
|
|
360
|
+
# Track which (source_type, target_type) pairs have parsed connections
|
|
361
|
+
parsed_type_pairs: Set[Tuple[str, str]] = set()
|
|
362
|
+
connected_pairs: Set[Tuple[str, str]] = set()
|
|
363
|
+
for rel in parse_result.relationships:
|
|
364
|
+
if rel.relationship_type != "references":
|
|
365
|
+
continue
|
|
366
|
+
src_svc = resource_to_service.get(rel.source_id)
|
|
367
|
+
tgt_svc = resource_to_service.get(rel.target_id)
|
|
368
|
+
if not src_svc or not tgt_svc:
|
|
369
|
+
continue
|
|
370
|
+
pair = (src_svc.id, tgt_svc.id)
|
|
371
|
+
if pair in connected_pairs:
|
|
372
|
+
continue
|
|
373
|
+
connected_pairs.add(pair)
|
|
374
|
+
parsed_type_pairs.add((src_svc.service_type, tgt_svc.service_type))
|
|
375
|
+
result.connections.append(
|
|
376
|
+
LogicalConnection(
|
|
377
|
+
source_id=src_svc.id,
|
|
378
|
+
target_id=tgt_svc.id,
|
|
379
|
+
label="",
|
|
380
|
+
connection_type="default",
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
# Create YAML logical connections, skipping type pairs that have parsed connections
|
|
379
385
|
for conn in self._logical_connections:
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
386
|
+
source_type = conn.get("source", "")
|
|
387
|
+
target_type = conn.get("target", "")
|
|
388
|
+
if source_type in services_by_type and target_type in services_by_type:
|
|
389
|
+
# Skip if parsed relationships already provide specific connections
|
|
390
|
+
if (source_type, target_type) in parsed_type_pairs:
|
|
391
|
+
continue
|
|
392
|
+
source_services = services_by_type[source_type]
|
|
393
|
+
target_services = services_by_type[target_type]
|
|
394
|
+
# Connect each source to each target of matching type
|
|
395
|
+
for source_service in source_services:
|
|
396
|
+
for target_service in target_services:
|
|
397
|
+
result.connections.append(
|
|
398
|
+
LogicalConnection(
|
|
399
|
+
source_id=source_service.id,
|
|
400
|
+
target_id=target_service.id,
|
|
401
|
+
label=conn.get("label", ""),
|
|
402
|
+
connection_type=conn.get("type", "default"),
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Build VPC structure if resolver is available
|
|
407
|
+
if resolver is not None:
|
|
408
|
+
vpc_builder = VPCStructureBuilder()
|
|
409
|
+
result.vpc_structure = vpc_builder.build(
|
|
410
|
+
parse_result.resources,
|
|
411
|
+
resolver=resolver,
|
|
412
|
+
state_result=state_result,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Create network flow connections (IGW <-> NAT Gateway)
|
|
416
|
+
self._create_network_flow_connections(result, services_by_type)
|
|
417
|
+
|
|
418
|
+
# Create security rule connections from SG cross-references
|
|
419
|
+
self._create_sg_connections(result, parse_result)
|
|
389
420
|
|
|
390
421
|
return result
|
|
391
422
|
|
|
423
|
+
@staticmethod
|
|
424
|
+
def _create_network_flow_connections(
|
|
425
|
+
result: AggregatedResult,
|
|
426
|
+
services_by_type: Dict[str, List["LogicalService"]],
|
|
427
|
+
) -> None:
|
|
428
|
+
"""Create network flow connections between IGW and NAT Gateway."""
|
|
429
|
+
igw_services = services_by_type.get("internet_gateway", [])
|
|
430
|
+
nat_services = services_by_type.get("nat_gateway", [])
|
|
431
|
+
|
|
432
|
+
if not igw_services or not nat_services:
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# Connect NAT Gateway -> IGW (arrow points at IGW, showing public route path)
|
|
436
|
+
for igw in igw_services:
|
|
437
|
+
for nat in nat_services:
|
|
438
|
+
result.connections.append(
|
|
439
|
+
LogicalConnection(
|
|
440
|
+
source_id=nat.id,
|
|
441
|
+
target_id=igw.id,
|
|
442
|
+
label="Public Route",
|
|
443
|
+
connection_type="network_flow",
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
@staticmethod
|
|
448
|
+
def _create_sg_connections(
|
|
449
|
+
result: AggregatedResult,
|
|
450
|
+
parse_result: "ParseResult",
|
|
451
|
+
) -> None:
|
|
452
|
+
"""Create security_rule connections between services based on SG cross-references.
|
|
453
|
+
|
|
454
|
+
Maps sg_allows_from relationships (SG-to-SG) to connections between the
|
|
455
|
+
services that USE those security groups.
|
|
456
|
+
"""
|
|
457
|
+
# Build index: resource_full_id -> service_id
|
|
458
|
+
resource_to_service: Dict[str, str] = {}
|
|
459
|
+
for service in result.services:
|
|
460
|
+
for resource in service.resources:
|
|
461
|
+
resource_to_service[resource.full_id] = service.id
|
|
462
|
+
|
|
463
|
+
# Build map: sg_resource_id -> [service_ids that reference it]
|
|
464
|
+
sg_to_services: Dict[str, List[str]] = {}
|
|
465
|
+
for rel in parse_result.relationships:
|
|
466
|
+
if rel.relationship_type == "uses_security_group":
|
|
467
|
+
svc_id = resource_to_service.get(rel.source_id)
|
|
468
|
+
if svc_id:
|
|
469
|
+
sg_to_services.setdefault(rel.target_id, []).append(svc_id)
|
|
470
|
+
|
|
471
|
+
# For each sg_allows_from relationship, create connections between services
|
|
472
|
+
seen: Set[Tuple[str, str]] = set()
|
|
473
|
+
for rel in parse_result.relationships:
|
|
474
|
+
if rel.relationship_type != "sg_allows_from":
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
source_sg = rel.source_id # SG that is allowed FROM
|
|
478
|
+
target_sg = rel.target_id # SG that allows traffic
|
|
479
|
+
|
|
480
|
+
source_services = sg_to_services.get(source_sg, [])
|
|
481
|
+
target_services = sg_to_services.get(target_sg, [])
|
|
482
|
+
|
|
483
|
+
# Connect services that use these SGs to each other
|
|
484
|
+
for src_svc_id in source_services:
|
|
485
|
+
for tgt_svc_id in target_services:
|
|
486
|
+
# Skip self-referencing (same service)
|
|
487
|
+
if src_svc_id == tgt_svc_id:
|
|
488
|
+
continue
|
|
489
|
+
# Deduplicate
|
|
490
|
+
key = (src_svc_id, tgt_svc_id)
|
|
491
|
+
if key in seen:
|
|
492
|
+
continue
|
|
493
|
+
seen.add(key)
|
|
494
|
+
|
|
495
|
+
result.connections.append(
|
|
496
|
+
LogicalConnection(
|
|
497
|
+
source_id=src_svc_id,
|
|
498
|
+
target_id=tgt_svc_id,
|
|
499
|
+
label=rel.label or "",
|
|
500
|
+
connection_type="security_rule",
|
|
501
|
+
)
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
# Also connect the SG nodes directly (if they are services)
|
|
505
|
+
source_sg_svc = resource_to_service.get(source_sg)
|
|
506
|
+
target_sg_svc = resource_to_service.get(target_sg)
|
|
507
|
+
if source_sg_svc and target_sg_svc and source_sg_svc != target_sg_svc:
|
|
508
|
+
key = (source_sg_svc, target_sg_svc)
|
|
509
|
+
if key not in seen:
|
|
510
|
+
seen.add(key)
|
|
511
|
+
result.connections.append(
|
|
512
|
+
LogicalConnection(
|
|
513
|
+
source_id=source_sg_svc,
|
|
514
|
+
target_id=target_sg_svc,
|
|
515
|
+
label=rel.label or "",
|
|
516
|
+
connection_type="security_rule",
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
@staticmethod
|
|
521
|
+
def get_aggregation_metadata(
|
|
522
|
+
result: AggregatedResult, threshold: int = 3
|
|
523
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
524
|
+
"""Return per-service-type metadata for client-side aggregation.
|
|
525
|
+
|
|
526
|
+
Returns a dict mapping service_type to:
|
|
527
|
+
- count: number of services of this type
|
|
528
|
+
- label: human-readable label
|
|
529
|
+
- icon_resource_type: Terraform type for icon lookup
|
|
530
|
+
- defaultAggregated: True if count >= threshold
|
|
531
|
+
- service_ids: list of service IDs in this group
|
|
532
|
+
- service_names: list of service display names in this group
|
|
533
|
+
"""
|
|
534
|
+
type_info: Dict[str, Dict[str, Any]] = {}
|
|
535
|
+
for service in result.services:
|
|
536
|
+
st = service.service_type
|
|
537
|
+
if st not in type_info:
|
|
538
|
+
type_info[st] = {
|
|
539
|
+
"count": 0,
|
|
540
|
+
"label": st.replace("_", " ").title(),
|
|
541
|
+
"icon_resource_type": service.icon_resource_type,
|
|
542
|
+
"service_ids": [],
|
|
543
|
+
"service_names": [],
|
|
544
|
+
}
|
|
545
|
+
type_info[st]["count"] += 1
|
|
546
|
+
type_info[st]["service_ids"].append(service.id)
|
|
547
|
+
type_info[st]["service_names"].append(service.name)
|
|
548
|
+
|
|
549
|
+
for st, info in type_info.items():
|
|
550
|
+
info["defaultAggregated"] = info["count"] >= threshold
|
|
551
|
+
|
|
552
|
+
return type_info
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class VPCStructureBuilder:
|
|
556
|
+
"""Builds VPC structure from Terraform resources."""
|
|
557
|
+
|
|
558
|
+
# Regex patterns for detecting AZ from resource names
|
|
559
|
+
AZ_PATTERNS: List[tuple] = [
|
|
560
|
+
# Pattern: name-a, name-b, name-c (single letter suffix)
|
|
561
|
+
(r"[-_]([a-f])$", lambda m: m.group(1)),
|
|
562
|
+
# Pattern: name-1a, name-1b, name-2a (number + letter suffix)
|
|
563
|
+
(r"[-_](\d[a-f])$", lambda m: m.group(1)),
|
|
564
|
+
# Pattern: name-az1, name-az2, name-az3 (az + number suffix)
|
|
565
|
+
(r"[-_]az(\d)$", lambda m: m.group(1)),
|
|
566
|
+
# Pattern: zone-a, zone-b in the middle of name
|
|
567
|
+
(r"[-_]([a-f])[-_]", lambda m: m.group(1)),
|
|
568
|
+
]
|
|
569
|
+
|
|
570
|
+
# Patterns for detecting subnet type from name/tags
|
|
571
|
+
SUBNET_TYPE_PATTERNS: Dict[str, List[str]] = {
|
|
572
|
+
"public": ["public", "pub", "external", "ext", "dmz", "bastion"],
|
|
573
|
+
"private": [
|
|
574
|
+
"private",
|
|
575
|
+
"priv",
|
|
576
|
+
"internal",
|
|
577
|
+
"int",
|
|
578
|
+
"app",
|
|
579
|
+
"compute",
|
|
580
|
+
"worker",
|
|
581
|
+
"backend",
|
|
582
|
+
"application",
|
|
583
|
+
],
|
|
584
|
+
"database": ["database", "db", "rds", "data", "storage", "persistence"],
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
def _detect_availability_zone(
|
|
588
|
+
self, resource: TerraformResource, sequential_index: Optional[int] = None
|
|
589
|
+
) -> Optional[str]:
|
|
590
|
+
"""Detect availability zone from resource attributes or name patterns.
|
|
591
|
+
|
|
592
|
+
Args:
|
|
593
|
+
resource: TerraformResource to analyze
|
|
594
|
+
sequential_index: Optional index for sequential AZ naming when patterns fail
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
Detected AZ name or None if not detectable
|
|
598
|
+
"""
|
|
599
|
+
# First check for explicit availability_zone attribute
|
|
600
|
+
az = resource.attributes.get("availability_zone")
|
|
601
|
+
if az and isinstance(az, str):
|
|
602
|
+
# Check if it's an unresolved variable (contains ${)
|
|
603
|
+
if "${" not in az:
|
|
604
|
+
return az
|
|
605
|
+
# Fall through to pattern detection if unresolved
|
|
606
|
+
|
|
607
|
+
# Try to detect from resource name
|
|
608
|
+
name = resource.attributes.get("name", resource.resource_name)
|
|
609
|
+
if not isinstance(name, str):
|
|
610
|
+
name = resource.resource_name
|
|
611
|
+
|
|
612
|
+
name_lower = name.lower()
|
|
613
|
+
|
|
614
|
+
for pattern, extractor in self.AZ_PATTERNS:
|
|
615
|
+
match = re.search(pattern, name_lower)
|
|
616
|
+
if match:
|
|
617
|
+
suffix = extractor(match)
|
|
618
|
+
# Return a placeholder AZ name with the detected suffix
|
|
619
|
+
return f"detected-{suffix}"
|
|
620
|
+
|
|
621
|
+
# If we have a sequential index (for count-based resources), use it
|
|
622
|
+
if sequential_index is not None:
|
|
623
|
+
az_letters = "abcdef"
|
|
624
|
+
if sequential_index < len(az_letters):
|
|
625
|
+
return f"detected-{az_letters[sequential_index]}"
|
|
626
|
+
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
def _detect_subnet_type(self, resource: TerraformResource) -> str:
|
|
630
|
+
"""Detect subnet type from name or tags.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
resource: TerraformResource to analyze
|
|
634
|
+
|
|
635
|
+
Returns:
|
|
636
|
+
Detected subnet type ('public', 'private', 'database', or 'unknown')
|
|
637
|
+
"""
|
|
638
|
+
# Check resource name and name attribute
|
|
639
|
+
names_to_check = [
|
|
640
|
+
resource.resource_name,
|
|
641
|
+
resource.attributes.get("name", ""),
|
|
642
|
+
]
|
|
643
|
+
|
|
644
|
+
# Check tags
|
|
645
|
+
tags = resource.attributes.get("tags", {})
|
|
646
|
+
if isinstance(tags, dict):
|
|
647
|
+
type_tag = tags.get("Type", tags.get("type", ""))
|
|
648
|
+
if type_tag:
|
|
649
|
+
type_tag_lower = type_tag.lower()
|
|
650
|
+
for subnet_type, patterns in self.SUBNET_TYPE_PATTERNS.items():
|
|
651
|
+
if type_tag_lower in patterns:
|
|
652
|
+
return subnet_type
|
|
653
|
+
|
|
654
|
+
# Check name patterns
|
|
655
|
+
for name in names_to_check:
|
|
656
|
+
if not isinstance(name, str):
|
|
657
|
+
continue
|
|
658
|
+
name_lower = name.lower()
|
|
659
|
+
for subnet_type, patterns in self.SUBNET_TYPE_PATTERNS.items():
|
|
660
|
+
for pattern in patterns:
|
|
661
|
+
if pattern in name_lower:
|
|
662
|
+
return subnet_type
|
|
663
|
+
|
|
664
|
+
return "unknown"
|
|
665
|
+
|
|
666
|
+
def _detect_endpoint_type(self, resource: TerraformResource) -> str:
|
|
667
|
+
"""Detect VPC endpoint type (gateway or interface).
|
|
668
|
+
|
|
669
|
+
Args:
|
|
670
|
+
resource: TerraformResource to analyze
|
|
671
|
+
|
|
672
|
+
Returns:
|
|
673
|
+
Endpoint type ('gateway' or 'interface')
|
|
674
|
+
"""
|
|
675
|
+
endpoint_type = resource.attributes.get("vpc_endpoint_type", "")
|
|
676
|
+
if isinstance(endpoint_type, str):
|
|
677
|
+
endpoint_type_lower = endpoint_type.lower()
|
|
678
|
+
if endpoint_type_lower == "gateway":
|
|
679
|
+
return "gateway"
|
|
680
|
+
return "interface"
|
|
681
|
+
|
|
682
|
+
def _detect_endpoint_service(self, resource: TerraformResource) -> str:
|
|
683
|
+
"""Extract service name from VPC endpoint.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
resource: TerraformResource to analyze
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
Service name (e.g., 's3', 'dynamodb', 'ecr.api')
|
|
690
|
+
"""
|
|
691
|
+
service_name = resource.attributes.get("service_name", "")
|
|
692
|
+
if not isinstance(service_name, str):
|
|
693
|
+
return "unknown"
|
|
694
|
+
|
|
695
|
+
# Service name format: com.amazonaws.<region>.<service>
|
|
696
|
+
# Example: com.amazonaws.us-east-1.s3
|
|
697
|
+
# But if region is a variable like ${var.aws_region}, we get:
|
|
698
|
+
# com.amazonaws.${var.aws_region}.s3
|
|
699
|
+
|
|
700
|
+
# Strategy: take the last part(s) after the last known prefix
|
|
701
|
+
# If there's a variable pattern, extract service from the end
|
|
702
|
+
if "${" in service_name:
|
|
703
|
+
# Find the service after the variable - typically the last segment
|
|
704
|
+
# e.g., "com.amazonaws.${var.aws_region}.s3" -> "s3"
|
|
705
|
+
parts = service_name.split(".")
|
|
706
|
+
# Get parts after any that contain "${"
|
|
707
|
+
service_parts = []
|
|
708
|
+
found_var = False
|
|
709
|
+
for part in parts:
|
|
710
|
+
if "${" in part or "}" in part:
|
|
711
|
+
found_var = True
|
|
712
|
+
service_parts = [] # Reset, service comes after
|
|
713
|
+
elif found_var:
|
|
714
|
+
service_parts.append(part)
|
|
715
|
+
if service_parts:
|
|
716
|
+
return ".".join(service_parts)
|
|
717
|
+
|
|
718
|
+
# Standard parsing: com.amazonaws.<region>.<service>
|
|
719
|
+
parts = service_name.split(".")
|
|
720
|
+
if len(parts) >= 4:
|
|
721
|
+
# Join everything after the region (handles services like ecr.api)
|
|
722
|
+
return ".".join(parts[3:])
|
|
723
|
+
|
|
724
|
+
return "unknown"
|
|
725
|
+
|
|
726
|
+
def _get_az_short_name(self, az_name: str) -> str:
|
|
727
|
+
"""Extract short name from AZ name.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
az_name: Full AZ name (e.g., 'us-east-1a' or 'detected-1a')
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
Short name (e.g., '1a', 'a')
|
|
734
|
+
"""
|
|
735
|
+
# Handle detected AZs
|
|
736
|
+
if az_name.startswith("detected-"):
|
|
737
|
+
return az_name.replace("detected-", "")
|
|
738
|
+
|
|
739
|
+
# Handle standard AWS AZ names like us-east-1a
|
|
740
|
+
match = re.search(r"(\d[a-z])$", az_name)
|
|
741
|
+
if match:
|
|
742
|
+
return match.group(1)
|
|
743
|
+
|
|
744
|
+
# Handle simple suffix like -a, -b
|
|
745
|
+
if len(az_name) >= 1 and az_name[-1].isalpha():
|
|
746
|
+
return az_name[-1]
|
|
747
|
+
|
|
748
|
+
return az_name
|
|
749
|
+
|
|
750
|
+
def _extract_az_suffix(self, resource_name: str) -> Optional[str]:
|
|
751
|
+
"""Extract AZ suffix from subnet resource name.
|
|
752
|
+
|
|
753
|
+
This extracts the numeric or letter suffix that indicates which AZ
|
|
754
|
+
a subnet belongs to, enabling realistic grouping where each AZ
|
|
755
|
+
contains all subnet types.
|
|
756
|
+
|
|
757
|
+
Args:
|
|
758
|
+
resource_name: The Terraform resource name (e.g., 'public_subnet_1')
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
AZ suffix (e.g., '1', 'a', '1a') or None if not detectable
|
|
762
|
+
|
|
763
|
+
Examples:
|
|
764
|
+
'public-subnet-1' -> '1'
|
|
765
|
+
'compute-subnet-a' -> 'a'
|
|
766
|
+
'database_subnet_1a' -> '1a'
|
|
767
|
+
'my-private-subnet' -> None
|
|
768
|
+
"""
|
|
769
|
+
name_lower = resource_name.lower()
|
|
770
|
+
|
|
771
|
+
# Pattern priority: more specific patterns first
|
|
772
|
+
patterns = [
|
|
773
|
+
r"[-_](\d[a-f])$", # ends with -1a, -1b, _2a
|
|
774
|
+
r"[-_](\d+)$", # ends with -1, -2, _3
|
|
775
|
+
r"[-_]([a-f])$", # ends with -a, -b, _c
|
|
776
|
+
]
|
|
777
|
+
|
|
778
|
+
for pattern in patterns:
|
|
779
|
+
match = re.search(pattern, name_lower)
|
|
780
|
+
if match:
|
|
781
|
+
return match.group(1)
|
|
782
|
+
|
|
783
|
+
return None
|
|
784
|
+
|
|
785
|
+
def _resolve_route_table_names(
|
|
786
|
+
self,
|
|
787
|
+
resources: List[TerraformResource],
|
|
788
|
+
availability_zones: List[AvailabilityZone],
|
|
789
|
+
) -> None:
|
|
790
|
+
"""Resolve route table names for subnets via route table associations."""
|
|
791
|
+
# Build route table name lookup: resource_id -> name
|
|
792
|
+
rt_names: Dict[str, str] = {}
|
|
793
|
+
for r in resources:
|
|
794
|
+
if r.resource_type == "aws_route_table":
|
|
795
|
+
name = r.attributes.get("name", r.resource_name)
|
|
796
|
+
rt_names[r.full_id] = name
|
|
797
|
+
|
|
798
|
+
# Build subnet -> route table mapping from associations
|
|
799
|
+
subnet_to_rt: Dict[str, str] = {}
|
|
800
|
+
for r in resources:
|
|
801
|
+
if r.resource_type == "aws_route_table_association":
|
|
802
|
+
attrs = r.attributes
|
|
803
|
+
# Find subnet reference
|
|
804
|
+
subnet_ref = self._extract_ref(attrs.get("subnet_id", ""))
|
|
805
|
+
rt_ref = self._extract_ref(attrs.get("route_table_id", ""))
|
|
806
|
+
if subnet_ref and rt_ref and rt_ref in rt_names:
|
|
807
|
+
subnet_to_rt[subnet_ref] = rt_names[rt_ref]
|
|
808
|
+
|
|
809
|
+
# Apply to subnet objects
|
|
810
|
+
for az in availability_zones:
|
|
811
|
+
for subnet in az.subnets:
|
|
812
|
+
if subnet.resource_id in subnet_to_rt:
|
|
813
|
+
subnet.route_table_name = subnet_to_rt[subnet.resource_id]
|
|
814
|
+
|
|
815
|
+
@staticmethod
|
|
816
|
+
def _extract_ref(value: Any) -> Optional[str]:
|
|
817
|
+
"""Extract a Terraform resource reference from an attribute value."""
|
|
818
|
+
if not isinstance(value, str):
|
|
819
|
+
return None
|
|
820
|
+
# Match pattern: aws_type.name.id or ${aws_type.name.id}
|
|
821
|
+
import re
|
|
822
|
+
|
|
823
|
+
match = re.search(r"(aws_\w+\.\w+)", value)
|
|
824
|
+
if match:
|
|
825
|
+
return match.group(1)
|
|
826
|
+
return None
|
|
827
|
+
|
|
828
|
+
def build(
|
|
829
|
+
self,
|
|
830
|
+
resources: List[TerraformResource],
|
|
831
|
+
resolver: Optional["VariableResolver"] = None,
|
|
832
|
+
state_result: Optional["TerraformStateResult"] = None,
|
|
833
|
+
) -> Optional[VPCStructure]:
|
|
834
|
+
"""Build VPCStructure from a list of Terraform resources.
|
|
835
|
+
|
|
836
|
+
Args:
|
|
837
|
+
resources: List of TerraformResource objects
|
|
838
|
+
resolver: Optional VariableResolver for resolving interpolations
|
|
839
|
+
state_result: Optional TerraformStateResult with actual state values
|
|
840
|
+
|
|
841
|
+
Returns:
|
|
842
|
+
VPCStructure or None if no VPC found
|
|
843
|
+
"""
|
|
844
|
+
if not resources:
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
# Build state lookup index if state is available
|
|
848
|
+
state_index: Dict[str, Dict[str, Any]] = {}
|
|
849
|
+
if state_result:
|
|
850
|
+
from .terraform_tools import map_state_to_resource_id
|
|
851
|
+
|
|
852
|
+
for state_res in state_result.resources:
|
|
853
|
+
resource_id = map_state_to_resource_id(state_res.address)
|
|
854
|
+
state_index[resource_id] = state_res.values
|
|
855
|
+
|
|
856
|
+
# Find VPC resource
|
|
857
|
+
vpc_resource = None
|
|
858
|
+
for r in resources:
|
|
859
|
+
if r.resource_type == "aws_vpc":
|
|
860
|
+
vpc_resource = r
|
|
861
|
+
break
|
|
862
|
+
|
|
863
|
+
if not vpc_resource:
|
|
864
|
+
return None
|
|
865
|
+
|
|
866
|
+
# Get VPC name
|
|
867
|
+
vpc_name = vpc_resource.attributes.get("name", vpc_resource.resource_name)
|
|
868
|
+
if resolver and isinstance(vpc_name, str):
|
|
869
|
+
vpc_name = resolver.resolve(vpc_name)
|
|
870
|
+
|
|
871
|
+
# Collect subnets and group by AZ for realistic representation
|
|
872
|
+
# In AWS, each AZ contains all subnet types (public, private, database)
|
|
873
|
+
subnet_resources = [r for r in resources if r.resource_type == "aws_subnet"]
|
|
874
|
+
|
|
875
|
+
# First pass: collect all subnets with their AZ info
|
|
876
|
+
all_subnets: List[Tuple[TerraformResource, Subnet, Optional[str]]] = []
|
|
877
|
+
explicit_azs: Set[str] = set()
|
|
878
|
+
|
|
879
|
+
for r in subnet_resources:
|
|
880
|
+
# Get subnet name
|
|
881
|
+
subnet_name = r.attributes.get("name", r.resource_name)
|
|
882
|
+
if resolver and isinstance(subnet_name, str):
|
|
883
|
+
subnet_name = resolver.resolve(subnet_name)
|
|
884
|
+
|
|
885
|
+
subnet_type = self._detect_subnet_type(r)
|
|
886
|
+
|
|
887
|
+
# Try to get explicit AZ - prefer state data if available
|
|
888
|
+
explicit_az = None
|
|
889
|
+
if r.full_id in state_index:
|
|
890
|
+
state_az = state_index[r.full_id].get("availability_zone")
|
|
891
|
+
if state_az and isinstance(state_az, str):
|
|
892
|
+
explicit_az = state_az
|
|
893
|
+
|
|
894
|
+
# Fallback to HCL-based detection
|
|
895
|
+
if not explicit_az:
|
|
896
|
+
explicit_az = self._detect_availability_zone(r)
|
|
897
|
+
|
|
898
|
+
# Try to extract suffix from resource name (e.g., "-a", "-1")
|
|
899
|
+
suffix = self._extract_az_suffix(r.resource_name)
|
|
900
|
+
|
|
901
|
+
# Determine AZ key for grouping
|
|
902
|
+
if explicit_az and not explicit_az.startswith("detected-"):
|
|
903
|
+
az_key = explicit_az
|
|
904
|
+
explicit_azs.add(explicit_az)
|
|
905
|
+
elif suffix:
|
|
906
|
+
az_key = f"detected-{suffix}"
|
|
907
|
+
else:
|
|
908
|
+
az_key = None # Will be assigned later
|
|
909
|
+
|
|
910
|
+
# Extract AWS subnet ID from state if available
|
|
911
|
+
aws_subnet_id = None
|
|
912
|
+
if r.full_id in state_index:
|
|
913
|
+
aws_subnet_id = state_index[r.full_id].get("id")
|
|
914
|
+
|
|
915
|
+
subnet = Subnet(
|
|
916
|
+
resource_id=r.full_id,
|
|
917
|
+
name=subnet_name,
|
|
918
|
+
subnet_type=subnet_type,
|
|
919
|
+
availability_zone=az_key or "unknown",
|
|
920
|
+
cidr_block=r.attributes.get("cidr_block"),
|
|
921
|
+
aws_id=aws_subnet_id,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
all_subnets.append((r, subnet, az_key))
|
|
925
|
+
|
|
926
|
+
# Determine the number of AZs
|
|
927
|
+
if explicit_azs:
|
|
928
|
+
# Use explicit AZs as the primary structure
|
|
929
|
+
az_names = sorted(explicit_azs)
|
|
930
|
+
else:
|
|
931
|
+
# Determine count from resource count or number of subnets
|
|
932
|
+
num_azs = 1
|
|
933
|
+
for r, _, _ in all_subnets:
|
|
934
|
+
if r.count and r.count > num_azs:
|
|
935
|
+
num_azs = r.count
|
|
936
|
+
|
|
937
|
+
# If no count, use number of distinct detected AZs or subnet count
|
|
938
|
+
if num_azs == 1:
|
|
939
|
+
detected_azs = set(
|
|
940
|
+
az_key
|
|
941
|
+
for _, _, az_key in all_subnets
|
|
942
|
+
if az_key and az_key.startswith("detected-")
|
|
943
|
+
)
|
|
944
|
+
if detected_azs:
|
|
945
|
+
num_azs = len(detected_azs)
|
|
946
|
+
else:
|
|
947
|
+
# Count subnets by type and use max
|
|
948
|
+
type_counts: Dict[str, int] = {}
|
|
949
|
+
for _, subnet, _ in all_subnets:
|
|
950
|
+
type_counts[subnet.subnet_type] = type_counts.get(subnet.subnet_type, 0) + 1
|
|
951
|
+
if type_counts:
|
|
952
|
+
num_azs = max(type_counts.values())
|
|
953
|
+
|
|
954
|
+
az_letters = "abcdef"
|
|
955
|
+
az_names = [f"detected-{az_letters[i % len(az_letters)]}" for i in range(num_azs)]
|
|
956
|
+
|
|
957
|
+
# Create AZ objects
|
|
958
|
+
az_map: Dict[str, AvailabilityZone] = {}
|
|
959
|
+
availability_zones = []
|
|
960
|
+
for az_name in az_names:
|
|
961
|
+
az = AvailabilityZone(
|
|
962
|
+
name=az_name,
|
|
963
|
+
short_name=self._get_az_short_name(az_name),
|
|
964
|
+
subnets=[],
|
|
965
|
+
)
|
|
966
|
+
az_map[az_name] = az
|
|
967
|
+
availability_zones.append(az)
|
|
968
|
+
|
|
969
|
+
# Distribute subnets to AZs
|
|
970
|
+
type_order = {"public": 0, "private": 1, "database": 2, "unknown": 3}
|
|
971
|
+
unassigned: List[Subnet] = []
|
|
972
|
+
|
|
973
|
+
for r, subnet, az_key in sorted(
|
|
974
|
+
all_subnets, key=lambda x: (type_order.get(x[1].subnet_type, 3), x[1].name)
|
|
975
|
+
):
|
|
976
|
+
if az_key and az_key in az_map:
|
|
977
|
+
az_map[az_key].subnets.append(subnet)
|
|
978
|
+
elif az_key and az_key.startswith("detected-"):
|
|
979
|
+
# Try to match by suffix
|
|
980
|
+
suffix = az_key.replace("detected-", "")
|
|
981
|
+
matched = False
|
|
982
|
+
for az in availability_zones:
|
|
983
|
+
if az.short_name == suffix or suffix in az.short_name:
|
|
984
|
+
az.subnets.append(subnet)
|
|
985
|
+
matched = True
|
|
986
|
+
break
|
|
987
|
+
if not matched:
|
|
988
|
+
unassigned.append(subnet)
|
|
989
|
+
else:
|
|
990
|
+
unassigned.append(subnet)
|
|
991
|
+
|
|
992
|
+
# Distribute unassigned subnets round-robin by type
|
|
993
|
+
if unassigned and availability_zones:
|
|
994
|
+
# Group unassigned by type
|
|
995
|
+
unassigned_by_type: Dict[str, List[Subnet]] = {}
|
|
996
|
+
for subnet in unassigned:
|
|
997
|
+
unassigned_by_type.setdefault(subnet.subnet_type, []).append(subnet)
|
|
998
|
+
|
|
999
|
+
# Distribute each type across AZs
|
|
1000
|
+
az_letters = "abcdef"
|
|
1001
|
+
for subnet_type in sorted(
|
|
1002
|
+
unassigned_by_type.keys(), key=lambda t: type_order.get(t, 3)
|
|
1003
|
+
):
|
|
1004
|
+
for idx, subnet in enumerate(unassigned_by_type[subnet_type]):
|
|
1005
|
+
az_idx = idx % len(availability_zones)
|
|
1006
|
+
# Add AZ indicator to name if distributing multiple of same type
|
|
1007
|
+
if len(unassigned_by_type[subnet_type]) > 1:
|
|
1008
|
+
subnet.name = f"{subnet.name} ({az_letters[az_idx]})"
|
|
1009
|
+
availability_zones[az_idx].subnets.append(subnet)
|
|
1010
|
+
|
|
1011
|
+
# Collect VPC endpoints
|
|
1012
|
+
endpoints = []
|
|
1013
|
+
for r in resources:
|
|
1014
|
+
if r.resource_type != "aws_vpc_endpoint":
|
|
1015
|
+
continue
|
|
1016
|
+
|
|
1017
|
+
endpoint_name = r.attributes.get("name", r.resource_name)
|
|
1018
|
+
if resolver and isinstance(endpoint_name, str):
|
|
1019
|
+
endpoint_name = resolver.resolve(endpoint_name)
|
|
1020
|
+
|
|
1021
|
+
endpoint = VPCEndpoint(
|
|
1022
|
+
resource_id=r.full_id,
|
|
1023
|
+
name=endpoint_name,
|
|
1024
|
+
endpoint_type=self._detect_endpoint_type(r),
|
|
1025
|
+
service=self._detect_endpoint_service(r),
|
|
1026
|
+
)
|
|
1027
|
+
endpoints.append(endpoint)
|
|
1028
|
+
|
|
1029
|
+
# Resolve route table associations for subnets
|
|
1030
|
+
self._resolve_route_table_names(resources, availability_zones)
|
|
392
1031
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
1032
|
+
return VPCStructure(
|
|
1033
|
+
vpc_id=vpc_resource.full_id,
|
|
1034
|
+
name=vpc_name,
|
|
1035
|
+
availability_zones=availability_zones,
|
|
1036
|
+
endpoints=endpoints,
|
|
1037
|
+
)
|