terraformgraph 1.0.2__py3-none-any.whl → 1.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
terraformgraph/main.py CHANGED
@@ -5,79 +5,231 @@ terraformgraph - Terraform Diagram Generator
5
5
  Generates AWS infrastructure diagrams from Terraform code using official AWS icons.
6
6
  Creates high-level architectural diagrams with logical service groupings.
7
7
 
8
+ PREREQUISITES:
9
+ Before using terraformgraph, run these commands in your Terraform directory:
10
+
11
+ cd ./infrastructure
12
+ terraform init
13
+ terraform apply # or terraform plan for undeployed infrastructure
14
+
8
15
  Usage:
9
- # Parse a directory directly (generates diagram.html by default)
16
+ # Generate diagram (auto-generates state JSON if needed)
10
17
  terraformgraph -t ./infrastructure
11
18
 
12
- # Parse a specific environment subdirectory
13
- terraformgraph -t ./infrastructure -e dev
19
+ # With pre-generated state file
20
+ terraformgraph -t ./infrastructure --state-file state.json
21
+
22
+ # With specific environment subdirectory
23
+ terraformgraph -t ./infrastructure -e prod
14
24
 
15
25
  # With custom output path
16
26
  terraformgraph -t ./infrastructure -o my-diagram.html
17
-
18
- # With custom icons path
19
- terraformgraph -t ./infrastructure -i /path/to/icons
20
27
  """
21
28
 
22
29
  import argparse
30
+ import json
31
+ import subprocess
23
32
  import sys
24
33
  from pathlib import Path
34
+ from typing import Optional
25
35
 
26
- from .aggregator import aggregate_resources
36
+ from .aggregator import ResourceAggregator
27
37
  from .icons import IconMapper
28
38
  from .layout import LayoutConfig, LayoutEngine
29
39
  from .parser import TerraformParser
30
40
  from .renderer import HTMLRenderer, SVGRenderer
41
+ from .terraform_tools import TerraformToolsRunner
42
+
43
+ # File name for auto-generated state cache
44
+ STATE_CACHE_FILE = ".terraformgraph-state.json"
45
+
46
+
47
+ def _generate_state_json(terraform_dir: Path, verbose: bool = False) -> Path:
48
+ """Generate state JSON from terraform show -json.
49
+
50
+ Args:
51
+ terraform_dir: Path to terraform directory
52
+ verbose: Print progress messages
53
+
54
+ Returns:
55
+ Path to the generated JSON file
56
+
57
+ Raises:
58
+ RuntimeError: If terraform is not available or not initialized
59
+ """
60
+ runner = TerraformToolsRunner(terraform_dir)
61
+
62
+ if not runner.check_terraform_available():
63
+ raise RuntimeError(
64
+ "Terraform CLI not found in PATH.\n"
65
+ "Please install Terraform: https://developer.hashicorp.com/terraform/install"
66
+ )
67
+
68
+ if not runner.check_initialized():
69
+ raise RuntimeError(
70
+ f"Terraform not initialized in {terraform_dir}.\n"
71
+ f"Please run: cd {terraform_dir} && terraform init"
72
+ )
73
+
74
+ if verbose:
75
+ print("Generating state JSON from terraform show -json...")
76
+
77
+ try:
78
+ result = subprocess.run(
79
+ ["terraform", "show", "-json"],
80
+ cwd=terraform_dir,
81
+ capture_output=True,
82
+ text=True,
83
+ timeout=120,
84
+ )
85
+
86
+ if result.returncode != 0:
87
+ raise RuntimeError(f"terraform show -json failed: {result.stderr}")
88
+
89
+ if not result.stdout.strip():
90
+ raise RuntimeError(
91
+ "No terraform state found.\n"
92
+ f"Please run: cd {terraform_dir} && terraform apply (or terraform plan)"
93
+ )
94
+
95
+ # Validate JSON
96
+ try:
97
+ json_data = json.loads(result.stdout)
98
+ except json.JSONDecodeError as e:
99
+ raise RuntimeError(f"Invalid JSON from terraform show: {e}")
100
+
101
+ # Save to cache file
102
+ cache_file = terraform_dir / STATE_CACHE_FILE
103
+ with open(cache_file, "w", encoding="utf-8") as f:
104
+ json.dump(json_data, f, indent=2)
105
+
106
+ if verbose:
107
+ print(f"State cached to {cache_file}")
108
+
109
+ return cache_file
110
+
111
+ except subprocess.TimeoutExpired:
112
+ raise RuntimeError("terraform show -json timed out after 120 seconds")
113
+ except OSError as e:
114
+ raise RuntimeError(f"Error running terraform: {e}")
115
+
116
+
117
+ def _get_state_file(
118
+ terraform_dir: Path, state_file_arg: Optional[str], verbose: bool = False
119
+ ) -> Path:
120
+ """Determine which state file to use.
121
+
122
+ Priority:
123
+ 1. User-specified --state-file argument
124
+ 2. Existing .terraformgraph-state.json cache
125
+ 3. Existing plan.json or state.json in terraform dir
126
+ 4. Auto-generate from terraform show -json
127
+
128
+ Args:
129
+ terraform_dir: Path to terraform directory
130
+ state_file_arg: User-provided state file path (or None)
131
+ verbose: Print progress messages
132
+
133
+ Returns:
134
+ Path to the state file to use
135
+
136
+ Raises:
137
+ RuntimeError: If state cannot be obtained
138
+ """
139
+ # 1. User-specified file
140
+ if state_file_arg:
141
+ state_path = Path(state_file_arg)
142
+ if not state_path.exists():
143
+ raise RuntimeError(f"State file not found: {state_path}")
144
+ if verbose:
145
+ print(f"Using specified state file: {state_path}")
146
+ return state_path
147
+
148
+ # 2. Check for cached state file
149
+ cache_file = terraform_dir / STATE_CACHE_FILE
150
+ if cache_file.exists():
151
+ if verbose:
152
+ print(f"Using cached state: {cache_file}")
153
+ return cache_file
154
+
155
+ # 3. Check for existing JSON files in terraform dir
156
+ for filename in ["plan.json", "state.json", "terraform.tfstate.json"]:
157
+ json_file = terraform_dir / filename
158
+ if json_file.exists():
159
+ if verbose:
160
+ print(f"Using existing state file: {json_file}")
161
+ return json_file
162
+
163
+ # 4. Auto-generate from terraform
164
+ return _generate_state_json(terraform_dir, verbose)
31
165
 
32
166
 
33
167
  def main():
34
168
  parser = argparse.ArgumentParser(
35
- description='Generate AWS infrastructure diagrams from Terraform code.',
169
+ description="Generate AWS infrastructure diagrams from Terraform code.",
36
170
  formatter_class=argparse.RawDescriptionHelpFormatter,
37
- epilog='''
171
+ epilog="""
172
+ Prerequisites:
173
+ Before running terraformgraph, ensure your Terraform is initialized:
174
+
175
+ cd ./infrastructure
176
+ terraform init
177
+ terraform apply # or terraform plan
178
+
38
179
  Examples:
