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.
- terraformgraph/__init__.py +19 -0
- terraformgraph/__main__.py +6 -0
- terraformgraph/aggregator.py +396 -0
- terraformgraph/config/aggregation_rules.yaml +132 -0
- terraformgraph/config/logical_connections.yaml +183 -0
- terraformgraph/config_loader.py +55 -0
- terraformgraph/icons.py +795 -0
- terraformgraph/layout.py +239 -0
- terraformgraph/main.py +194 -0
- terraformgraph/parser.py +341 -0
- terraformgraph/renderer.py +1134 -0
- terraformgraph-1.0.1.dist-info/METADATA +161 -0
- terraformgraph-1.0.1.dist-info/RECORD +17 -0
- terraformgraph-1.0.1.dist-info/WHEEL +5 -0
- terraformgraph-1.0.1.dist-info/entry_points.txt +2 -0
- terraformgraph-1.0.1.dist-info/licenses/LICENSE +21 -0
- terraformgraph-1.0.1.dist-info/top_level.txt +1 -0
terraformgraph/layout.py
ADDED
|
@@ -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()
|