Created IAC reverse generator
This commit is contained in:
15
src/iac_reverse/generator/__init__.py
Normal file
15
src/iac_reverse/generator/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Code generator module for Terraform HCL output."""
|
||||
|
||||
from iac_reverse.generator.code_generator import CodeGenerator
|
||||
from iac_reverse.generator.provider_block import ProviderBlockGenerator
|
||||
from iac_reverse.generator.resource_merger import ResourceMerger
|
||||
from iac_reverse.generator.sanitize import sanitize_identifier
|
||||
from iac_reverse.generator.variable_extractor import VariableExtractor
|
||||
|
||||
__all__ = [
|
||||
"CodeGenerator",
|
||||
"ProviderBlockGenerator",
|
||||
"ResourceMerger",
|
||||
"VariableExtractor",
|
||||
"sanitize_identifier",
|
||||
]
|
||||
BIN
src/iac_reverse/generator/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
src/iac_reverse/generator/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
src/iac_reverse/generator/__pycache__/sanitize.cpython-313.pyc
Normal file
BIN
src/iac_reverse/generator/__pycache__/sanitize.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
304
src/iac_reverse/generator/code_generator.py
Normal file
304
src/iac_reverse/generator/code_generator.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""HCL code generator using Jinja2 templates.
|
||||
|
||||
Produces Terraform HCL files from a DependencyGraph and list of ScanProfiles.
|
||||
Organizes output by resource type (one .tf file per type), includes traceability
|
||||
comments, architecture-specific tags/labels, and uses Terraform resource
|
||||
references for inter-resource dependencies.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from jinja2 import Environment, BaseLoader
|
||||
|
||||
from iac_reverse.generator.sanitize import sanitize_identifier
|
||||
from iac_reverse.models import (
|
||||
CodeGenerationResult,
|
||||
CpuArchitecture,
|
||||
DependencyGraph,
|
||||
DiscoveredResource,
|
||||
GeneratedFile,
|
||||
ResourceRelationship,
|
||||
ScanProfile,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Jinja2 HCL Templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_RESOURCE_BLOCK_TEMPLATE = """\
|
||||
{% for resource in resources %}
|
||||
# Source: {{ resource.unique_id }}
|
||||
resource "{{ resource.resource_type }}" "{{ resource.tf_name }}" {
|
||||
{% for key, value in resource.attributes.items() %}
|
||||
{{ key }} = {{ value }}
|
||||
{% endfor %}
|
||||
{% if resource.tags %}
|
||||
|
||||
tags = {
|
||||
{% for tag_key, tag_value in resource.tags.items() %}
|
||||
"{{ tag_key }}" = "{{ tag_value }}"
|
||||
{% endfor %}
|
||||
}
|
||||
{% endif %}
|
||||
{% if resource.dependencies %}
|
||||
|
||||
{% for dep in resource.dependencies %}
|
||||
depends_on = [{{ dep }}]
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
_RESOURCE_BLOCK_TEMPLATE_V2 = """\
|
||||
{% for resource in resources %}
|
||||
# Source: {{ resource.unique_id }}
|
||||
resource "{{ resource.resource_type }}" "{{ resource.tf_name }}" {
|
||||
{% for key, value in resource.rendered_attributes %}
|
||||
{{ key }} = {{ value }}
|
||||
{% endfor %}
|
||||
{% if resource.tags %}
|
||||
|
||||
tags = {
|
||||
{% for tag_key, tag_value in resource.tags.items() %}
|
||||
"{{ tag_key }}" = "{{ tag_value }}"
|
||||
{% endfor %}
|
||||
}
|
||||
{% endif %}
|
||||
}
|
||||
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: format HCL attribute values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_hcl_value(value: object) -> str:
|
||||
"""Format a Python value as an HCL literal string."""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
elif isinstance(value, int):
|
||||
return str(value)
|
||||
elif isinstance(value, float):
|
||||
return str(value)
|
||||
elif isinstance(value, str):
|
||||
# Escape quotes in strings
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
elif isinstance(value, list):
|
||||
items = [_format_hcl_value(item) for item in value]
|
||||
return "[" + ", ".join(items) + "]"
|
||||
elif isinstance(value, dict):
|
||||
lines = []
|
||||
lines.append("{")
|
||||
for k, v in value.items():
|
||||
lines.append(f' "{k}" = {_format_hcl_value(v)}')
|
||||
lines.append(" }")
|
||||
return "\n".join(lines)
|
||||
else:
|
||||
return f'"{value}"'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal data structure for template rendering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RenderableResource:
|
||||
"""Internal representation of a resource ready for template rendering."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
resource_type: str,
|
||||
tf_name: str,
|
||||
unique_id: str,
|
||||
rendered_attributes: list[tuple[str, str]],
|
||||
tags: dict[str, str],
|
||||
):
|
||||
self.resource_type = resource_type
|
||||
self.tf_name = tf_name
|
||||
self.unique_id = unique_id
|
||||
self.rendered_attributes = rendered_attributes
|
||||
self.tags = tags
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CodeGenerator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CodeGenerator:
|
||||
"""Generates Terraform HCL files from a dependency graph.
|
||||
|
||||
Accepts a DependencyGraph and list of ScanProfiles, produces one .tf file
|
||||
per resource type with traceability comments, architecture tags, and
|
||||
Terraform resource references for dependencies.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the code generator with Jinja2 environment."""
|
||||
self._env = Environment(
|
||||
loader=BaseLoader(),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
self._template = self._env.from_string(_RESOURCE_BLOCK_TEMPLATE_V2)
|
||||
|
||||
def generate(
|
||||
self, graph: DependencyGraph, profiles: list[ScanProfile]
|
||||
) -> CodeGenerationResult:
|
||||
"""Generate Terraform HCL from a dependency graph.
|
||||
|
||||
Args:
|
||||
graph: The DependencyGraph containing resources and relationships.
|
||||
profiles: List of ScanProfiles used during scanning.
|
||||
|
||||
Returns:
|
||||
CodeGenerationResult with resource_files, variables_file, and
|
||||
provider_file. Variables and provider files are empty placeholders
|
||||
(implemented in tasks 5.3 and 5.4).
|
||||
"""
|
||||
# Build lookup maps
|
||||
resource_map: dict[str, DiscoveredResource] = {
|
||||
r.unique_id: r for r in graph.resources
|
||||
}
|
||||
# Map from source_id -> list of (target_id, relationship)
|
||||
relationships_by_source: dict[str, list[ResourceRelationship]] = defaultdict(
|
||||
list
|
||||
)
|
||||
for rel in graph.relationships:
|
||||
relationships_by_source[rel.source_id].append(rel)
|
||||
|
||||
# Group resources by type
|
||||
resources_by_type: dict[str, list[DiscoveredResource]] = defaultdict(list)
|
||||
for resource in graph.resources:
|
||||
resources_by_type[resource.resource_type].append(resource)
|
||||
|
||||
# Generate one file per resource type
|
||||
resource_files: list[GeneratedFile] = []
|
||||
for resource_type, resources in sorted(resources_by_type.items()):
|
||||
renderable_resources = []
|
||||
for resource in resources:
|
||||
renderable = self._build_renderable(
|
||||
resource, relationships_by_source, resource_map
|
||||
)
|
||||
renderable_resources.append(renderable)
|
||||
|
||||
content = self._template.render(resources=renderable_resources)
|
||||
filename = f"{resource_type}.tf"
|
||||
resource_files.append(
|
||||
GeneratedFile(
|
||||
filename=filename,
|
||||
content=content,
|
||||
resource_count=len(resources),
|
||||
)
|
||||
)
|
||||
|
||||
# Placeholder files for tasks 5.3 and 5.4
|
||||
variables_file = GeneratedFile(
|
||||
filename="variables.tf",
|
||||
content="",
|
||||
resource_count=0,
|
||||
)
|
||||
provider_file = GeneratedFile(
|
||||
filename="providers.tf",
|
||||
content="",
|
||||
resource_count=0,
|
||||
)
|
||||
|
||||
return CodeGenerationResult(
|
||||
resource_files=resource_files,
|
||||
variables_file=variables_file,
|
||||
provider_file=provider_file,
|
||||
)
|
||||
|
||||
def _build_renderable(
|
||||
self,
|
||||
resource: DiscoveredResource,
|
||||
relationships_by_source: dict[str, list[ResourceRelationship]],
|
||||
resource_map: dict[str, DiscoveredResource],
|
||||
) -> _RenderableResource:
|
||||
"""Build a renderable resource with formatted attributes and references.
|
||||
|
||||
For attributes that reference other resources in the graph, replaces
|
||||
the hardcoded ID with a Terraform resource reference expression.
|
||||
"""
|
||||
tf_name = sanitize_identifier(resource.name)
|
||||
|
||||
# Build a set of target IDs this resource references
|
||||
target_ids_for_resource: dict[str, ResourceRelationship] = {}
|
||||
for rel in relationships_by_source.get(resource.unique_id, []):
|
||||
target_ids_for_resource[rel.target_id] = rel
|
||||
|
||||
# Render attributes, replacing references with Terraform expressions
|
||||
rendered_attributes: list[tuple[str, str]] = []
|
||||
for attr_key, attr_value in resource.attributes.items():
|
||||
resolved_value = self._resolve_attribute_value(
|
||||
attr_value, target_ids_for_resource, resource_map
|
||||
)
|
||||
rendered_attributes.append((attr_key, resolved_value))
|
||||
|
||||
# Generate architecture-specific tags
|
||||
tags = self._generate_architecture_tags(resource)
|
||||
|
||||
return _RenderableResource(
|
||||
resource_type=resource.resource_type,
|
||||
tf_name=tf_name,
|
||||
unique_id=resource.unique_id,
|
||||
rendered_attributes=rendered_attributes,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def _resolve_attribute_value(
|
||||
self,
|
||||
value: object,
|
||||
target_ids: dict[str, ResourceRelationship],
|
||||
resource_map: dict[str, DiscoveredResource],
|
||||
) -> str:
|
||||
"""Resolve an attribute value, replacing resource IDs with Terraform references.
|
||||
|
||||
If the value matches a target resource's unique_id or name, returns a
|
||||
Terraform resource reference expression. Otherwise formats as HCL literal.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
# Check if this string matches a target resource's unique_id
|
||||
if value in target_ids:
|
||||
target_resource = resource_map[value]
|
||||
return self._make_terraform_reference(target_resource)
|
||||
|
||||
# Check if this string matches a target resource's name
|
||||
for target_id, rel in target_ids.items():
|
||||
target_resource = resource_map[target_id]
|
||||
if value == target_resource.name:
|
||||
return self._make_terraform_reference(target_resource)
|
||||
|
||||
# Default: format as HCL literal
|
||||
return _format_hcl_value(value)
|
||||
|
||||
def _make_terraform_reference(self, target_resource: DiscoveredResource) -> str:
|
||||
"""Create a Terraform resource reference expression.
|
||||
|
||||
Example: kubernetes_namespace.default.id
|
||||
"""
|
||||
target_tf_name = sanitize_identifier(target_resource.name)
|
||||
return f"{target_resource.resource_type}.{target_tf_name}.id"
|
||||
|
||||
def _generate_architecture_tags(
|
||||
self, resource: DiscoveredResource
|
||||
) -> dict[str, str]:
|
||||
"""Generate architecture-specific tags/labels for a resource.
|
||||
|
||||
Returns a dict of tag key-value pairs including the CPU architecture.
|
||||
"""
|
||||
tags: dict[str, str] = {
|
||||
"arch": resource.architecture.value,
|
||||
"managed_by": "iac-reverse",
|
||||
}
|
||||
return tags
|
||||
197
src/iac_reverse/generator/provider_block.py
Normal file
197
src/iac_reverse/generator/provider_block.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Provider block generator for Terraform HCL output.
|
||||
|
||||
Generates a providers.tf file containing:
|
||||
- A terraform { required_providers { ... } } block listing all providers used
|
||||
- Individual provider configuration blocks with platform-specific settings
|
||||
(endpoints, certificates, credentials) for each distinct provider type.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from iac_reverse.models import ProviderType, ScanProfile, GeneratedFile
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider metadata: maps ProviderType to Terraform provider details
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Each entry: (terraform_provider_name, source, version_constraint)
|
||||
_PROVIDER_METADATA: dict[ProviderType, tuple[str, str, str]] = {
|
||||
ProviderType.KUBERNETES: (
|
||||
"kubernetes",
|
||||
"hashicorp/kubernetes",
|
||||
"~> 2.0",
|
||||
),
|
||||
ProviderType.DOCKER_SWARM: (
|
||||
"docker",
|
||||
"kreuzwerker/docker",
|
||||
"~> 3.0",
|
||||
),
|
||||
ProviderType.SYNOLOGY: (
|
||||
"synology",
|
||||
"synology-community/synology",
|
||||
"~> 0.2",
|
||||
),
|
||||
ProviderType.HARVESTER: (
|
||||
"harvester",
|
||||
"harvester/harvester",
|
||||
"~> 0.6",
|
||||
),
|
||||
ProviderType.BARE_METAL: (
|
||||
"redfish",
|
||||
"dell/redfish",
|
||||
"~> 1.0",
|
||||
),
|
||||
ProviderType.WINDOWS: (
|
||||
"windows",
|
||||
"hashicorp/windows",
|
||||
"~> 0.1",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _generate_provider_config(
|
||||
provider_type: ProviderType, profile: ScanProfile
|
||||
) -> str:
|
||||
"""Generate the provider configuration block for a given provider type.
|
||||
|
||||
Uses credentials and endpoints from the ScanProfile to populate
|
||||
platform-specific configuration attributes.
|
||||
"""
|
||||
tf_name = _PROVIDER_METADATA[provider_type][0]
|
||||
lines: list[str] = []
|
||||
lines.append(f'provider "{tf_name}" {{')
|
||||
|
||||
if provider_type == ProviderType.KUBERNETES:
|
||||
host = profile.credentials.get("host", "")
|
||||
cluster_ca = profile.credentials.get("cluster_ca_certificate", "")
|
||||
token = profile.credentials.get("token", "")
|
||||
lines.append(f' host = "{host}"')
|
||||
lines.append(f' cluster_ca_certificate = "{cluster_ca}"')
|
||||
lines.append(f' token = "{token}"')
|
||||
|
||||
elif provider_type == ProviderType.DOCKER_SWARM:
|
||||
host = profile.credentials.get("host", "")
|
||||
cert_path = profile.credentials.get("cert_path", "")
|
||||
lines.append(f' host = "{host}"')
|
||||
lines.append(f' cert_path = "{cert_path}"')
|
||||
|
||||
elif provider_type == ProviderType.SYNOLOGY:
|
||||
url = profile.credentials.get("url", "")
|
||||
username = profile.credentials.get("username", "")
|
||||
password = profile.credentials.get("password", "")
|
||||
lines.append(f' url = "{url}"')
|
||||
lines.append(f' username = "{username}"')
|
||||
lines.append(f' password = "{password}"')
|
||||
|
||||
elif provider_type == ProviderType.HARVESTER:
|
||||
kubeconfig = profile.credentials.get("kubeconfig", "")
|
||||
lines.append(f' kubeconfig = "{kubeconfig}"')
|
||||
|
||||
elif provider_type == ProviderType.BARE_METAL:
|
||||
endpoint = profile.credentials.get("endpoint", "")
|
||||
username = profile.credentials.get("username", "")
|
||||
password = profile.credentials.get("password", "")
|
||||
lines.append(f' endpoint = "{endpoint}"')
|
||||
lines.append(f' username = "{username}"')
|
||||
lines.append(f' password = "{password}"')
|
||||
|
||||
elif provider_type == ProviderType.WINDOWS:
|
||||
host = profile.credentials.get("host", "")
|
||||
username = profile.credentials.get("username", "")
|
||||
password = profile.credentials.get("password", "")
|
||||
lines.append(f' host = "{host}"')
|
||||
lines.append(f' username = "{username}"')
|
||||
lines.append(f' password = "{password}"')
|
||||
lines.append("")
|
||||
lines.append(" winrm {")
|
||||
winrm_port = profile.credentials.get("winrm_port", "5985")
|
||||
winrm_use_ssl = profile.credentials.get("winrm_use_ssl", "false")
|
||||
lines.append(f" port = {winrm_port}")
|
||||
lines.append(f" use_ssl = {winrm_use_ssl}")
|
||||
lines.append(" }")
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_required_providers_block(
|
||||
provider_types: set[ProviderType],
|
||||
) -> str:
|
||||
"""Generate the terraform { required_providers { ... } } block."""
|
||||
lines: list[str] = []
|
||||
lines.append("terraform {")
|
||||
lines.append(" required_providers {")
|
||||
|
||||
for provider_type in sorted(provider_types, key=lambda p: p.value):
|
||||
tf_name, source, version = _PROVIDER_METADATA[provider_type]
|
||||
lines.append(f" {tf_name} = {{")
|
||||
lines.append(f' source = "{source}"')
|
||||
lines.append(f' version = "{version}"')
|
||||
lines.append(" }")
|
||||
|
||||
lines.append(" }")
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProviderBlockGenerator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ProviderBlockGenerator:
|
||||
"""Generates Terraform provider configuration blocks.
|
||||
|
||||
Accepts a list of ScanProfiles and a set of ProviderTypes used in the
|
||||
generated code, and produces a providers.tf file containing:
|
||||
- A terraform { required_providers { ... } } block
|
||||
- Individual provider blocks with platform-specific configuration
|
||||
"""
|
||||
|
||||
def generate(
|
||||
self,
|
||||
profiles: list[ScanProfile],
|
||||
provider_types: set[ProviderType],
|
||||
) -> GeneratedFile:
|
||||
"""Generate the providers.tf file content.
|
||||
|
||||
Args:
|
||||
profiles: List of ScanProfiles providing credentials/endpoints.
|
||||
provider_types: Set of distinct ProviderTypes used in the code.
|
||||
|
||||
Returns:
|
||||
A GeneratedFile with filename "providers.tf" and the HCL content.
|
||||
"""
|
||||
# Build a map from ProviderType -> first matching profile
|
||||
profile_map: dict[ProviderType, ScanProfile] = {}
|
||||
for profile in profiles:
|
||||
if profile.provider not in profile_map:
|
||||
profile_map[profile.provider] = profile
|
||||
|
||||
sections: list[str] = []
|
||||
|
||||
# 1. required_providers block
|
||||
sections.append(_generate_required_providers_block(provider_types))
|
||||
|
||||
# 2. Individual provider configuration blocks
|
||||
for provider_type in sorted(provider_types, key=lambda p: p.value):
|
||||
profile = profile_map.get(provider_type)
|
||||
if profile is not None:
|
||||
sections.append(
|
||||
_generate_provider_config(provider_type, profile)
|
||||
)
|
||||
else:
|
||||
# Generate a placeholder block if no profile matches
|
||||
tf_name = _PROVIDER_METADATA[provider_type][0]
|
||||
sections.append(
|
||||
f'provider "{tf_name}" {{\n # No profile provided\n}}'
|
||||
)
|
||||
|
||||
content = "\n\n".join(sections) + "\n"
|
||||
|
||||
return GeneratedFile(
|
||||
filename="providers.tf",
|
||||
content=content,
|
||||
resource_count=0,
|
||||
)
|
||||
59
src/iac_reverse/generator/resource_merger.py
Normal file
59
src/iac_reverse/generator/resource_merger.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Multi-provider resource merging with conflict resolution.
|
||||
|
||||
Merges resources from multiple scan profiles into a unified inventory,
|
||||
resolving naming conflicts by prefixing with the provider identifier.
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
from collections import defaultdict
|
||||
|
||||
from iac_reverse.models import DiscoveredResource, ScanResult
|
||||
|
||||
|
||||
class ResourceMerger:
|
||||
"""Merges resources from multiple ScanResult objects into a unified list.
|
||||
|
||||
When resources from different providers share the same name, the merger
|
||||
resolves the conflict by prefixing each conflicting resource's name with
|
||||
its provider identifier (e.g., "kubernetes_nginx", "docker_swarm_nginx").
|
||||
|
||||
Provider-specific attributes are preserved unchanged.
|
||||
"""
|
||||
|
||||
def merge(self, scan_results: list[ScanResult]) -> list[DiscoveredResource]:
|
||||
"""Merge resources from multiple scan results into a unified list.
|
||||
|
||||
Args:
|
||||
scan_results: List of ScanResult objects, one per provider/scan profile.
|
||||
|
||||
Returns:
|
||||
A unified list of DiscoveredResource with naming conflicts resolved
|
||||
by prefixing conflicting names with the provider identifier.
|
||||
"""
|
||||
# Collect all resources from all scan results
|
||||
all_resources: list[DiscoveredResource] = []
|
||||
for result in scan_results:
|
||||
all_resources.extend(result.resources)
|
||||
|
||||
# Group resources by name to detect conflicts
|
||||
resources_by_name: dict[str, list[DiscoveredResource]] = defaultdict(list)
|
||||
for resource in all_resources:
|
||||
resources_by_name[resource.name].append(resource)
|
||||
|
||||
# Identify conflicting names: same name from different providers
|
||||
conflicting_names: set[str] = set()
|
||||
for name, resources in resources_by_name.items():
|
||||
providers = {r.provider for r in resources}
|
||||
if len(providers) > 1:
|
||||
conflicting_names.add(name)
|
||||
|
||||
# Build the merged list, resolving conflicts
|
||||
merged: list[DiscoveredResource] = []
|
||||
for resource in all_resources:
|
||||
if resource.name in conflicting_names:
|
||||
prefixed_name = f"{resource.provider.value}_{resource.name}"
|
||||
merged.append(replace(resource, name=prefixed_name))
|
||||
else:
|
||||
merged.append(resource)
|
||||
|
||||
return merged
|
||||
41
src/iac_reverse/generator/sanitize.py
Normal file
41
src/iac_reverse/generator/sanitize.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Identifier sanitization for Terraform resource names.
|
||||
|
||||
Converts arbitrary resource names into valid Terraform identifiers
|
||||
matching the pattern: ^[a-zA-Z_][a-zA-Z0-9_]*$
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def sanitize_identifier(name: str) -> str:
|
||||
"""Convert a resource name to a valid Terraform identifier.
|
||||
|
||||
Terraform identifiers must match: ^[a-zA-Z_][a-zA-Z0-9_]*$
|
||||
|
||||
Rules applied:
|
||||
- Replace any character not in [a-zA-Z0-9_] with underscore
|
||||
- Collapse multiple consecutive underscores into one
|
||||
- If result starts with a digit, prepend an underscore
|
||||
- If result is empty or only underscores, return "_resource"
|
||||
|
||||
Args:
|
||||
name: Any string resource name.
|
||||
|
||||
Returns:
|
||||
A valid Terraform identifier derived from the input.
|
||||
"""
|
||||
# Replace any non-alphanumeric/underscore character with underscore
|
||||
result = re.sub(r"[^a-zA-Z0-9_]", "_", name)
|
||||
|
||||
# Collapse multiple consecutive underscores into one
|
||||
result = re.sub(r"_+", "_", result)
|
||||
|
||||
# If result is only underscores or empty, return fallback
|
||||
if not result or result.strip("_") == "":
|
||||
return "_resource"
|
||||
|
||||
# If starts with a digit, prepend underscore
|
||||
if result[0].isdigit():
|
||||
result = "_" + result
|
||||
|
||||
return result
|
||||
203
src/iac_reverse/generator/variable_extractor.py
Normal file
203
src/iac_reverse/generator/variable_extractor.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Variable extraction logic for Terraform code generation.
|
||||
|
||||
Identifies attribute values that appear in 2+ resources and extracts them
|
||||
into Terraform variables with appropriate type expressions and defaults.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from iac_reverse.models import DiscoveredResource, ExtractedVariable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _infer_type_expr(value: object) -> str:
|
||||
"""Infer a Terraform type expression from a Python value.
|
||||
|
||||
Args:
|
||||
value: The Python value to infer a type for.
|
||||
|
||||
Returns:
|
||||
A Terraform type expression string (e.g., "string", "number", "bool").
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "bool"
|
||||
elif isinstance(value, int) or isinstance(value, float):
|
||||
return "number"
|
||||
elif isinstance(value, str):
|
||||
return "string"
|
||||
elif isinstance(value, list):
|
||||
return "list(string)"
|
||||
elif isinstance(value, dict):
|
||||
return "map(string)"
|
||||
else:
|
||||
return "string"
|
||||
|
||||
|
||||
def _format_default_value(value: object) -> str:
|
||||
"""Format a Python value as a Terraform default value literal.
|
||||
|
||||
Args:
|
||||
value: The Python value to format.
|
||||
|
||||
Returns:
|
||||
A string representation suitable for a Terraform variable default.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
elif isinstance(value, int) or isinstance(value, float):
|
||||
return str(value)
|
||||
elif isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
elif isinstance(value, list):
|
||||
items = ", ".join(f'"{item}"' if isinstance(item, str) else str(item) for item in value)
|
||||
return f"[{items}]"
|
||||
elif isinstance(value, dict):
|
||||
entries = ", ".join(f'"{k}" = "{v}"' for k, v in value.items())
|
||||
return "{" + entries + "}"
|
||||
else:
|
||||
return f'"{value}"'
|
||||
|
||||
|
||||
def _make_hashable(value: object) -> object:
|
||||
"""Convert a value to a hashable representation for counting.
|
||||
|
||||
Args:
|
||||
value: Any Python value from resource attributes.
|
||||
|
||||
Returns:
|
||||
A hashable version of the value.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return tuple(sorted(value.items()))
|
||||
elif isinstance(value, list):
|
||||
return tuple(value)
|
||||
else:
|
||||
return value
|
||||
|
||||
|
||||
class VariableExtractor:
|
||||
"""Extracts shared attribute values into Terraform variables.
|
||||
|
||||
Scans a list of DiscoveredResource objects, identifies attribute values
|
||||
that appear in 2 or more resources, and creates ExtractedVariable instances
|
||||
for each shared value.
|
||||
"""
|
||||
|
||||
def extract_variables(
|
||||
self, resources: list[DiscoveredResource]
|
||||
) -> list[ExtractedVariable]:
|
||||
"""Identify shared attribute values and extract them as variables.
|
||||
|
||||
For each attribute key, collects all values across all resources.
|
||||
If a value appears in 2+ resources for the same attribute key,
|
||||
it becomes a variable with the most common value as the default.
|
||||
|
||||
Args:
|
||||
resources: List of discovered resources to analyze.
|
||||
|
||||
Returns:
|
||||
List of ExtractedVariable instances for shared values.
|
||||
"""
|
||||
if len(resources) < 2:
|
||||
return []
|
||||
|
||||
# Collect attribute values grouped by attribute key
|
||||
# key -> {hashable_value -> [list of (resource_unique_id, original_value)]}
|
||||
attr_values: dict[str, dict[object, list[tuple[str, object]]]] = defaultdict(
|
||||
lambda: defaultdict(list)
|
||||
)
|
||||
|
||||
for resource in resources:
|
||||
for attr_key, attr_value in resource.attributes.items():
|
||||
# Skip complex nested structures (dicts/lists) for variable extraction
|
||||
# as they are less likely to be meaningfully shared
|
||||
if isinstance(attr_value, (dict, list)):
|
||||
continue
|
||||
hashable = _make_hashable(attr_value)
|
||||
attr_values[attr_key][hashable].append(
|
||||
(resource.unique_id, attr_value)
|
||||
)
|
||||
|
||||
# Build extracted variables for values appearing in 2+ resources
|
||||
variables: list[ExtractedVariable] = []
|
||||
|
||||
for attr_key, value_groups in sorted(attr_values.items()):
|
||||
# Find all values that appear in 2+ resources for this key
|
||||
shared_values = [
|
||||
(hv, entries)
|
||||
for hv, entries in value_groups.items()
|
||||
if len(entries) >= 2
|
||||
]
|
||||
|
||||
if not shared_values:
|
||||
continue
|
||||
|
||||
# If only one shared value exists for this key, use the key as the var name
|
||||
# If multiple shared values exist, disambiguate with a suffix
|
||||
for idx, (hashable_value, resource_entries) in enumerate(shared_values):
|
||||
original_value = resource_entries[0][1]
|
||||
used_by = [entry[0] for entry in resource_entries]
|
||||
|
||||
# Determine the most common value among the shared values for this key
|
||||
# The default is set to the most common value overall
|
||||
most_common_entries = max(shared_values, key=lambda x: len(x[1]))
|
||||
most_common_value = most_common_entries[1][0][1]
|
||||
|
||||
# Use the most common value as default for the primary variable,
|
||||
# but each variable's default is its own value
|
||||
default_value = _format_default_value(original_value)
|
||||
|
||||
if len(shared_values) == 1:
|
||||
var_name = f"var_{attr_key}"
|
||||
else:
|
||||
# Disambiguate when multiple shared values exist for same key
|
||||
var_name = f"var_{attr_key}_{idx}"
|
||||
|
||||
type_expr = _infer_type_expr(original_value)
|
||||
description = (
|
||||
f"Shared {attr_key} value extracted from "
|
||||
f"{len(resource_entries)} resources"
|
||||
)
|
||||
|
||||
variables.append(
|
||||
ExtractedVariable(
|
||||
name=var_name,
|
||||
type_expr=type_expr,
|
||||
default_value=default_value,
|
||||
description=description,
|
||||
used_by=used_by,
|
||||
)
|
||||
)
|
||||
|
||||
return variables
|
||||
|
||||
def generate_variables_tf(
|
||||
self, variables: list[ExtractedVariable]
|
||||
) -> str:
|
||||
"""Generate Terraform variables.tf file content.
|
||||
|
||||
Produces variable blocks with type, description, and default values.
|
||||
|
||||
Args:
|
||||
variables: List of extracted variables to render.
|
||||
|
||||
Returns:
|
||||
String content for a variables.tf file.
|
||||
"""
|
||||
if not variables:
|
||||
return ""
|
||||
|
||||
blocks: list[str] = []
|
||||
for var in variables:
|
||||
block = (
|
||||
f'variable "{var.name}" {{\n'
|
||||
f' type = {var.type_expr}\n'
|
||||
f' description = "{var.description}"\n'
|
||||
f' default = {var.default_value}\n'
|
||||
f'}}'
|
||||
)
|
||||
blocks.append(block)
|
||||
|
||||
return "\n\n".join(blocks) + "\n"
|
||||
Reference in New Issue
Block a user