39
- # Parse a directory (generates diagram.html by default)
180
+ # Generate diagram (auto-generates state if needed)
40
181
  terraformgraph -t ./infrastructure
41
182
 
42
- # Parse a specific environment subdirectory
43
- terraformgraph -t ./infrastructure -e dev
183
+ # With pre-generated state file
184
+ terraform show -json > state.json
185
+ terraformgraph -t ./infrastructure --state-file state.json
44
186
 
45
- # With custom output path
46
- terraformgraph -t ./infrastructure -o my-diagram.html
187
+ # With specific environment subdirectory
188
+ terraformgraph -t ./infrastructure -e prod
47
189
 
48
190
  # With custom icons path
49
- terraformgraph -t ./infrastructure -i /path/to/icons
50
- '''
191
+ terraformgraph -t ./infrastructure -i /path/to/aws-icons
192
+ """,
51
193
  )
52
194
 
53
195
  parser.add_argument(
54
- '-t', '--terraform',
55
- required=True,
56
- help='Path to the Terraform infrastructure directory'
196
+ "-t", "--terraform", required=True, help="Path to the Terraform infrastructure directory"
57
197
  )
58
198
 
59
199
  parser.add_argument(
60
- '-e', '--environment',
61
- help='Environment name (dev, staging, prod). If not provided, parses the terraform directory directly.',
62
- default=None
200
+ "-e",
201
+ "--environment",
202
+ help="Environment name (dev, staging, prod). If not provided, parses the terraform directory directly.",
203
+ default=None,
63
204
  )
64
205
 
65
206
  parser.add_argument(
66
- '-i', '--icons',
67
- help='Path to AWS icons directory (auto-discovers in ./aws-official-icons, ~/aws-official-icons, ~/.terraformgraph/icons)',
68
- default=None
207
+ "-i",
208
+ "--icons",
209
+ help="Path to AWS icons directory (auto-discovers in ./aws-official-icons, ~/aws-official-icons, ~/.terraformgraph/icons)",
210
+ default=None,
69
211
  )
70
212
 
71
213
  parser.add_argument(
72
- '-o', '--output',
73
- default='diagram.html',
74
- help='Output file path (HTML). Default: diagram.html'
214
+ "-o",
215
+ "--output",
216
+ default="terraformgraph.html",
217
+ help="Output file path (HTML). Default: terraformgraph.html",
218
+ )
219
+
220
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable verbose output")
221
+
222
+ parser.add_argument(
223
+ "--state-file",
224
+ "-s",
225
+ metavar="FILE",
226
+ help="Path to terraform state JSON file (from 'terraform show -json'). If not provided, auto-generates from terraform CLI.",
75
227
  )
76
228
 
77
229
  parser.add_argument(
78
- '-v', '--verbose',
79
- action='store_true',
80
- help='Enable verbose output'
230
+ "--refresh-state",
231
+ action="store_true",
232
+ help="Force regeneration of state JSON even if cached file exists.",
81
233
  )
82
234
 
83
235
  args = parser.parse_args()
@@ -108,7 +260,9 @@ Examples:
108
260
  break
109
261
 
110
262
  if icons_path and not icons_path.exists():
111
- print(f"Warning: Icons path not found: {icons_path}. Using fallback colors.", file=sys.stderr)
263
+ print(
264
+ f"Warning: Icons path not found: {icons_path}. Using fallback colors.", file=sys.stderr
265
+ )
112
266
  icons_path = None
113
267
  elif icons_path and args.verbose:
114
268
  print(f"Using icons from: {icons_path}")
@@ -120,7 +274,11 @@ Examples:
120
274
  title = f"{args.environment.upper()} Environment"
121
275
  if not parse_path.exists():
122
276
  print(f"Error: Environment not found: {parse_path}", file=sys.stderr)
123
- available = [d.name for d in terraform_path.iterdir() if d.is_dir() and not d.name.startswith('.')]
277
+ available = [
278
+ d.name
279
+ for d in terraform_path.iterdir()
280
+ if d.is_dir() and not d.name.startswith(".")
281
+ ]
124
282
  print(f"Available environments: {available}", file=sys.stderr)
125
283
  sys.exit(1)
126
284
  else:
@@ -129,11 +287,26 @@ Examples:
129
287
  title = terraform_path.name
130
288
 
131
289
  try:
290
+ # Delete cached state if --refresh-state is specified
291
+ if args.refresh_state:
292
+ cache_file = parse_path / STATE_CACHE_FILE
293
+ if cache_file.exists():
294
+ cache_file.unlink()
295
+ if args.verbose:
296
+ print(f"Removed cached state: {cache_file}")
297
+
298
+ # Get state file (auto-generates if needed)
299
+ state_file = _get_state_file(parse_path, args.state_file, args.verbose)
300
+
132
301
  # Parse Terraform files
133
302
  if args.verbose:
134
303
  print(f"Parsing Terraform files from {parse_path}...")
135
304
 
136
- tf_parser = TerraformParser(str(terraform_path), str(icons_path) if icons_path else None)
305
+ tf_parser = TerraformParser(
306
+ str(terraform_path),
307
+ use_terraform_state=True,
308
+ state_file=str(state_file),
309
+ )
137
310
 
138
311
  if args.environment:
139
312
  parse_result = tf_parser.parse_environment(args.environment)
@@ -143,44 +316,70 @@ Examples:
143
316
  if args.verbose:
144
317
  print(f"Found {len(parse_result.resources)} raw resources")
145
318
  print(f"Found {len(parse_result.modules)} module calls")
319
+ if tf_parser.get_state_result():
320
+ state = tf_parser.get_state_result()
321
+ print(f"Enhanced with terraform state: {len(state.resources)} resources")
146
322
 
147
323
  # Aggregate into logical services
148
324
  if args.verbose:
149
325
  print("Aggregating into logical services...")
150
326
 
151
- aggregated = aggregate_resources(parse_result)
327
+ aggregator = ResourceAggregator()
328
+ aggregated = aggregator.aggregate(
329
+ parse_result,
330
+ terraform_dir=parse_path,
331
+ state_result=tf_parser.get_state_result(),
332
+ )
152
333
 
153
334
  if args.verbose:
154
335
  print(f"Created {len(aggregated.services)} logical services:")
155
336
  for service in aggregated.services:
156
- print(f" - {service.name}: {len(service.resources)} resources (count: {service.count})")
337
+ print(
338
+ f" - {service.name}: {len(service.resources)} resources (count: {service.count})"
339
+ )
157
340
  print(f"Created {len(aggregated.connections)} logical connections")
158
-
159
- # Setup layout
160
- config = LayoutConfig()
161
- layout_engine = LayoutEngine(config)
162
- positions, groups = layout_engine.compute_layout(aggregated)
341
+ if aggregated.vpc_structure:
342
+ vpc = aggregated.vpc_structure
343
+ print(f"VPC Structure: {vpc.name}")
344
+ print(f" - {len(vpc.availability_zones)} Availability Zones")
345
+ for az in vpc.availability_zones:
346
+ print(f" - {az.name}: {len(az.subnets)} subnets")
347
+ print(f" - {len(vpc.endpoints)} VPC Endpoints")
348
+
349
+ # Setup layout (config is now responsive and scaled based on content)
350
+ base_config = LayoutConfig()
351
+ layout_engine = LayoutEngine(base_config)
352
+ positions, groups, actual_height = layout_engine.compute_layout(aggregated)
353
+
354
+ # Get the scaled config from the layout engine
355
+ responsive_config = layout_engine.config
163
356
 
164
357
  if args.verbose:
165
358
  print(f"Positioned {len(positions)} services")
359
+ print(
360
+ f"Canvas: {responsive_config.canvas_width}x{actual_height} (scale: {layout_engine._compute_responsive_scale(aggregated):.2f})"
361
+ )
166
362
 
167
- # Setup renderers
363
+ # Setup renderers with the responsive config
168
364
  icon_mapper = IconMapper(str(icons_path) if icons_path else None)
169
- svg_renderer = SVGRenderer(icon_mapper, config)
365
+ svg_renderer = SVGRenderer(icon_mapper, responsive_config)
170
366
  html_renderer = HTMLRenderer(svg_renderer)
171
367
 
172
- # Generate HTML
368
+ # Generate HTML with actual calculated height
173
369
  if args.verbose:
174
370
  print("Generating HTML output...")
175
371
 
176
372
  html_content = html_renderer.render_html(
177
- aggregated, positions, groups,
178
- environment=args.environment or title
373
+ aggregated,
374
+ positions,
375
+ groups,
376
+ environment=args.environment or title,
377
+ actual_height=actual_height,
179
378
  )
180
379
 
181
380
  # Write output
182
381
  output_path = Path(args.output)
183
- output_path.write_text(html_content, encoding='utf-8')
382
+ output_path.write_text(html_content, encoding="utf-8")
184
383
 
185
384
  print(f"Diagram generated: {output_path.absolute()}")
186
385
  print("\nSummary:")
@@ -188,13 +387,17 @@ Examples:
188
387
  print(f" Resources: {sum(len(s.resources) for s in aggregated.services)}")
189
388
  print(f" Connections: {len(aggregated.connections)}")
190
389
 
390
+ except RuntimeError as e:
391
+ print(f"Error: {e}", file=sys.stderr)
392
+ sys.exit(1)
191
393
  except Exception as e:
192
394
  print(f"Error: {e}", file=sys.stderr)
193
395
  if args.verbose:
194
396
  import traceback
397
+
195
398
  traceback.print_exc()
196
399
  sys.exit(1)
197
400
 
198
401
 
199
- if __name__ == '__main__':
402
+ if __name__ == "__main__":
200
403
  main()