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/parser.py CHANGED
@@ -4,17 +4,26 @@ 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
10
- from typing import Any, Dict, List, Optional
11
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
11
12
 
12
13
  import hcl2
14
+ from lark.exceptions import UnexpectedInput, UnexpectedToken
15
+
16
+ if TYPE_CHECKING:
17
+ from terraformgraph.terraform_tools import TerraformStateResult
18
+ from terraformgraph.variable_resolver import VariableResolver
19
+
20
+ logger = logging.getLogger(__name__)
13
21
 
14
22
 
15
23
  @dataclass
16
24
  class TerraformResource:
17
25
  """Represents a parsed Terraform resource."""
26
+
18
27
  resource_type: str
19
28
  resource_name: str
20
29
  module_path: str
@@ -33,15 +42,33 @@ class TerraformResource:
33
42
  @property
34
43
  def display_name(self) -> str:
35
44
  """Human-readable name for display."""
36
- name = self.attributes.get('name', self.resource_name)
37
- if isinstance(name, str) and '${' not in name:
45
+ name = self.attributes.get("name", self.resource_name)
46
+ if isinstance(name, str) and "${" not in name:
38
47
  return name
39
48
  return self.resource_name
40
49
 
50
+ def get_resolved_display_name(self, resolver: "VariableResolver") -> str:
51
+ """Get display name with interpolations resolved and truncated.
52
+
53
+ Args:
54
+ resolver: VariableResolver instance for resolving interpolations
55
+
56
+ Returns:
57
+ Resolved and truncated display name
58
+ """
59
+ from terraformgraph.variable_resolver import VariableResolver
60
+
61
+ name = self.attributes.get("name", self.resource_name)
62
+ if isinstance(name, str):
63
+ resolved_name = resolver.resolve(name)
64
+ return VariableResolver.truncate_name(resolved_name)
65
+ return VariableResolver.truncate_name(self.resource_name)
66
+
41
67
 
42
68
  @dataclass
43
69
  class ModuleCall:
44
70
  """Represents a module instantiation."""
71
+
45
72
  name: str
46
73
  source: str
47
74
  inputs: Dict[str, Any]
@@ -51,6 +78,7 @@ class ModuleCall:
51
78
  @dataclass
52
79
  class ResourceRelationship:
53
80
  """Represents a connection between resources."""
81
+
54
82
  source_id: str
55
83
  target_id: str
56
84
  relationship_type: str
@@ -60,6 +88,7 @@ class ResourceRelationship:
60
88
  @dataclass
61
89
  class ParseResult:
62
90
  """Result of parsing Terraform files."""
91
+
63
92
  resources: List[TerraformResource] = field(default_factory=list)
64
93
  modules: List[ModuleCall] = field(default_factory=list)
65
94
  relationships: List[ResourceRelationship] = field(default_factory=list)
@@ -68,39 +97,37 @@ class ParseResult:
68
97
  class TerraformParser:
69
98
  """Parses Terraform HCL files and extracts resources."""
70
99
 
