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.
@@ -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 typing import Any, Dict, List, Optional
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
- attributes: Dict[str, str] = field(default_factory=dict)
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 = 'default' # 'default', 'data_flow', 'trigger', 'encrypt'
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
- 'primary': config.get("primary", []),
322
- 'aggregate': config.get("secondary", []), # secondary in YAML -> aggregate internally
323
- 'icon': config.get("primary", [""])[0] if config.get("primary") else "",
324
- 'display_name': service_name.replace("_", " ").title(),
325
- 'is_vpc': config.get("in_vpc", False),
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['primary']:
140
+ for res_type in rule["primary"]:
334
141
  self._type_to_rule[res_type] = rule_name
335
- for res_type in rule['aggregate']:
142
+ for res_type in rule["aggregate"]:
336
143
  self._type_to_rule[res_type] = rule_name
337
144
 
338
- def aggregate(self, parse_result: ParseResult) -> AggregatedResult:
339
- """Aggregate parsed resources into logical services."""
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
- primary_count = sum(1 for r in resources if r.resource_type in rule['primary'])
359
- if primary_count == 0:
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
- service = LogicalService(
363
- service_type=rule_name,
364
- name=rule['display_name'],
365
- icon_resource_type=rule['icon'],
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
- result.services.append(service)
372
- if service.is_vpc_resource:
373
- result.vpc_services.append(service)
374
- else:
375
- result.global_services.append(service)
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
- existing_services = {s.service_type for s in result.services}
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
- source = conn.get("source", "")
381
- target = conn.get("target", "")
382
- if source in existing_services and target in existing_services:
383
- result.connections.append(LogicalConnection(
384
- source_id=f"{source}.{self._aggregation_rules[source]['display_name']}",
385
- target_id=f"{target}.{self._aggregation_rules[target]['display_name']}",
386
- label=conn.get("label", ""),
387
- connection_type=conn.get("type", "default"),
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
- def aggregate_resources(parse_result: ParseResult) -> AggregatedResult:
394
- """Convenience function to aggregate resources."""
395
- aggregator = ResourceAggregator()
396
- return aggregator.aggregate(parse_result)
1032
+ return VPCStructure(
1033
+ vpc_id=vpc_resource.full_id,
1034
+ name=vpc_name,
1035
+ availability_zones=availability_zones,
1036
+ endpoints=endpoints,
1037
+ )