terraformgraph 1.0.2__tar.gz → 1.0.3__tar.gz

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.
Files changed (25) hide show
  1. {terraformgraph-1.0.2/terraformgraph.egg-info → terraformgraph-1.0.3}/PKG-INFO +2 -2
  2. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/pyproject.toml +2 -2
  3. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/__init__.py +1 -1
  4. terraformgraph-1.0.3/terraformgraph/aggregator.py +140 -0
  5. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/layout.py +2 -2
  6. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/parser.py +6 -3
  7. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/renderer.py +23 -3
  8. {terraformgraph-1.0.2 → terraformgraph-1.0.3/terraformgraph.egg-info}/PKG-INFO +2 -2
  9. terraformgraph-1.0.2/terraformgraph/aggregator.py +0 -396
  10. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/LICENSE +0 -0
  11. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/README.md +0 -0
  12. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/setup.cfg +0 -0
  13. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/__main__.py +0 -0
  14. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/config/aggregation_rules.yaml +0 -0
  15. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/config/logical_connections.yaml +0 -0
  16. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/config_loader.py +0 -0
  17. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/icons.py +0 -0
  18. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph/main.py +0 -0
  19. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph.egg-info/SOURCES.txt +0 -0
  20. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph.egg-info/dependency_links.txt +0 -0
  21. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph.egg-info/entry_points.txt +0 -0
  22. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph.egg-info/requires.txt +0 -0
  23. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/terraformgraph.egg-info/top_level.txt +0 -0
  24. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/tests/test_config_loader.py +0 -0
  25. {terraformgraph-1.0.2 → terraformgraph-1.0.3}/tests/test_integration.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: terraformgraph
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Generate interactive architecture diagrams from Terraform configurations
5
- Author-email: Your Name <your.email@example.com>
5
+ Author-email: Ferdinando Bonsegna <1bonsegnaferdinando@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/ferdinandobons/terraformgraph
8
8
  Project-URL: Documentation, https://github.com/ferdinandobons/terraformgraph#readme
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "terraformgraph"
7
- version = "1.0.2"
7
+ version = "1.0.3"
8
8
  description = "Generate interactive architecture diagrams from Terraform configurations"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
11
11
  requires-python = ">=3.9"
12
12
  authors = [
13
- {name = "Your Name", email = "your.email@example.com"}
13
+ {name = "Ferdinando Bonsegna", email = "1bonsegnaferdinando@gmail.com"}
14
14
  ]