71
- REFERENCE_PATTERNS = [
72
- # module.X.output
73
- (r'module\.(\w+)\.(\w+)', 'module_ref'),
74
- # aws_resource.name.attribute
75
- (r'(aws_\w+)\.(\w+)\.(\w+)', 'resource_ref'),
76
- # var.X
77
- (r'var\.(\w+)', 'var_ref'),
78
- # local.X
79
- (r'local\.(\w+)', 'local_ref'),
80
- ]
81
-
82
100
  RELATIONSHIP_EXTRACTORS = {
83
- 'vpc_id': ('belongs_to_vpc', 'aws_vpc'),
84
- 'subnet_id': ('deployed_in_subnet', 'aws_subnet'),
85
- 'subnet_ids': ('deployed_in_subnets', 'aws_subnet'),
86
- 'security_group_ids': ('uses_security_group', 'aws_security_group'),
87
- 'kms_master_key_id': ('encrypted_by', 'aws_kms_key'),
88
- 'kms_key_id': ('encrypted_by', 'aws_kms_key'),
89
- 'target_group_arn': ('routes_to', 'aws_lb_target_group'),
90
- 'load_balancer_arn': ('attached_to', 'aws_lb'),
91
- 'web_acl_arn': ('protected_by', 'aws_wafv2_web_acl'),
92
- 'waf_acl_arn': ('protected_by', 'aws_wafv2_web_acl'),
93
- 'certificate_arn': ('uses_certificate', 'aws_acm_certificate'),
94
- 'role_arn': ('assumes_role', 'aws_iam_role'),
95
- 'queue_arn': ('sends_to_queue', 'aws_sqs_queue'),
96
- 'topic_arn': ('publishes_to', 'aws_sns_topic'),
97
- 'alarm_topic_arn': ('alerts_to', 'aws_sns_topic'),
101
+ "vpc_id": ("belongs_to_vpc", "aws_vpc"),
102
+ "subnet_id": ("deployed_in_subnet", "aws_subnet"),
103
+ "subnet_ids": ("deployed_in_subnets", "aws_subnet"),
104
+ "security_group_ids": ("uses_security_group", "aws_security_group"),
105
+ "vpc_security_group_ids": ("uses_security_group", "aws_security_group"),
106
+ "security_groups": ("uses_security_group", "aws_security_group"),
107
+ "kms_master_key_id": ("encrypted_by", "aws_kms_key"),
108
+ "kms_key_id": ("encrypted_by", "aws_kms_key"),
109
+ "target_group_arn": ("routes_to", "aws_lb_target_group"),
110
+ "load_balancer_arn": ("attached_to", "aws_lb"),
111
+ "web_acl_arn": ("protected_by", "aws_wafv2_web_acl"),
112
+ "waf_acl_arn": ("protected_by", "aws_wafv2_web_acl"),
113
+ "certificate_arn": ("uses_certificate", "aws_acm_certificate"),
114
+ "role_arn": ("assumes_role", "aws_iam_role"),
115
+ "queue_arn": ("sends_to_queue", "aws_sqs_queue"),
116
+ "topic_arn": ("publishes_to", "aws_sns_topic"),
117
+ "alarm_topic_arn": ("alerts_to", "aws_sns_topic"),
98
118
  }
99
119
 
100
- def __init__(self, infrastructure_path: str, icons_path: Optional[str] = None):
120
+ def __init__(
121
+ self,
122
+ infrastructure_path: str,
123
+ use_terraform_state: bool = False,
124
+ state_file: Optional[str] = None,
125
+ ):
101
126
  self.infrastructure_path = Path(infrastructure_path)
102
- self.icons_path = Path(icons_path) if icons_path else None
103
127
  self._parsed_modules: Dict[str, ParseResult] = {}
128
+ self.use_terraform_state = use_terraform_state
129
+ self.state_file = Path(state_file) if state_file else None
130
+ self._state_result: Optional["TerraformStateResult"] = None
104
131
 
105
132
  def parse_environment(self, environment: str) -> ParseResult:
106
133
  """Parse all Terraform files for a specific environment."""
@@ -119,9 +146,6 @@ class TerraformParser:
119
146
  Returns:
120
147
  ParseResult with all resources and relationships
