terraformgraph 1.0.3__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.
@@ -0,0 +1,355 @@
1
+ """
2
+ Terraform CLI Tools Integration
3
+
4
+ Provides functionality to run terraform commands and parse their output
5
+ to enhance the visualization with accurate state information.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class TerraformStateResource:
22
+ """A resource from terraform state/plan JSON output."""
23
+
24
+ address: str # e.g., "aws_subnet.public[0]" or "aws_subnet.public[\"key\"]"
25
+ resource_type: str
26
+ name: str
27
+ index: Optional[Any] # Can be int (count) or str (for_each)
28
+ values: Dict[str, Any]
29
+ module_path: str = ""
30
+
31
+ @property
32
+ def base_address(self) -> str:
33
+ """Address without index, e.g., 'aws_subnet.public' from 'aws_subnet.public[0]' or 'aws_subnet.public[\"key\"]'."""
34
+ # Remove numeric index [0] or string index ["key"]
35
+ address = re.sub(r"\[\d+\]$", "", self.address)
36
+ address = re.sub(r'\["[^"]+"\]$', "", address)
37
+ return address
38
+
39
+ @property
40
+ def full_id(self) -> str:
41
+ """Full ID matching parser's format."""
42
+ if self.module_path:
43
+ return f"{self.module_path}.{self.resource_type}.{self.name}"
44
+ return f"{self.resource_type}.{self.name}"
45
+
46
+
47
+ @dataclass
48
+ class TerraformStateResult:
49
+ """Result from parsing terraform show/plan JSON output."""
50
+
51
+ resources: List[TerraformStateResource] = field(default_factory=list)
52
+
53
+
54
+ class TerraformToolsRunner:
55
+ """Executes terraform commands and parses their output."""
56
+
57
+ TIMEOUT_SHOW = 120 # seconds
58
+
59
+ def __init__(self, terraform_dir: Path, terraform_bin: str = "terraform"):
60
+ self.terraform_dir = Path(terraform_dir)
61
+ self.terraform_bin = terraform_bin
62
+
63
+ def check_terraform_available(self) -> bool:
64
+ """Check if terraform CLI is available in PATH."""
65
+ return shutil.which(self.terraform_bin) is not None
66
+
67
+ def check_initialized(self) -> bool:
68
+ """Check if terraform init has been run in the directory."""
69
+ terraform_dir = self.terraform_dir / ".terraform"
70
+ return terraform_dir.exists() and terraform_dir.is_dir()
71
+
72
+ def run_show_json(self, state_file: Optional[Path] = None) -> Optional[TerraformStateResult]:
73
+ """Run terraform show -json and parse the state output.
74
+
75
+ If state_file is provided, reads from that file directly.
76
+ Otherwise, tries to read from local JSON files (plan.json, state.json, terraform.tfstate.json),
77
+ then falls back to running terraform show -json.
78
+
79
+ Args:
80
+ state_file: Optional path to a specific JSON state/plan file.
81
+
82
+ Returns:
83
+ TerraformStateResult with resources, or None if failed.
84
+ """
85
+ # If a specific state file is provided, use it directly
86
+ if state_file is not None:
87
+ if state_file.exists():
88
+ try:
89
+ with open(state_file, "r", encoding="utf-8") as f:
90
+ json_data = json.load(f)
91
+ result = parse_state_json(json_data)
92
+ if result and result.resources:
93
+ logger.info(
94
+ "Loaded state from %s: %d resources", state_file, len(result.resources)
95
+ )
96
+ return result
97
+ else:
98
+ logger.warning("No resources found in %s", state_file)
99
+ return None
100
+ except OSError as e:
101
+ logger.warning("Could not read %s: %s", state_file, e)
102
+ return None
103
+ except json.JSONDecodeError as e:
104
+ logger.warning("Could not parse JSON in %s: %s", state_file, e)
105
+ return None
106
+ else:
107
+ logger.warning("State file not found: %s", state_file)
108
+ return None
109
+
110
+ # Try to read from local JSON files in terraform dir
111
+ json_files = [
112
+ self.terraform_dir / "plan.json",
113
+ self.terraform_dir / "state.json",
114
+ self.terraform_dir / "terraform.tfstate.json",
115
+ ]
116
+
117
+ for json_file in json_files:
118
+ if json_file.exists():
119
+ try:
120
+ with open(json_file, "r", encoding="utf-8") as f:
121
+ json_data = json.load(f)
122
+
123
+ result = parse_state_json(json_data)
124
+ if result and result.resources:
125
+ logger.info(
126
+ "Loaded state from %s: %d resources",
127
+ json_file.name,
128
+ len(result.resources),
129
+ )
130
+ return result
131
+ except OSError as e:
132
+ logger.debug("Could not read %s: %s", json_file.name, e)
133
+ except json.JSONDecodeError as e:
134
+ logger.debug("Could not parse JSON in %s: %s", json_file.name, e)
135
+
136
+ # Fall back to running terraform show -json
137
+ if not self.check_terraform_available():
138
+ logger.warning("Terraform CLI not found in PATH")
139
+ return None
140
+
141
+ if not self.check_initialized():
142
+ logger.warning(
143
+ "Terraform not initialized in %s. Run 'terraform init' first.", self.terraform_dir
144
+ )
145
+ return None
146
+
147
+ try:
148
+ result = subprocess.run(
149
+ [self.terraform_bin, "show", "-json"],
150
+ cwd=self.terraform_dir,
151
+ capture_output=True,
152
+ text=True,
153
+ timeout=self.TIMEOUT_SHOW,
154
+ )
155
+
156
+ if result.returncode != 0:
157
+ logger.warning("terraform show -json failed: %s", result.stderr)
158
+ return None
159
+
160
+ if not result.stdout.strip():
161
+ logger.info("No terraform state found")
162
+ return None
163
+
164
+ try:
165
+ json_data = json.loads(result.stdout)
166
+ except json.JSONDecodeError as e:
167
+ logger.warning("Failed to parse terraform show output: %s", e)
168
+ return None
169
+
170
+ return parse_state_json(json_data)
171
+
172
+ except subprocess.TimeoutExpired:
173
+ logger.warning("terraform show timed out after %ds", self.TIMEOUT_SHOW)
174
+ return None
175
+ except (OSError, subprocess.SubprocessError) as e:
176
+ logger.warning("Error running terraform show: %s", e)
177
+ return None
178
+
179
+
180
+ def parse_state_json(json_data: Any) -> TerraformStateResult:
181
+ """Parse terraform show -json or terraform plan -json output.
182
+
183
+ Supports multiple JSON structures:
184
+
185
+ 1. terraform show -json (state):
186
+ {
187
+ "values": {
188
+ "root_module": {
189
+ "resources": [...],
190
+ "child_modules": [...]
191
+ }
192
+ }
193
+ }
194
+
195
+ 2. terraform plan -json:
196
+ {
197
+ "planned_values": {
198
+ "root_module": {
199
+ "resources": [...],
200
+ "child_modules": [...]
201
+ }
202
+ },
203
+ "prior_state": {
204
+ "values": {
205
+ "root_module": {...}
206
+ }
207
+ }
208
+ }
209
+
210
+ Args:
211
+ json_data: Parsed JSON from terraform show/plan -json
212
+
213
+ Returns:
214
+ TerraformStateResult with parsed resources (empty if input is invalid)
215
+ """
216
+ result = TerraformStateResult()
217
+
218
+ # Validate input type
219
+ if not isinstance(json_data, dict):
220
+ logger.warning("parse_state_json received non-dict input: %s", type(json_data).__name__)
221
+ return result
222
+
223
+ # Try different JSON structures in order of preference
224
+ root_module = None
225
+
226
+ # 1. Try "values" (terraform show -json format)
227
+ values = json_data.get("values")
228
+ if values:
229
+ root_module = values.get("root_module")
230
+ if root_module:
231
+ logger.debug("Using 'values.root_module' structure (terraform show format)")
232
+
233
+ # 2. Try "planned_values" (terraform plan -json format)
234
+ if not root_module:
235
+ planned_values = json_data.get("planned_values")
236
+ if planned_values:
237
+ root_module = planned_values.get("root_module")
238
+ if root_module:
239
+ logger.debug("Using 'planned_values.root_module' structure (terraform plan format)")
240
+
241
+ # 3. Try "prior_state.values" (terraform plan -json format, existing state)
242
+ if not root_module:
243
+ prior_state = json_data.get("prior_state")
244
+ if prior_state:
245
+ prior_values = prior_state.get("values")
246
+ if prior_values:
247
+ root_module = prior_values.get("root_module")
248
+ if root_module:
249
+ logger.debug("Using 'prior_state.values.root_module' structure")
250
+
251
+ if not root_module:
252
+ logger.debug("No root_module found in terraform JSON")
253
+ return result
254
+
255
+ # Parse root module resources
256
+ _parse_module_resources(root_module, result, module_path="")
257
+
258
+ # Parse child modules recursively
259
+ for child_module in root_module.get("child_modules", []):
260
+ _parse_child_module(child_module, result)
261
+
262
+ logger.debug("Parsed terraform state: %d resources", len(result.resources))
263
+
264
+ return result
265
+
266
+
267
+ def _parse_module_resources(
268
+ module_data: dict, result: TerraformStateResult, module_path: str
269
+ ) -> None:
270
+ """Parse resources from a module in state JSON."""
271
+ for res in module_data.get("resources", []):
272
+ address = res.get("address", "")
273
+ resource_type = res.get("type", "")
274
+ name = res.get("name", "")
275
+ index = res.get("index") # Can be int (count) or str (for_each)
276
+ values = res.get("values", {})
277
+
278
+ # Ensure values is a dict
279
+ if not isinstance(values, dict):
280
+ values = {}
281
+
282
+ if resource_type and name:
283
+ state_resource = TerraformStateResource(
284
+ address=address,
285
+ resource_type=resource_type,
286
+ name=name,
287
+ index=index, # Keep original type (int or str)
288
+ values=values,
289
+ module_path=module_path,
290
+ )
291
+ result.resources.append(state_resource)
292
+
293
+
294
+ def _parse_child_module(module_data: dict, result: TerraformStateResult) -> None:
295
+ """Recursively parse a child module from state JSON."""
296
+ address = module_data.get("address", "")
297
+
298
+ # Extract module path from address (e.g., "module.vpc" -> "vpc")
299
+ module_path = ""
300
+ if address.startswith("module."):
301
+ # Handle nested modules: "module.vpc.module.subnets" -> "vpc.subnets"
302
+ parts = address.split(".")
303
+ module_parts = []
304
+ for i, part in enumerate(parts):
305
+ if part != "module" and (i == 0 or parts[i - 1] == "module"):
306
+ module_parts.append(part)
307
+ module_path = ".".join(module_parts)
308
+
309
+ _parse_module_resources(module_data, result, module_path)
310
+
311
+ # Recurse into nested child modules
312
+ for child in module_data.get("child_modules", []):
313
+ _parse_child_module(child, result)
314
+
315
+
316
+ def map_state_to_resource_id(state_address: str) -> str:
317
+ """Convert terraform state address to parser resource full_id format.
318
+
319
+ Examples:
320
+ "aws_vpc.main" -> "aws_vpc.main"
321
+ "aws_subnet.public[0]" -> "aws_subnet.public"
322
+ "module.vpc.aws_subnet.public[0]" -> "vpc.aws_subnet.public"
323
+
324
+ Args:
325
+ state_address: Resource address from terraform state
326
+
327
+ Returns:
328
+ Resource ID matching parser's full_id format
329
+ """
330
+ # Remove index brackets
331
+ address = re.sub(r"\[\d+\]", "", state_address)
332
+ address = re.sub(r'\["[^"]+"\]', "", address)
333
+
334
+ # Handle module prefix
335
+ if address.startswith("module."):
336
+ parts = address.split(".")
337
+ # module.vpc.aws_subnet.public -> vpc.aws_subnet.public
338
+ module_parts = []
339
+ resource_parts = []
340
+
341
+ i = 0
342
+ while i < len(parts):
343
+ if parts[i] == "module" and i + 1 < len(parts):
344
+ module_parts.append(parts[i + 1])
345
+ i += 2
346
+ else:
347
+ resource_parts = parts[i:]
348
+ break
349
+
350
+ if module_parts and resource_parts:
351
+ module_path = ".".join(module_parts)
352
+ resource_part = ".".join(resource_parts)
353
+ return f"{module_path}.{resource_part}"
354
+
355
+ return address
@@ -0,0 +1,180 @@
1
+ """
2
+ Variable Resolver Module
3
+
4
+ Parses and resolves Terraform variables, locals, and interpolations.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional, Union
11
+
12
+ import hcl2
13
+ from lark.exceptions import UnexpectedInput, UnexpectedToken
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class VariableResolver:
19
+ """Resolves Terraform variables and locals from tfvars and .tf files."""
20
+
21
+ def __init__(self, directory: Union[str, Path]):
22
+ """Initialize the resolver by parsing files in the given directory.
23
+
24
+ Args:
25
+ directory: Path to directory containing Terraform files
26
+ """
27
+ self.directory = Path(directory)
28
+ self._variables: Dict[str, Any] = {}
29
+ self._locals: Dict[str, Any] = {}
30
+
31
+ # Parse files in order of precedence
32
+ self._parse_variable_defaults()
33
+ self._parse_tfvars()
34
+ self._parse_locals()
35
+
36
+ def _parse_tfvars(self) -> None:
37
+ """Parse .tfvars and .auto.tfvars files for variable values.
38
+
39
+ Files are parsed in alphabetical order, with later files overriding earlier ones.
40
+ terraform.tfvars is parsed last to give it highest precedence.
41
+ """
42
+ tfvars_files = []
43
+
44
+ # Collect .auto.tfvars files
45
+ tfvars_files.extend(sorted(self.directory.glob("*.auto.tfvars")))
46
+
47
+ # Add terraform.tfvars last (highest precedence)
48
+ terraform_tfvars = self.directory / "terraform.tfvars"
49
+ if terraform_tfvars.exists():
50
+ tfvars_files.append(terraform_tfvars)
51
+
52
+ for tfvars_file in tfvars_files:
53
+ try:
54
+ with open(tfvars_file, "r", encoding="utf-8") as f:
55
+ content = hcl2.load(f)
56
+ for key, value in content.items():
57
+ self._variables[key] = value
58
+ except OSError as e:
59
+ logger.warning("Could not read tfvars file %s: %s", tfvars_file, e)
60
+ except (UnexpectedInput, UnexpectedToken) as e:
61
+ logger.warning("Could not parse tfvars file %s: %s", tfvars_file, e)
62
+
63
+ def _parse_locals(self) -> None:
64
+ """Parse locals blocks from all .tf files."""
65
+ for tf_file in self.directory.glob("*.tf"):
66
+ try:
67
+ with open(tf_file, "r", encoding="utf-8") as f:
68
+ content = hcl2.load(f)
69
+
70
+ for locals_block in content.get("locals", []):
71
+ if isinstance(locals_block, dict):
72
+ for key, value in locals_block.items():
73
+ self._locals[key] = value
74
+ except OSError as e:
75
+ logger.warning("Could not read file %s: %s", tf_file, e)
76
+ except (UnexpectedInput, UnexpectedToken) as e:
77
+ logger.warning("Could not parse locals from %s: %s", tf_file, e)
78
+
79
+ def _parse_variable_defaults(self) -> None:
80
+ """Parse variable blocks for default values from all .tf files."""
81
+ for tf_file in self.directory.glob("*.tf"):
82
+ try:
83
+ with open(tf_file, "r", encoding="utf-8") as f:
84
+ content = hcl2.load(f)
85
+
86
+ for variable_block in content.get("variable", []):
87
+ if isinstance(variable_block, dict):
88
+ for var_name, var_config in variable_block.items():
89
+ if isinstance(var_config, dict):
90
+ default = var_config.get("default")
91
+ if default is not None:
92
+ self._variables[var_name] = default
93
+ elif isinstance(var_config, list) and var_config:
94
+ # HCL2 sometimes returns list of configs
95
+ config = var_config[0]
96
+ if isinstance(config, dict):
97
+ default = config.get("default")
98
+ if default is not None:
99
+ self._variables[var_name] = default
100
+ except OSError as e:
101
+ logger.warning("Could not read file %s: %s", tf_file, e)
102
+ except (UnexpectedInput, UnexpectedToken) as e:
103
+ logger.warning("Could not parse variables from %s: %s", tf_file, e)
104
+
105
+ def get_variable(self, name: str) -> Optional[Any]:
106
+ """Get a variable value by name.
107
+
108
+ Args:
109
+ name: The variable name (without 'var.' prefix)
110
+
111
+ Returns:
112
+ The variable value, or None if not found
113
+ """
114
+ return self._variables.get(name)
115
+
116
+ def get_local(self, name: str) -> Optional[Any]:
117
+ """Get a local value by name.
118
+
119
+ Args:
120
+ name: The local name (without 'local.' prefix)
121
+
122
+ Returns:
123
+ The local value, or None if not found
124
+ """
125
+ return self._locals.get(name)
126
+
127
+ def resolve(self, value: Any) -> Any:
128
+ """Resolve interpolations in a value.
129
+
130
+ Handles ${var.name} and ${local.name} interpolations.
131
+
132
+ Args:
133
+ value: The value to resolve (string or other type)
134
+
135
+ Returns:
136
+ The resolved value with interpolations replaced,
137
+ or the original value if no interpolations or resolution failed
138
+ """
139
+ if value is None:
140
+ return None
141
+
142
+ if not isinstance(value, str):
143
+ return value
144
+
145
+ # Pattern to match ${var.name} or ${local.name}
146
+ pattern = r"\$\{(var|local)\.(\w+)\}"
147
+
148
+ def replace_interpolation(match: re.Match) -> str:
149
+ ref_type = match.group(1)
150
+ ref_name = match.group(2)
151
+
152
+ if ref_type == "var":
153
+ resolved = self.get_variable(ref_name)
154
+ else: # local
155
+ resolved = self.get_local(ref_name)
156
+
157
+ if resolved is not None:
158
+ return str(resolved)
159
+ else:
160
+ # Keep original if not resolvable
161
+ return match.group(0)
162
+
163
+ return re.sub(pattern, replace_interpolation, value)
164
+
165
+ @staticmethod
166
+ def truncate_name(name: str, max_length: int = 25) -> str:
167
+ """Truncate a name to a maximum length with ellipsis.
168
+
169
+ Args:
170
+ name: The name to truncate
171
+ max_length: Maximum length (default 25)
172
+
173
+ Returns:
174
+ The truncated name with '...' suffix if it exceeds max_length
175
+ """
176
+ if len(name) <= max_length:
177
+ return name
178
+
179
+ # Leave room for '...' suffix
180
+ return name[: max_length - 3] + "..."