terraformgraph 1.0.1__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.
@@ -0,0 +1,239 @@
1
+ """
2
+ Layout Engine
3
+
4
+ Computes positions for logical services in the diagram.
5
+ """
6
+
7
+ from dataclasses import dataclass, field
8
+ from typing import Dict, List, Optional, Tuple
9
+
10
+ from .aggregator import AggregatedResult, LogicalService
11
+
12
+
13
+ @dataclass
14
+ class Position:
15
+ """Position and size of an element."""
16
+ x: float
17
+ y: float
18
+ width: float
19
+ height: float
20
+
21
+
22
+ @dataclass
23
+ class ServiceGroup:
24
+ """A visual group of services."""
25
+ group_type: str # 'aws_cloud', 'vpc', 'global'
26
+ name: str
27
+ services: List[LogicalService] = field(default_factory=list)
28
+ position: Optional[Position] = None
29
+
30
+
31
+ @dataclass
32
+ class LayoutConfig:
33
+ """Configuration for layout engine."""
34
+ canvas_width: int = 1400
35
+ canvas_height: int = 900
36
+ padding: int = 30
37
+ icon_size: int = 64
38
+ icon_spacing: int = 40
39
+ group_padding: int = 25
40
+ label_height: int = 24
41
+ row_spacing: int = 100
42
+ column_spacing: int = 130
43
+
44
+
45
+ class LayoutEngine:
46
+ """Computes positions for diagram elements."""
47
+
48
+ def __init__(self, config: Optional[LayoutConfig] = None):
49
+ self.config = config or LayoutConfig()
50
+
51
+ def compute_layout(
52
+ self,
53
+ aggregated: AggregatedResult
54
+ ) -> Tuple[Dict[str, Position], List[ServiceGroup]]:
55
+ """
56
+ Compute positions for all logical services.
57
+
58
+ Layout structure:
59
+ - Top row: Internet-facing services (CloudFront, WAF, Route53, ACM)
60
+ - Middle: VPC box with ALB, ECS, EC2
61
+ - Bottom rows: Global services grouped by function
62
+ """
63
+ positions: Dict[str, Position] = {}
64
+ groups: List[ServiceGroup] = []
65
+
66
+ # Create AWS Cloud container
67
+ aws_cloud = ServiceGroup(
68
+ group_type='aws_cloud',
69
+ name='AWS Cloud',
70
+ position=Position(
71
+ x=self.config.padding,
72
+ y=self.config.padding,
73
+ width=self.config.canvas_width - 2 * self.config.padding,
74
+ height=self.config.canvas_height - 2 * self.config.padding
75
+ )
76
+ )
77
+ groups.append(aws_cloud)
78
+
79
+ # Categorize services for layout
80
+ edge_services = [] # CloudFront, WAF, Route53, ACM, Cognito
81
+ vpc_services = [] # ALB, ECS, EC2, Security
82
+ data_services = [] # S3, DynamoDB, MongoDB
83
+ messaging_services = [] # SQS, SNS, EventBridge
84
+ security_services = [] # KMS, Secrets, IAM
85
+ other_services = [] # CloudWatch, Bedrock, ECR, etc.
86
+
87
+ for service in aggregated.services:
88
+ st = service.service_type
89
+ if st in ('cloudfront', 'waf', 'route53', 'acm', 'cognito'):
90
+ edge_services.append(service)
91
+ elif st in ('alb', 'ecs', 'ec2', 'security', 'vpc'):
92
+ vpc_services.append(service)
93
+ elif st in ('s3', 'dynamodb', 'mongodb'):
94
+ data_services.append(service)
95
+ elif st in ('sqs', 'sns', 'eventbridge'):
96
+ messaging_services.append(service)
97
+ elif st in ('kms', 'secrets', 'iam'):
98
+ security_services.append(service)
99
+ else:
100
+ other_services.append(service)
101
+
102
+ y_offset = self.config.padding + 40
103
+
104
+ # Row 1: Edge services (top)
105
+ if edge_services:
106
+ x = self._center_row_start(len(edge_services))
107
+ for service in edge_services:
108
+ positions[service.id] = Position(
109
+ x=x, y=y_offset,
110
+ width=self.config.icon_size,
111
+ height=self.config.icon_size
112
+ )
113
+ x += self.config.column_spacing
114
+
115
+ y_offset += self.config.row_spacing + 20
116
+
117
+ # Row 2: VPC box with internal services
118
+ vpc_x = self.config.padding + 50
119
+ vpc_width = self.config.canvas_width - 2 * (self.config.padding + 50)
120
+ vpc_height = 180
121
+
122
+ # Filter out 'vpc' itself from vpc_services for positioning
123
+ vpc_internal = [s for s in vpc_services if s.service_type != 'vpc']
124
+
125
+ vpc_group = ServiceGroup(
126
+ group_type='vpc',
127
+ name='VPC',
128
+ services=vpc_internal,
129
+ position=Position(x=vpc_x, y=y_offset, width=vpc_width, height=vpc_height)
130
+ )
131
+ groups.append(vpc_group)
132
+
133
+ # Position services inside VPC
134
+ inner_y = y_offset + self.config.group_padding + 30
135
+ if vpc_internal:
136
+ x = self._center_row_start(len(vpc_internal), vpc_x + self.config.group_padding,
137
+ vpc_x + vpc_width - self.config.group_padding)
138
+ for service in vpc_internal:
139
+ positions[service.id] = Position(
140
+ x=x, y=inner_y,
141
+ width=self.config.icon_size,
142
+ height=self.config.icon_size
143
+ )
144
+ x += self.config.column_spacing
145
+
146
+ y_offset += vpc_height + 40
147
+
148
+ # Row 3: Data services
149
+ if data_services:
150
+ x = self._center_row_start(len(data_services))
151
+ for service in data_services:
152
+ positions[service.id] = Position(
153
+ x=x, y=y_offset,
154
+ width=self.config.icon_size,
155
+ height=self.config.icon_size
156
+ )
157
+ x += self.config.column_spacing
158
+
159
+ y_offset += self.config.row_spacing
160
+
161
+ # Row 4: Messaging services
162
+ if messaging_services:
163
+ x = self._center_row_start(len(messaging_services))
164
+ for service in messaging_services:
165
+ positions[service.id] = Position(
166
+ x=x, y=y_offset,
167
+ width=self.config.icon_size,
168
+ height=self.config.icon_size
169
+ )
170
+ x += self.config.column_spacing
171
+
172
+ y_offset += self.config.row_spacing
173
+
174
+ # Row 5: Security + Other services
175
+ bottom_services = security_services + other_services
176
+ if bottom_services:
177
+ x = self._center_row_start(len(bottom_services))
178
+ for service in bottom_services:
179
+ positions[service.id] = Position(
180
+ x=x, y=y_offset,
181
+ width=self.config.icon_size,
182
+ height=self.config.icon_size
183
+ )
184
+ x += self.config.column_spacing
185
+
186
+ return positions, groups
187
+
188
+ def _center_row_start(
189
+ self,
190
+ num_items: int,
191
+ min_x: Optional[float] = None,
192
+ max_x: Optional[float] = None
193
+ ) -> float:
194
+ """Calculate starting X position to center items in a row."""
195
+ if min_x is None:
196
+ min_x = self.config.padding
197
+ if max_x is None:
198
+ max_x = self.config.canvas_width - self.config.padding
199
+
200
+ available_width = max_x - min_x
201
+ total_items_width = num_items * self.config.icon_size + (num_items - 1) * self.config.icon_spacing
202
+ return min_x + (available_width - total_items_width) / 2
203
+
204
+ def compute_connection_path(
205
+ self,
206
+ source_pos: Position,
207
+ target_pos: Position,
208
+ connection_type: str = 'default'
209
+ ) -> str:
210
+ """Compute SVG path for a connection between two services."""
211
+ # Calculate center points
212
+ sx = source_pos.x + source_pos.width / 2
213
+ sy = source_pos.y + source_pos.height / 2
214
+ tx = target_pos.x + target_pos.width / 2
215
+ ty = target_pos.y + target_pos.height / 2
216
+
217
+ # Use straight lines with slight curves for cleaner look
218
+ if abs(ty - sy) > abs(tx - sx):
219
+ # Mostly vertical - connect top/bottom
220
+ if ty > sy:
221
+ sy = source_pos.y + source_pos.height
222
+ ty = target_pos.y
223
+ else:
224
+ sy = source_pos.y
225
+ ty = target_pos.y + target_pos.height
226
+ else:
227
+ # Mostly horizontal - connect left/right
228
+ if tx > sx:
229
+ sx = source_pos.x + source_pos.width
230
+ tx = target_pos.x
231
+ else:
232
+ sx = source_pos.x
233
+ tx = target_pos.x + target_pos.width
234
+
235
+ # Simple curved path
236
+ mid_x = (sx + tx) / 2
237
+ mid_y = (sy + ty) / 2
238
+
239
+ return f"M {sx} {sy} Q {mid_x} {sy}, {mid_x} {mid_y} Q {mid_x} {ty}, {tx} {ty}"
terraformgraph/main.py ADDED
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ terraformgraph - Terraform Diagram Generator
4
+
5
+ Generates AWS infrastructure diagrams from Terraform code using official AWS icons.
6
+ Creates high-level architectural diagrams with logical service groupings.
7
+
8
+ Usage:
9
+ # Parse a directory directly (auto-discovers icons in ./aws-official-icons)
10
+ terraformgraph -t ./infrastructure -o diagram.html
11
+
12
+ # Parse a specific environment subdirectory
13
+ terraformgraph -t ./infrastructure -e dev -o diagram.html
14
+
15
+ # With custom icons path
16
+ terraformgraph -t ./infrastructure -i /path/to/icons -o diagram.html
17
+ """
18
+
19
+ import argparse
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from .aggregator import aggregate_resources
24
+ from .icons import IconMapper
25
+ from .layout import LayoutConfig, LayoutEngine
26
+ from .parser import TerraformParser
27
+ from .renderer import HTMLRenderer, SVGRenderer
28
+
29
+
30
+ def main():
31
+ parser = argparse.ArgumentParser(
32
+ description='Generate AWS infrastructure diagrams from Terraform code.',
33
+ formatter_class=argparse.RawDescriptionHelpFormatter,
34
+ epilog='''
35
+ Examples:
36
+ # Parse a directory (auto-discovers icons in ./aws-official-icons)
37
+ terraformgraph -t ./infrastructure -o diagram.html
38
+
39
+ # Parse a specific environment subdirectory
40
+ terraformgraph -t ./infrastructure -e dev -o diagram.html
41
+
42
+ # With custom icons path
43
+ terraformgraph -t ./infrastructure -i /path/to/icons -o diagram.html
44
+ '''
45
+ )
46
+
47
+ parser.add_argument(
48
+ '-t', '--terraform',
49
+ required=True,
50
+ help='Path to the Terraform infrastructure directory'
51
+ )
52
+
53
+ parser.add_argument(
54
+ '-e', '--environment',
55
+ help='Environment name (dev, staging, prod). If not provided, parses the terraform directory directly.',
56
+ default=None
57
+ )
58
+
59
+ parser.add_argument(
60
+ '-i', '--icons',
61
+ help='Path to AWS icons directory (auto-discovers in ./aws-official-icons, ~/aws-official-icons, ~/.terraformgraph/icons)',
62
+ default=None
63
+ )
64
+
65
+ parser.add_argument(
66
+ '-o', '--output',
67
+ required=True,
68
+ help='Output file path (HTML)'
69
+ )
70
+
71
+ parser.add_argument(
72
+ '-v', '--verbose',
73
+ action='store_true',
74
+ help='Enable verbose output'
75
+ )
76
+
77
+ args = parser.parse_args()
78
+
79
+ # Validate paths
80
+ terraform_path = Path(args.terraform)
81
+ if not terraform_path.exists():
82
+ print(f"Error: Terraform path not found: {terraform_path}", file=sys.stderr)
83
+ sys.exit(1)
84
+
85
+ # Auto-discover icons path
86
+ icons_path = None
87
+ if args.icons:
88
+ icons_path = Path(args.icons)
89
+ else:
90
+ # Try common locations for AWS icons
91
+ search_paths = [
92
+ Path.cwd() / "aws-official-icons",
93
+ Path.cwd() / "aws-icons",
94
+ Path.cwd() / "AWS_Icons",
95
+ Path(__file__).parent.parent / "aws-official-icons",
96
+ Path.home() / "aws-official-icons",
97
+ Path.home() / ".terraformgraph" / "icons",
98
+ ]
99
+ for search_path in search_paths:
100
+ if search_path.exists() and any(search_path.glob("Architecture-Service-Icons_*")):
101
+ icons_path = search_path
102
+ break
103
+
104
+ if icons_path and not icons_path.exists():
105
+ print(f"Warning: Icons path not found: {icons_path}. Using fallback colors.", file=sys.stderr)
106
+ icons_path = None
107
+ elif icons_path and args.verbose:
108
+ print(f"Using icons from: {icons_path}")
109
+
110
+ # Determine parsing mode
111
+ if args.environment:
112
+ # Environment mode: terraform_path/environment/
113
+ parse_path = terraform_path / args.environment
114
+ title = f"{args.environment.upper()} Environment"
115
+ if not parse_path.exists():
116
+ print(f"Error: Environment not found: {parse_path}", file=sys.stderr)
117
+ available = [d.name for d in terraform_path.iterdir() if d.is_dir() and not d.name.startswith('.')]
118
+ print(f"Available environments: {available}", file=sys.stderr)
119
+ sys.exit(1)
120
+ else:
121
+ # Direct mode: terraform_path is the folder to parse
122
+ parse_path = terraform_path
123
+ title = terraform_path.name
124
+
125
+ try:
126
+ # Parse Terraform files
127
+ if args.verbose:
128
+ print(f"Parsing Terraform files from {parse_path}...")
129
+
130
+ tf_parser = TerraformParser(str(terraform_path), str(icons_path) if icons_path else None)
131
+
132
+ if args.environment:
133
+ parse_result = tf_parser.parse_environment(args.environment)
134
+ else:
135
+ parse_result = tf_parser.parse_directory(parse_path)
136
+
137
+ if args.verbose:
138
+ print(f"Found {len(parse_result.resources)} raw resources")
139
+ print(f"Found {len(parse_result.modules)} module calls")
140
+
141
+ # Aggregate into logical services
142
+ if args.verbose:
143
+ print("Aggregating into logical services...")
144
+
145
+ aggregated = aggregate_resources(parse_result)
146
+
147
+ if args.verbose:
148
+ print(f"Created {len(aggregated.services)} logical services:")
149
+ for service in aggregated.services:
150
+ print(f" - {service.name}: {len(service.resources)} resources (count: {service.count})")
151
+ print(f"Created {len(aggregated.connections)} logical connections")
152
+
153
+ # Setup layout
154
+ config = LayoutConfig()
155
+ layout_engine = LayoutEngine(config)
156
+ positions, groups = layout_engine.compute_layout(aggregated)
157
+
158
+ if args.verbose:
159
+ print(f"Positioned {len(positions)} services")
160
+
161
+ # Setup renderers
162
+ icon_mapper = IconMapper(str(icons_path) if icons_path else None)
163
+ svg_renderer = SVGRenderer(icon_mapper, config)
164
+ html_renderer = HTMLRenderer(svg_renderer)
165
+
166
+ # Generate HTML
167
+ if args.verbose:
168
+ print("Generating HTML output...")
169
+
170
+ html_content = html_renderer.render_html(
171
+ aggregated, positions, groups,
172
+ environment=args.environment or title
173
+ )
174
+
175
+ # Write output
176
+ output_path = Path(args.output)
177
+ output_path.write_text(html_content, encoding='utf-8')
178
+
179
+ print(f"Diagram generated: {output_path.absolute()}")
180
+ print("\nSummary:")
181
+ print(f" Services: {len(aggregated.services)}")
182
+ print(f" Resources: {sum(len(s.resources) for s in aggregated.services)}")
183
+ print(f" Connections: {len(aggregated.connections)}")
184
+
185
+ except Exception as e:
186
+ print(f"Error: {e}", file=sys.stderr)
187
+ if args.verbose:
188
+ import traceback
189
+ traceback.print_exc()
190
+ sys.exit(1)
191
+
192
+
193
+ if __name__ == '__main__':
194
+ main()