feat: auto-split monolithic Terraform files and fix single-line HCL blocks

- Add 3-layer protection pipeline for model-generated Terraform code:
  1. Frontend auto-split: splits monolithic HCL into multiple files by resource type
  2. Backend auto-split: Python port as last defense before writing to disk
  3. Backend deduplication: removes monolithic leftovers with duplicate resources
- Fix single-line block expansion to handle ALL HCL blocks (nested included):
  ingress_security_rules, egress_security_rules, route_rules, match_criteria, etc.
- Add prompt size logging for terraform agent diagnostics
- Strengthen system prompt: minimum 3 files, final reminder section
This commit is contained in:
nogueiraguh
2026-03-09 00:40:48 -03:00
parent 15a8366f9c
commit 6fb2eecddf
2 changed files with 323 additions and 19 deletions

View File

@@ -1949,8 +1949,8 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
- Referencie o provider alias nos recursos: `provider = oci.secondary`
- NUNCA diga que não pode criar recursos em outra região. Use provider aliases para qualquer região OCI.
### Múltiplos Arquivos (PADRÃO)
- Por PADRÃO, SEMPRE gere o código em MÚLTIPLOS arquivos organizados por função:
### Múltiplos Arquivos (OBRIGATÓRIO)
- SEMPRE gere o código em MÚLTIPLOS arquivos organizados por função, NÃO IMPORTA o tamanho da infraestrutura:
- `variables.tf` — todas as variáveis (incluindo `variable "region"`)
- `networking.tf` — VCN, subnets, gateways, route tables, security lists
- `compute.tf` — instances, instance pools
@@ -1958,9 +1958,10 @@ TF_DEFAULT_SYSTEM_PROMPT = """Você é um agente especializado EXCLUSIVAMENTE em
- `firewall.tf` — network firewalls, policies
- `outputs.tf` — outputs úteis
- Outros arquivos conforme necessário (ex: `storage.tf`, `iam.tf`, `loadbalancer.tf`)
- MÍNIMO OBRIGATÓRIO: gere pelo menos `variables.tf`, um ou mais arquivos de recursos, e `outputs.tf` (mínimo 3 arquivos).
- Nomeie cada bloco com um comentário na primeira linha: `// filename: networking.tf`, `// filename: compute.tf`, etc.
- Cada bloco `hcl` vira um arquivo separado no sistema.
- Somente gere tudo em um único bloco se o usuário pedir EXPLICITAMENTE "um único arquivo" ou a infraestrutura for muito simples (1-2 recursos).
- NUNCA gere tudo em um único bloco ou em apenas 2 arquivos. Somente gere arquivo único se o usuário pedir EXPLICITAMENTE "um único arquivo".
### REGRA CRÍTICA: Unicidade de Recursos
- **NUNCA** declare o mesmo recurso (mesmo tipo + mesmo nome) em mais de um arquivo.
@@ -2091,6 +2092,11 @@ Antes de entregar qualquer código (novo ou corrigido), valide TODOS os itens ab
```
O erro "Invalid single-argument block definition" ocorre quando HCL encontra `bloco { a = 1, b = 2 }` com mais de um argumento. SEMPRE usar formato multi-line.
Isso vale para TODOS os blocos: `ingress_security_rules`, `egress_security_rules`, `route_rules`, `match_criteria`, `tcp_options`, `udp_options`, etc.
### LEMBRETE FINAL (MAIS IMPORTANTE)
- Para NOVAS gerações: SEMPRE gere MÚLTIPLOS arquivos (mínimo 3: variables.tf + recursos + outputs.tf). NUNCA entregue apenas 1 ou 2 arquivos.
- Para CORREÇÕES (quando há "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE"): gere SOMENTE os arquivos que mudaram.
- Se a mensagem do usuário NÃO contém "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE", trate como NOVA geração e gere TODOS os arquivos necessários.
"""
# ── Terraform OCI Resource Reference (generated at build time, updatable at runtime) ──
@@ -3267,6 +3273,12 @@ async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_c
ref_compact
log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)")
# Log total prompt sizes for debugging
if agent_type == "terraform":
sp_len = len(cfg_dict.get("system_prompt", ""))
msg_len = len(msg.message) if hasattr(msg, 'message') else 0
log.info(f"Terraform prompt sizes: system_prompt={sp_len} chars (~{sp_len//4} tokens), user_msg={msg_len} chars (~{msg_len//4} tokens), max_tokens={cfg_dict.get('max_tokens')}")
mcp_tools = []
tool_defs = None
if msg.use_tools:
@@ -3844,22 +3856,178 @@ async def tf_refresh_reference(u=Depends(current_user)):
return result
def _fix_single_line_blocks(content: str) -> str:
"""Expand ANY single-line HCL block with multiple args into multi-line format.
Matches both top-level (variable, resource) AND nested blocks (ingress_security_rules, route_rules, etc.)
Does NOT match map assignments (tags = { ... }) because of the '=' before '{'."""
import re as _re
def _expand(m):
header, body = m.group(1), m.group(2)
# Split by comma first
args = [a.strip() for a in body.split(',') if a.strip()]
if len(args) < 2:
# Try space-separated key=value
args = _re.findall(r'\w+\s*=\s*(?:"[^"]*"|\S+)', body)
if len(args) < 2:
return m.group(0)
indent = _re.match(r'^(\s*)', header).group(1)
return header.rstrip() + ' {\n' + '\n'.join(indent + ' ' + a for a in args) + '\n' + indent + '}'
return _re.sub(
r'^(\s*[a-z]\w*(?:\s+"[^"]*")*)\s*\{\s*(.+?)\s*\}\s*$',
_expand, content, flags=_re.MULTILINE)
def _split_tf_monolith(content: str) -> dict:
"""Split a monolithic HCL string into multiple logical files by resource type.
Returns dict of {filename: content} or None if splitting not needed."""
import re as _re
content = _fix_single_line_blocks(content)
lines = content.split('\n')
top_blocks = []
preamble = []
accum = []
in_block = False
b_depth = 0
cur_header = ''
for line in lines:
stripped = _re.sub(r'"[^"]*"', '', line)
opens = stripped.count('{')
closes = stripped.count('}')
if not in_block:
hdr = _re.match(r'^\s*(variable|output|resource|data|provider|module)\s+"', line) or \
_re.match(r'^\s*(locals|terraform)\s*\{', line)
if hdr:
in_block = True
cur_header = line
accum = preamble + [line]
preamble = []
b_depth = opens - closes
if b_depth <= 0:
b_depth = 0
if b_depth == 0 and opens > 0:
top_blocks.append((cur_header, '\n'.join(accum)))
in_block = False
accum = []
else:
preamble.append(line)
else:
accum.append(line)
b_depth += opens - closes
if b_depth <= 0:
b_depth = 0
top_blocks.append((cur_header, '\n'.join(accum)))
in_block = False
accum = []
cur_header = ''
if accum:
top_blocks.append((cur_header or '', '\n'.join(accum)))
if len(top_blocks) < 3:
return None
cats = {'variables': [], 'outputs': [], 'networking': [], 'compute': [], 'database': [],
'firewall': [], 'loadbalancer': [], 'storage': [], 'iam': [], 'drg': [],
'data': [], 'providers': [], 'other': []}
for header, body in top_blocks:
h = header.strip()
if h.startswith('variable '):
cats['variables'].append(body)
elif h.startswith('output '):
cats['outputs'].append(body)
elif h.startswith('provider '):
cats['providers'].append(body)
elif _re.search(r'resource\s+"oci_core_(drg|remote_peering|drg_route|drg_attachment)', h):
cats['drg'].append(body)
elif _re.search(r'resource\s+"oci_core_(vcn|subnet|internet_gateway|nat_gateway|service_gateway|route_table|security_list|network_security_group|dhcp_options|local_peering_gateway|virtual_circuit)', h):
cats['networking'].append(body)
elif _re.search(r'resource\s+"oci_core_instance', h):
cats['compute'].append(body)
elif _re.search(r'resource\s+"oci_(database|nosql|mysql|psql)', h) or _re.search(r'resource\s+"oci_core_autonomous', h):
cats['database'].append(body)
elif _re.search(r'resource\s+"oci_network_firewall', h):
cats['firewall'].append(body)
elif _re.search(r'resource\s+"oci_(load_balancer|network_load_balancer)', h):
cats['loadbalancer'].append(body)
elif _re.search(r'resource\s+"oci_(objectstorage|file_storage)', h):
cats['storage'].append(body)
elif _re.search(r'resource\s+"oci_identity', h):
cats['iam'].append(body)
elif h.startswith('data '):
cats['data'].append(body)
else:
cats['other'].append(body)
result = {}
mapping = [('variables.tf', 'variables'), ('providers.tf', 'providers'), ('networking.tf', 'networking'),
('drg.tf', 'drg'), ('compute.tf', 'compute'), ('database.tf', 'database'),
('firewall.tf', 'firewall'), ('loadbalancer.tf', 'loadbalancer'), ('storage.tf', 'storage'),
('iam.tf', 'iam'), ('data.tf', 'data'), ('main.tf', 'other'), ('outputs.tf', 'outputs')]
for fname, key in mapping:
if cats[key]:
result[fname] = '\n\n'.join(cats[key])
return result if len(result) >= 3 else None
def _write_tf_files(wdir: Path, tf_code: str):
"""Parse tf_code for '// filename: xxx.tf' markers and write separate files."""
"""Parse tf_code for '// filename: xxx.tf' markers and write separate files.
If only 1-2 large files, auto-split into multiple files by resource type."""
import re as _re
parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', tf_code, flags=_re.MULTILINE)
files = {}
if len(parts) >= 3:
# parts = [preamble, filename1, content1, filename2, content2, ...]
if parts[0].strip():
(wdir / "main.tf").write_text(parts[0].strip())
files['main.tf'] = parts[0].strip()
for i in range(1, len(parts), 2):
fname = parts[i].strip()
fname = parts[i].strip().replace("/", "_").replace("..", "_")
content = parts[i + 1].strip() if i + 1 < len(parts) else ""
if fname and content:
safe_name = fname.replace("/", "_").replace("..", "_")
(wdir / safe_name).write_text(content)
files[fname] = content
else:
(wdir / "main.tf").write_text(tf_code)
files['main.tf'] = tf_code
# Auto-split: if 1-2 large files, split by resource type
if len(files) <= 2:
all_content = '\n\n'.join(files.values())
if len(all_content) > 2000:
split = _split_tf_monolith(all_content)
if split:
files = split
log.info(f"Backend auto-split monolithic TF into {len(files)} files: {list(files.keys())}")
# Deduplicate: if a resource appears in multiple files, keep only in the split file
# This handles edge cases where monolithic + split files both exist
if len(files) > 2:
resource_locations = {} # "type.name" -> [(filename, line)]
dupes_in = set()
for fname, content in files.items():
for m in _re.finditer(r'^resource\s+"(\S+)"\s+"(\S+)"', content, _re.MULTILINE):
key = f'{m.group(1)}.{m.group(2)}'
resource_locations.setdefault(key, []).append(fname)
# Find files that contain ONLY duplicate resources (= monolithic leftovers)
for key, fnames in resource_locations.items():
if len(fnames) > 1:
# The largest file is likely the monolithic one
largest = max(fnames, key=lambda f: len(files.get(f, '')))
dupes_in.add(largest)
# Remove monolithic files that are fully duplicated
for fname in dupes_in:
if all(
any(f2 != fname and f2 in [fl for fl in resource_locations.get(key, []) if fl != fname])
for key, flist in resource_locations.items()
if fname in flist
) and len(files) - 1 >= 2:
log.info(f"Removing duplicate monolithic file: {fname}")
del files[fname]
# Fix single-line blocks in all files
for fname in files:
files[fname] = _fix_single_line_blocks(files[fname])
# Write files to disk
for fname, content in files.items():
(wdir / fname).write_text(content)
async def _terraform_exec(wid: str, action: str, user: dict):