121
148
  """
122
- if isinstance(directory, str):
123
- directory = Path(directory)
124
-
125
149
  if not directory.exists():
126
150
  raise ValueError(f"Directory does not exist: {directory}")
127
151
 
@@ -130,7 +154,7 @@ class TerraformParser:
130
154
  # Parse all .tf files in directory
131
155
  tf_files = list(directory.glob("*.tf"))
132
156
  if not tf_files:
133
- print(f"Warning: No .tf files found in {directory}")
157
+ logger.warning("No .tf files found in %s", directory)
134
158
 
135
159
  for tf_file in tf_files:
136
160
  self._parse_file(tf_file, result, module_path="")
@@ -145,19 +169,60 @@ class TerraformParser:
145
169
  # Extract relationships from all resources
146
170
  self._extract_relationships(result)
147
171
 
172
+ # Enhance with terraform state if requested
173
+ if self.use_terraform_state:
174
+ self._enhance_with_terraform_state(result, directory)
175
+
148
176
  return result
149
177
 
178
+ def _enhance_with_terraform_state(self, result: ParseResult, directory: Path) -> None:
179
+ """Enhance parse result with data from terraform state."""
180
+ from terraformgraph.terraform_tools import TerraformToolsRunner
181
+
182
+ runner = TerraformToolsRunner(directory)
183
+ state_result = runner.run_show_json(state_file=self.state_file)
184
+ if state_result:
185
+ self._state_result = state_result
186
+ self._enrich_resources_with_state(result, state_result)
187
+ logger.info("Enhanced with terraform state: %d resources", len(state_result.resources))
188
+
189
+ def _enrich_resources_with_state(
190
+ self, result: ParseResult, state_result: "TerraformStateResult"
191
+ ) -> None:
192
+ """Enrich parsed resources with actual values from terraform state."""
193
+ from terraformgraph.terraform_tools import map_state_to_resource_id
194
+
195
+ # Build index by full_id
196
+ resource_index = {r.full_id: r for r in result.resources}
197
+
198
+ for state_res in state_result.resources:
199
+ resource_id = map_state_to_resource_id(state_res.address)
200
+
201
+ if resource_id in resource_index:
202
+ resource = resource_index[resource_id]
203
+ # Merge state values into attributes (state values take precedence)
204
+ for key, value in state_res.values.items():
205
+ if value is not None:
206
+ resource.attributes[f"_state_{key}"] = value
207
+
208
+ def get_state_result(self) -> Optional["TerraformStateResult"]:
209
+ """Get the terraform state result if available."""
210
+ return self._state_result
211
+
150
212
  def _parse_file(self, file_path: Path, result: ParseResult, module_path: str) -> None:
151
213
  """Parse a single Terraform file."""
152
214
  try:
153
- with open(file_path, 'r') as f:
215
+ with open(file_path, "r", encoding="utf-8") as f:
154
216
  content = hcl2.load(f)
155
- except Exception as e:
156
- print(f"Warning: Could not parse {file_path}: {e}")
217
+ except OSError as e:
218
+ logger.warning("Could not read %s: %s", file_path, e)
219
+ return
220
+ except (UnexpectedInput, UnexpectedToken) as e:
221
+ logger.warning("Could not parse HCL in %s: %s", file_path, e)
157
222
  return
158
223
 
159
224
  # Extract resources
160
- for resource_block in content.get('resource', []):
225
+ for resource_block in content.get("resource", []):
161
226
  for resource_type, resources in resource_block.items():
162
227
  for resource_name, config in resources.items():
163
228
  # Handle list configs (HCL2 can return lists)
@@ -171,35 +236,32 @@ class TerraformParser:
171
236
  attributes=config,
172
237
  source_file=str(file_path),
173
238
  count=self._extract_count(config),
174
- for_each='for_each' in config
239
+ for_each="for_each" in config,
175
240
  )
176
241
  result.resources.append(resource)
177
242
 
178
243
  # Extract module calls
179
- for module_block in content.get('module', []):
244
+ for module_block in content.get("module", []):
180
245
  for module_name, config in module_block.items():
181
246
  if isinstance(config, list):
182
247
  config = config[0] if config else {}
183
248
 
184
- source = config.get('source', '')
249
+ source = config.get("source", "")
185
250
  module = ModuleCall(
186
- name=module_name,
187
- source=source,
188
- inputs=config,
189
- source_file=str(file_path)
251
+ name=module_name, source=source, inputs=config, source_file=str(file_path)
190
252
  )
191
253
  result.modules.append(module)
192
254
 
193
255
  def _parse_module(self, source: str, base_path: Path, module_name: str) -> ParseResult:
194
256
  """Parse a module from its source path."""
195
257
  # Resolve relative path
196
- if source.startswith('../') or source.startswith('./'):
258
+ if source.startswith("../") or source.startswith("./"):
197
259
  module_path = (base_path / source).resolve()
198
260
  else:
199
- module_path = self.infrastructure_path / '.modules' / source
261
+ module_path = self.infrastructure_path / ".modules" / source
200
262
 
201
263
  if not module_path.exists():
202
- print(f"Warning: Module path not found: {module_path}")
264
+ logger.warning("Module path not found: %s", module_path)
203
265
  return ParseResult()
204
266
 
205
267
  # Check cache
@@ -216,7 +278,7 @@ class TerraformParser:
216
278
  attributes=res.attributes,
217
279
  source_file=res.source_file,
218
280
  count=res.count,
219
- for_each=res.for_each
281
+ for_each=res.for_each,
220
282
  )
221
283
  result.resources.append(new_res)
222
284
  return result
@@ -230,7 +292,7 @@ class TerraformParser:
230
292
 
231
293
  def _extract_count(self, config: Dict[str, Any]) -> Optional[int]:
232
294
  """Extract count value from resource config."""
233
- count = config.get('count')
295
+ count = config.get("count")
234
296
  if count is None:
235
297
  return None
236
298
  if isinstance(count, int):
@@ -260,54 +322,247 @@ class TerraformParser:
260
322
  if value:
261
323
  targets = self._find_referenced_resources(value, target_type, type_index)
262
324
  for target in targets:
263
- result.relationships.append(ResourceRelationship(
264
- source_id=resource.full_id,
265
- target_id=target.full_id,
266
- relationship_type=rel_type
267
- ))
325
+ result.relationships.append(
326
+ ResourceRelationship(
327
+ source_id=resource.full_id,
328
+ target_id=target.full_id,
329
+ relationship_type=rel_type,
330
+ )
331
+ )
332
+
333
+ # Deep scan: find resource references in ALL attributes (catches nested refs
334
+ # like environment.variables that RELATIONSHIP_EXTRACTORS miss)
335
+ self._extract_deep_references(resource, result, type_index)
336
+
337
+ # Check for security group cross-references
338
+ self._extract_sg_cross_references(resource, result, type_index)
339
+
340
+ # Resource types excluded from deep scan (infrastructure plumbing, not logical connections)
341
+ _DEEP_SCAN_EXCLUDED_TYPES = frozenset({
342
+ "aws_security_group", "aws_iam_role", "aws_iam_policy",
343
+ "aws_subnet", "aws_vpc", "aws_route_table", "aws_route_table_association",
344
+ "aws_eip", "aws_network_interface",
345
+ })
346
+
347
+ def _extract_deep_references(
348
+ self,
349
+ resource: TerraformResource,
350
+ result: ParseResult,
351
+ type_index: Dict[str, List[TerraformResource]],
352
+ ) -> None:
353
+ """Scan all attribute values for resource references not caught by RELATIONSHIP_EXTRACTORS."""
354
+ # Build set of already-known targets to avoid duplicates
355
+ known_targets: set = set()
356
+ for rel in result.relationships:
357
+ if rel.source_id == resource.full_id:
358
+ known_targets.add(rel.target_id)
359
+
360
+ # Convert entire attributes dict to string and scan for all known resource types
361
+ attrs_str = str(resource.attributes)
362
+ for target_type, resources_of_type in type_index.items():
363
+ if target_type == resource.resource_type:
364
+ continue # Skip self-type references
365
+ if target_type in self._DEEP_SCAN_EXCLUDED_TYPES:
366
+ continue # Skip infrastructure plumbing types
367
+ pattern = rf"{re.escape(target_type)}\.(\w+)\."
368
+ for match in re.finditer(pattern, attrs_str):
369
+ res_name = match.group(1)
370
+ for target_res in resources_of_type:
371
+ if target_res.resource_name == res_name and target_res.full_id not in known_targets:
372
+ known_targets.add(target_res.full_id)
373
+ result.relationships.append(
374
+ ResourceRelationship(
375
+ source_id=resource.full_id,
376
+ target_id=target_res.full_id,
377
+ relationship_type="references",
378
+ )
379
+ )
380
+ break
268
381
 
269
382
  def _extract_dlq_relationship(
270
383
  self,
271
384
  resource: TerraformResource,
272
385
  result: ParseResult,
273
- type_index: Dict[str, List[TerraformResource]]
386
+ type_index: Dict[str, List[TerraformResource]],
274
387
  ) -> None:
275
388
  """Extract SQS dead letter queue relationships."""
276
- if resource.resource_type != 'aws_sqs_queue':
389
+ if resource.resource_type != "aws_sqs_queue":
277
390
  return
278
391
 
279
- redrive = resource.attributes.get('redrive_policy')
392
+ redrive = resource.attributes.get("redrive_policy")
280
393
  if not redrive:
281
394
  return
282
395
 
283
396
  # Parse redrive policy (could be string or dict)
284
397
  if isinstance(redrive, str):
285
398
  # Try to find DLQ reference in string
286
- match = re.search(r'aws_sqs_queue\.(\w+)\.arn', redrive)
399
+ match = re.search(r"aws_sqs_queue\.(\w+)\.arn", redrive)
287
400
  if match:
288
401
  dlq_name = match.group(1)
289
- for queue in type_index.get('aws_sqs_queue', []):
402
+ for queue in type_index.get("aws_sqs_queue", []):
290
403
  if queue.resource_name == dlq_name:
291
- result.relationships.append(ResourceRelationship(
292
- source_id=resource.full_id,
293
- target_id=queue.full_id,
294
- relationship_type='redrives_to',
295
- label='DLQ'
296
- ))
404
+ result.relationships.append(
405
+ ResourceRelationship(
406
+ source_id=resource.full_id,
407
+ target_id=queue.full_id,
408
+ relationship_type="redrives_to",
409
+ label="DLQ",
410
+ )
411
+ )
297
412
  break
298
413
 
299
- def _find_referenced_resources(
414
+ def _extract_sg_cross_references(
415
+ self,
416
+ resource: TerraformResource,
417
+ result: ParseResult,
418
+ type_index: Dict[str, List[TerraformResource]],
419
+ ) -> None:
420
+ """Extract security group cross-references from ingress rules.
421
+
422
+ Creates sg_allows_from relationships when a security group rule
423
+ references another security group as its source.
424
+ """
425
+ sg_resources = type_index.get("aws_security_group", [])
426
+ if not sg_resources:
427
+ return
428
+
429
+ # Case 1: Inline ingress rules in aws_security_group
430
+ if resource.resource_type == "aws_security_group":
431
+ ingress_rules = resource.attributes.get("ingress", [])
432
+ if not isinstance(ingress_rules, list):
433
+ return
434
+ for rule in ingress_rules:
435
+ if not isinstance(rule, dict):
436
+ continue
437
+ self._process_sg_rule(
438
+ rule, resource.full_id, result, sg_resources, is_inline=True
439
+ )
440
+
441
+ # Case 2: Standalone aws_security_group_rule with type=ingress
442
+ elif resource.resource_type == "aws_security_group_rule":
443
+ if resource.attributes.get("type") != "ingress":
444
+ return
445
+ # The SG this rule belongs to
446
+ sg_id_attr = resource.attributes.get("security_group_id", "")
447
+ target_sg = self._resolve_sg_ref(str(sg_id_attr), sg_resources)
448
+ if not target_sg:
449
+ return
450
+ source_ref = resource.attributes.get("source_security_group_id", "")
451
+ source_sg = self._resolve_sg_ref(str(source_ref), sg_resources)
452
+ if source_sg and source_sg.full_id != target_sg.full_id:
453
+ port_label = self._format_port_label(resource.attributes)
454
+ result.relationships.append(
455
+ ResourceRelationship(
456
+ source_id=source_sg.full_id,
457
+ target_id=target_sg.full_id,
458
+ relationship_type="sg_allows_from",
459
+ label=port_label,
460
+ )
461
+ )
462
+
463
+ # Case 3: aws_vpc_security_group_ingress_rule
464
+ elif resource.resource_type == "aws_vpc_security_group_ingress_rule":
465
+ sg_id_attr = resource.attributes.get("security_group_id", "")
466
+ target_sg = self._resolve_sg_ref(str(sg_id_attr), sg_resources)
467
+ if not target_sg:
468
+ return
469
+ source_ref = resource.attributes.get(
470
+ "referenced_security_group_id", ""
471
+ )
472
+ source_sg = self._resolve_sg_ref(str(source_ref), sg_resources)
473
+ if source_sg and source_sg.full_id != target_sg.full_id:
474
+ port_label = self._format_port_label(resource.attributes)
475
+ result.relationships.append(
476
+ ResourceRelationship(
477
+ source_id=source_sg.full_id,
478
+ target_id=target_sg.full_id,
479
+ relationship_type="sg_allows_from",
480
+ label=port_label,
481
+ )
482
+ )
483
+
484
+ def _process_sg_rule(
300
485
  self,
301
- value: Any,
302
- target_type: str,
303
- type_index: Dict[str, List[TerraformResource]]
486
+ rule: dict,
487
+ sg_full_id: str,
488
+ result: ParseResult,
489
+ sg_resources: List[TerraformResource],
490
+ is_inline: bool = True,
491
+ ) -> None:
492
+ """Process a single SG ingress rule for cross-references."""
493
+ # Look for security_groups list (inline rules use this)
494
+ sg_refs = rule.get("security_groups", [])
495
+ if not isinstance(sg_refs, list):
496
+ sg_refs = [sg_refs] if sg_refs else []
497
+
498
+ for ref in sg_refs:
499
+ source_sg = self._resolve_sg_ref(str(ref), sg_resources)
500
+ if source_sg and source_sg.full_id != sg_full_id:
501
+ port_label = self._format_port_label(rule)
502
+ result.relationships.append(
503
+ ResourceRelationship(
504
+ source_id=source_sg.full_id,
505
+ target_id=sg_full_id,
506
+ relationship_type="sg_allows_from",
507
+ label=port_label,
508
+ )
509
+ )
510
+
511
+ @staticmethod
512
+ def _resolve_sg_ref(
513
+ value: str, sg_resources: List[TerraformResource]
514
+ ) -> Optional[TerraformResource]:
515
+ """Resolve a security group reference to a TerraformResource."""
516
+ if not value:
517
+ return None
518
+ match = re.search(r"aws_security_group\.(\w+)", value)
519
+ if match:
520
+ name = match.group(1)
521
+ for sg in sg_resources:
522
+ if sg.resource_name == name:
523
+ return sg
524
+ return None
525
+
526
+ @staticmethod
527
+ def _format_port_label(attrs: dict) -> str:
528
+ """Format a port label from rule attributes (e.g., 'TCP/80')."""
529
+ from_port = attrs.get("from_port")
530
+ to_port = attrs.get("to_port")
531
+ protocol = attrs.get("protocol", "tcp")
532
+
533
+ if from_port is None:
534
+ return ""
535
+
536
+ # Coerce ports to int (HCL2 may return strings in some contexts)
537
+ try:
538
+ from_port = int(from_port)
539
+ except (TypeError, ValueError):
540
+ pass
541
+ try:
542
+ to_port = int(to_port)
543
+ except (TypeError, ValueError):
544
+ pass
545
+
546
+ if isinstance(protocol, str):
547
+ protocol = protocol.upper()
548
+ if protocol == "-1":
549
+ return "All Traffic"
550
+
551
+ if from_port == to_port or to_port is None:
552
+ return f"{protocol}/{from_port}"
553
+ if from_port == 0 and to_port == 65535:
554
+ return f"{protocol}/All"
555
+ return f"{protocol}/{from_port}-{to_port}"
556
+
557
+ def _find_referenced_resources(
558
+ self, value: Any, target_type: str, type_index: Dict[str, List[TerraformResource]]
304
559
  ) -> List[TerraformResource]:
305
560
  """Find resources referenced in a value."""
306
561
  results = []
307
562
  value_str = str(value)
308
563
 
309
564
  # Look for resource references
310
- pattern = rf'{target_type}\.(\w+)\.'
565
+ pattern = rf"{target_type}\.(\w+)\."
311
566
  for match in re.finditer(pattern, value_str):
312
567
  res_name = match.group(1)
313
568
  for res in type_index.get(target_type, []):
@@ -316,7 +571,7 @@ class TerraformParser:
316
571
  break
317
572
 
318
573
  # Look for module references
319
- module_pattern = r'module\.(\w+)\.(\w+)'
574
+ module_pattern = r"module\.(\w+)\.(\w+)"
320
575
  for match in re.finditer(module_pattern, value_str):
321
576
  module_name = match.group(1)
322
577
  # Find resources in that module
@@ -326,16 +581,3 @@ class TerraformParser:
326
581
  break
327
582
 
328
583
  return results
329
-
330
-
331
- def get_resource_summary(result: ParseResult) -> Dict[str, int]:
332
- """Get a summary count of resources by type."""
333
- summary: Dict[str, int] = {}
334
- for resource in result.resources:
335
- count = 1
336
- if resource.count and resource.count > 0:
337
- count = resource.count
338
- elif resource.for_each:
339
- count = 1 # Unknown, but at least 1
340
- summary[resource.resource_type] = summary.get(resource.resource_type, 0) + count
341
- return summary