Created IAC reverse generator

This commit is contained in:
p2913020
2026-05-22 00:19:30 -04:00
parent d04c2c6e4b
commit 1a11244fff
161 changed files with 26806 additions and 51 deletions

View 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",
]

View 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

View 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,
)

View 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

View 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

View 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"