15
15
  keywords = [
16
16
  "terraform",
@@ -1,6 +1,6 @@
1
1
  """terraformgraph - Create architecture diagrams from Terraform configurations."""
2
2
 
3
- __version__ = "1.0.2"
3
+ __version__ = "1.0.3"
4
4
 
5
5
  from .aggregator import ResourceAggregator
6
6
  from .config_loader import ConfigLoader
@@ -0,0 +1,140 @@
1
+ """
2
+ Resource Aggregator
3
+
4
+ Aggregates low-level Terraform resources into high-level logical services
5
+ for cleaner architecture diagrams.
6
+ """
7
+
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Dict, List, Optional
10
+
11
+ from .config_loader import ConfigLoader
12
+ from .parser import ParseResult, TerraformResource
13
+
14
+
15
+ @dataclass
16
+ class LogicalService:
17
+ """A high-level logical service aggregating multiple resources."""
18
+ service_type: str # e.g., 'alb', 'ecs', 's3', 'sqs'
19
+ name: str
20
+ icon_resource_type: str # The Terraform type to use for the icon
21
+ resources: List[TerraformResource] = field(default_factory=list)
22
+ count: int = 1 # How many instances (e.g., 24 SQS queues)
23
+ is_vpc_resource: bool = False
24
+ attributes: Dict[str, str] = field(default_factory=dict)
25
+
26
+ @property
27
+ def id(self) -> str:
28
+ return f"{self.service_type}.{self.name}"
29
+
30
+
31
+ @dataclass
32
+ class LogicalConnection:
33
+ """A connection between logical services."""
34
+ source_id: str
35
+ target_id: str
36
+ label: Optional[str] = None
37
+ connection_type: str = 'default' # 'default', 'data_flow', 'trigger', 'encrypt'
38
+
39
+
40
+ @dataclass
41
+ class AggregatedResult:
42
+ """Result of aggregating resources into logical services."""
43
+ services: List[LogicalService] = field(default_factory=list)
44
+ connections: List[LogicalConnection] = field(default_factory=list)
45
+ vpc_services: List[LogicalService] = field(default_factory=list)
46
+ global_services: List[LogicalService] = field(default_factory=list)
47
+
48
+
49
+ class ResourceAggregator:
50
+ """Aggregates Terraform resources into logical services."""
51
+
52
+ def __init__(self, config_loader: Optional[ConfigLoader] = None):
53
+ self._config = config_loader or ConfigLoader()
54
+ self._aggregation_rules = self._build_aggregation_rules()
55
+ self._logical_connections = self._config.get_logical_connections()
56
+ self._build_type_to_rule_map()
57
+
58
+ def _build_aggregation_rules(self) -> Dict[str, Dict[str, Any]]:
59
+ """Build aggregation rules dict from config."""
60
+ flat_rules = self._config.get_flat_aggregation_rules()
61
+ result = {}
62
+ for service_name, config in flat_rules.items():
63
+ # Map YAML format (primary/secondary/in_vpc) to internal format
64
+ result[service_name] = {
65
+ 'primary': config.get("primary", []),
66
+ 'aggregate': config.get("secondary", []), # secondary in YAML -> aggregate internally
67
+ 'icon': config.get("primary", [""])[0] if config.get("primary") else "",
68
+ 'display_name': service_name.replace("_", " ").title(),
69
+ 'is_vpc': config.get("in_vpc", False),
70
+ }
71
+ return result
72
+
73
+ def _build_type_to_rule_map(self) -> None:
74
+ """Build a mapping from resource type to aggregation rule."""
75
+ self._type_to_rule: Dict[str, str] = {}
76
+ for rule_name, rule in self._aggregation_rules.items():
77
+ for res_type in rule['primary']:
78
+ self._type_to_rule[res_type] = rule_name
79
+ for res_type in rule['aggregate']:
80
+ self._type_to_rule[res_type] = rule_name
81
+
82
+ def aggregate(self, parse_result: ParseResult) -> AggregatedResult:
83
+ """Aggregate parsed resources into logical services."""
84
+ result = AggregatedResult()
85
+
86
+ # Group resources by aggregation rule
87
+ rule_resources: Dict[str, List[TerraformResource]] = {}
88
+ unmatched: List[TerraformResource] = []
89
+
90
+ for resource in parse_result.resources:
91
+ rule_name = self._type_to_rule.get(resource.resource_type)
92
+ if rule_name:
93
+ rule_resources.setdefault(rule_name, []).append(resource)
94
+ else:
95
+ unmatched.append(resource)
96
+
97
+ # Create logical services from grouped resources
98
+ for rule_name, resources in rule_resources.items():
99
+ rule = self._aggregation_rules[rule_name]
100
+
101
+ # Count primary resources
102
+ primary_count = sum(1 for r in resources if r.resource_type in rule['primary'])
103
+ if primary_count == 0:
104
+ continue # Skip if no primary resources
105
+
106
+ service = LogicalService(
107
+ service_type=rule_name,
108
+ name=rule['display_name'],
109
+ icon_resource_type=rule['icon'],
110
+ resources=resources,
111
+ count=primary_count,
112
+ is_vpc_resource=rule['is_vpc'],
113
+ )
114
+
115
+ result.services.append(service)
116
+ if service.is_vpc_resource:
117
+ result.vpc_services.append(service)
118
+ else:
119
+ result.global_services.append(service)
120
+
121
+ # Create logical connections based on which services exist
122
+ existing_services = {s.service_type for s in result.services}
123
+ for conn in self._logical_connections:
124
+ source = conn.get("source", "")
125
+ target = conn.get("target", "")
126
+ if source in existing_services and target in existing_services:
127
+ result.connections.append(LogicalConnection(
128
+ source_id=f"{source}.{self._aggregation_rules[source]['display_name']}",
129
+ target_id=f"{target}.{self._aggregation_rules[target]['display_name']}",
130
+ label=conn.get("label", ""),
131
+ connection_type=conn.get("type", "default"),
132
+ ))
133
+
134
+ return result
135
+
136
+
137
+ def aggregate_resources(parse_result: ParseResult) -> AggregatedResult:
138
+ """Convenience function to aggregate resources."""
139
+ aggregator = ResourceAggregator()
140
+ return aggregator.aggregate(parse_result)
@@ -88,13 +88,13 @@ class LayoutEngine:
88
88
  st = service.service_type
89
89
  if st in ('cloudfront', 'waf', 'route53', 'acm', 'cognito'):
90
90
  edge_services.append(service)
91
- elif st in ('alb', 'ecs', 'ec2', 'security', 'vpc'):
91
+ elif st in ('alb', 'ecs', 'ec2', 'security_groups', 'security', 'vpc'):
92
92
  vpc_services.append(service)
93
93
  elif st in ('s3', 'dynamodb', 'mongodb'):
94
94
  data_services.append(service)
95
95
  elif st in ('sqs', 'sns', 'eventbridge'):
96
96
  messaging_services.append(service)
97
- elif st in ('kms', 'secrets', 'iam'):
97
+ elif st in ('kms', 'secrets', 'secrets_manager', 'iam'):
98
98
  security_services.append(service)
99
99
  else:
100
100
  other_services.append(service)
@@ -4,6 +4,7 @@ Terraform HCL Parser
4
4
  Parses Terraform files and extracts AWS resources and their relationships.
5
5
  """
6
6
 
7
+ import logging
7
8
  import re
8
9
  from dataclasses import dataclass, field
9
10
  from pathlib import Path
@@ -11,6 +12,8 @@ from typing import Any, Dict, List, Optional
11
12
 
12
13
  import hcl2
13
14
 
15
+ logger = logging.getLogger(__name__)
16
+
14
17
 
15
18
  @dataclass
16
19
  class TerraformResource:
@@ -130,7 +133,7 @@ class TerraformParser:
130
133
  # Parse all .tf files in directory
131
134
  tf_files = list(directory.glob("*.tf"))
132
135
  if not tf_files:
133
- print(f"Warning: No .tf files found in {directory}")
136
+ logger.warning("No .tf files found in %s", directory)
134
137
 
135
138
  for tf_file in tf_files:
136
139
  self._parse_file(tf_file, result, module_path="")
@@ -153,7 +156,7 @@ class TerraformParser:
153
156
  with open(file_path, 'r') as f:
154
157
  content = hcl2.load(f)
155
158
  except Exception as e:
156
- print(f"Warning: Could not parse {file_path}: {e}")
159
+ logger.warning("Could not parse %s: %s", file_path, e)
157
160
  return
158
161
 
159
162
  # Extract resources
@@ -199,7 +202,7 @@ class TerraformParser:
199
202
  module_path = self.infrastructure_path / '.modules' / source
200
203
 
201
204
  if not module_path.exists():
202
- print(f"Warning: Module path not found: {module_path}")
205
+ logger.warning("Module path not found: %s", module_path)
203
206
  return ParseResult()
204
207
 
205
208
  # Check cache
@@ -250,7 +250,7 @@ class SVGRenderer:
250
250
  data-target="{html.escape(connection.target_id)}"
251
251
  data-conn-type="{connection.connection_type}"
252
252
  data-label="{html.escape(label)}">
253
- <path class="connection-hitarea" d="{path}"/>
253
+ <path class="connection-hitarea" d="{path}" fill="none" stroke="transparent" stroke-width="15"/>
254
254
  <path class="connection-path" d="{path}" fill="none" stroke="{stroke_color}"
255
255
  stroke-width="1.5" {dash_attr} marker-end="{marker}" opacity="0.7"/>
256
256
  </g>
@@ -676,6 +676,11 @@ class HTMLRenderer:
676
676
 
677
677
  function startDrag(e) {{
678
678
  e.preventDefault();
679
+
680
+ // Guard against null CTM (can happen during rendering)
681
+ const ctm = svg.getScreenCTM();
682
+ if (!ctm) return;
683
+
679
684
  dragging = e.currentTarget;
680
685
  dragging.classList.add('dragging');
681
686
  dragging.style.cursor = 'grabbing';
@@ -683,7 +688,15 @@ class HTMLRenderer:
683
688
  const pt = svg.createSVGPoint();
684
689
  pt.x = e.clientX;
685
690
  pt.y = e.clientY;
686
- const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
691
+ const svgP = pt.matrixTransform(ctm.inverse());
692
+
693
+ // Validate coordinates to prevent NaN issues
694
+ if (isNaN(svgP.x) || isNaN(svgP.y)) {{
695
+ dragging.classList.remove('dragging');
696
+ dragging.style.cursor = 'grab';
697
+ dragging = null;
698
+ return;
699
+ }}
687
700
 
688
701
  const id = dragging.dataset.serviceId;
689
702
  const pos = servicePositions[id] || {{ x: 0, y: 0 }};
@@ -697,10 +710,17 @@ class HTMLRenderer:
697
710
  function drag(e) {{
698
711
  if (!dragging) return;
699
712
 
713
+ // Guard against null CTM
714
+ const ctm = svg.getScreenCTM();
715
+ if (!ctm) return;
716
+
700
717
  const pt = svg.createSVGPoint();
701
718
  pt.x = e.clientX;
702
719
  pt.y = e.clientY;
703
- const svgP = pt.matrixTransform(svg.getScreenCTM().inverse());
720
+ const svgP = pt.matrixTransform(ctm.inverse());
721
+
722
+ // Validate coordinates to prevent NaN issues
723
+ if (isNaN(svgP.x) || isNaN(svgP.y)) return;
704
724
 
705
725
  let newX = svgP.x - offset.x;
706
726
  let newY = svgP.y - offset.y;
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: terraformgraph
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Generate interactive architecture diagrams from Terraform configurations
5
- Author-email: Your Name <your.email@example.com>
5
+ Author-email: Ferdinando Bonsegna <1bonsegnaferdinando@gmail.com>
6
6
  License: MIT
7
7
  Project-URL: Homepage, https://github.com/ferdinandobons/terraformgraph
8
8
  Project-URL: Documentation, https://github.com/ferdinandobons/terraformgraph#readme
@@ -1,396 +0,0 @@
1
- """
2
- Resource Aggregator
3
-
4
- Aggregates low-level Terraform resources into high-level logical services
5
- for cleaner architecture diagrams.
6
- """
7
-
8
- from dataclasses import dataclass, field
9
- from typing import Any, Dict, List, Optional
10
-
11
- from .config_loader import ConfigLoader
12
- from .parser import ParseResult, TerraformResource
13
-
14
-
15
- @dataclass
16
- class LogicalService:
17
- """A high-level logical service aggregating multiple resources."""
18
- service_type: str # e.g., 'alb', 'ecs', 's3', 'sqs'
19
- name: str
20
- icon_resource_type: str # The Terraform type to use for the icon
21
- resources: List[TerraformResource] = field(default_factory=list)
22
- count: int = 1 # How many instances (e.g., 24 SQS queues)
23
- is_vpc_resource: bool = False
24
- attributes: Dict[str, str] = field(default_factory=dict)
25
-
26
- @property
27
- def id(self) -> str:
28
- return f"{self.service_type}.{self.name}"
29
-
30
-
31
- @dataclass
32
- class LogicalConnection:
33
- """A connection between logical services."""
34
- source_id: str
35
- target_id: str
36
- label: Optional[str] = None
37
- connection_type: str = 'default' # 'default', 'data_flow', 'trigger', 'encrypt'
38
-
39
-
40
- @dataclass
41
- class AggregatedResult:
42
- """Result of aggregating resources into logical services."""
43
- services: List[LogicalService] = field(default_factory=list)
44
- connections: List[LogicalConnection] = field(default_factory=list)
45
- vpc_services: List[LogicalService] = field(default_factory=list)
46
- 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
- ]
303
-
304
-
305
- class ResourceAggregator:
306
- """Aggregates Terraform resources into logical services."""
307
-
308
- def __init__(self, config_loader: Optional[ConfigLoader] = None):
309
- self._config = config_loader or ConfigLoader()
310
- self._aggregation_rules = self._build_aggregation_rules()
311
- self._logical_connections = self._config.get_logical_connections()
312
- self._build_type_to_rule_map()
313
-
314
- def _build_aggregation_rules(self) -> Dict[str, Dict[str, Any]]:
315
- """Build aggregation rules dict from config."""
316
- flat_rules = self._config.get_flat_aggregation_rules()
317
- result = {}
318
- for service_name, config in flat_rules.items():
319
- # Map YAML format (primary/secondary/in_vpc) to internal format
320
- 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),
326
- }
327
- return result
328
-
329
- def _build_type_to_rule_map(self) -> None:
330
- """Build a mapping from resource type to aggregation rule."""
331
- self._type_to_rule: Dict[str, str] = {}
332
- for rule_name, rule in self._aggregation_rules.items():
333
- for res_type in rule['primary']:
334
- self._type_to_rule[res_type] = rule_name
335
- for res_type in rule['aggregate']:
336
- self._type_to_rule[res_type] = rule_name
337
-
338
- def aggregate(self, parse_result: ParseResult) -> AggregatedResult:
339
- """Aggregate parsed resources into logical services."""
340
- result = AggregatedResult()
341
-
342
- # Group resources by aggregation rule
343
- rule_resources: Dict[str, List[TerraformResource]] = {}
344
- unmatched: List[TerraformResource] = []
345
-
346
- for resource in parse_result.resources:
347
- rule_name = self._type_to_rule.get(resource.resource_type)
348
- if rule_name:
349
- rule_resources.setdefault(rule_name, []).append(resource)
350
- else:
351
- unmatched.append(resource)
352
-
353
- # Create logical services from grouped resources
354
- for rule_name, resources in rule_resources.items():
355
- rule = self._aggregation_rules[rule_name]
356
-
357
- # Count primary resources
358
- primary_count = sum(1 for r in resources if r.resource_type in rule['primary'])
359
- if primary_count == 0:
360
- continue # Skip if no primary resources
361
-
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
- )
370
-
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)
376
-
377
- # Create logical connections based on which services exist
378
- existing_services = {s.service_type for s in result.services}
379
- 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
- ))
389
-
390
- return result
391
-
392
-
393
- def aggregate_resources(parse_result: ParseResult) -> AggregatedResult:
394
- """Convenience function to aggregate resources."""
395
- aggregator = ResourceAggregator()
396
- return aggregator.aggregate(parse_result)
File without changes
File without changes
File without changes