From 6fb2eecddf9f306c786551d0aa820ad9b85d887f Mon Sep 17 00:00:00 2001 From: nogueiraguh Date: Mon, 9 Mar 2026 00:40:48 -0300 Subject: [PATCH] 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 --- backend/app.py | 188 +++++++++++++++++++++++++++++++++++++++++--- frontend/index.html | 154 +++++++++++++++++++++++++++++++++--- 2 files changed, 323 insertions(+), 19 deletions(-) diff --git a/backend/app.py b/backend/app.py index 6cd88b8..ef7b62d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -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): diff --git a/frontend/index.html b/frontend/index.html index 51cd43d..d0d28fd 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -714,11 +714,125 @@ async function loadChatLogs(){ const TF_ICON=``; function escHtml(s){return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} +function _fixSingleLineBlocks(content){ + // 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 "{" + // Pattern: identifier ["quoted"]* { key=val, key=val } (no "=" between identifier and "{") + return content.replace(/^(\s*[a-z]\w*(?:\s+"[^"]*")*)\s*\{\s*(.+?)\s*\}\s*$/gm, (match, header, body) => { + // Skip if body doesn't have multiple key=value pairs + let args = body.split(/,\s*/); + if(args.length < 2){ + args = []; + const re = /(\w+)\s*=\s*(?:"[^"]*"|\S+)/g; + let m2; + while((m2 = re.exec(body)) !== null) args.push(m2[0]); + } + if(args.length < 2) return match; + const indent = header.match(/^(\s*)/)[1]; + return header.trimEnd() + ' {\n' + args.map(a => indent + ' ' + a.trim()).join('\n') + '\n' + indent + '}'; + }); +} + +function _splitTfMonolith(content){ + // Split a monolithic HCL file into multiple logical files by parsing top-level blocks. + // Returns array of {name, content} or null if splitting not needed. + content = _fixSingleLineBlocks(content); + const lines=content.split('\n'); + const topBlocks=[]; + let preamble=[]; // comments/blanks before next block + let accum=[]; + let inBlock=false; + let bDepth=0; + let curHeader=''; + + for(let i=0;i0){ + topBlocks.push({header:curHeader,body:accum.join('\n')}); + inBlock=false; accum=[]; + } + } else { + // Comment, blank, or stray line between blocks — save as preamble for next block + preamble.push(line); + } + } else { + accum.push(line); + bDepth+=open-close; + if(bDepth<=0){ + bDepth=0; + topBlocks.push({header:curHeader,body:accum.join('\n')}); + inBlock=false; accum=[]; curHeader=''; + } + } + } + // Flush remaining + if(accum.length) topBlocks.push({header:curHeader||'',body:accum.join('\n')}); + + if(topBlocks.length<3) return null; + + // Categorize each block by its header + const cats={variables:[],outputs:[],networking:[],compute:[],database:[], + firewall:[],loadbalancer:[],storage:[],iam:[],drg:[],data:[],providers:[],other:[]}; + for(const b of topBlocks){ + const h=b.header.trim(); + if(h.startsWith('variable ')) cats.variables.push(b.body); + else if(h.startsWith('output ')) cats.outputs.push(b.body); + else if(h.startsWith('provider ')) cats.providers.push(b.body); + else if(/resource\s+"oci_core_(drg|remote_peering|drg_route|drg_attachment)/.test(h)) cats.drg.push(b.body); + else if(/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)/.test(h)) cats.networking.push(b.body); + else if(/resource\s+"oci_core_instance/.test(h)) cats.compute.push(b.body); + else if(/resource\s+"oci_(database|nosql|mysql|psql)/.test(h) || /resource\s+"oci_core_autonomous/.test(h)) cats.database.push(b.body); + else if(/resource\s+"oci_network_firewall/.test(h)) cats.firewall.push(b.body); + else if(/resource\s+"oci_(load_balancer|network_load_balancer)/.test(h)) cats.loadbalancer.push(b.body); + else if(/resource\s+"oci_(objectstorage|file_storage)/.test(h)) cats.storage.push(b.body); + else if(/resource\s+"oci_identity/.test(h)) cats.iam.push(b.body); + else if(h.startsWith('data ')) cats.data.push(b.body); + else cats.other.push(b.body); + } + + // Build result files in logical order + const result=[]; + const add=(name,arr)=>{if(arr.length) result.push({name,content:arr.join('\n\n')})}; + add('variables.tf', cats.variables); + add('providers.tf', cats.providers); + add('networking.tf', cats.networking); + add('drg.tf', cats.drg); + add('compute.tf', cats.compute); + add('database.tf', cats.database); + add('firewall.tf', cats.firewall); + add('loadbalancer.tf', cats.loadbalancer); + add('storage.tf', cats.storage); + add('iam.tf', cats.iam); + add('data.tf', cats.data); + add('main.tf', cats.other); + add('outputs.tf', cats.outputs); + + return result.length>=3 ? result : null; +} + function fmTf(t){ // Extract ALL hcl blocks, parse // filename: comments, build separate files const codeBlocks=[]; let r=t.replace(/```(?:hcl|terraform)\s*\n([\s\S]*?)```/g,(m,code)=>{ - const trimmed=code.trim(); + const trimmed=_fixSingleLineBlocks(code.trim()); // Check for // filename: comment on first line const fnMatch=trimmed.match(/^\/\/\s*filename:\s*(\S+)/); const fileName=fnMatch?fnMatch[1]:'main.tf'; @@ -736,30 +850,52 @@ function fmTf(t){ return '
'+TF_ICON+' Resource Plan — '+S.tfPlan.length+' resource(s)
'+ S.tfPlan.map(x=>'
+'+escHtml(x.type)+''+escHtml(x.desc)+'
').join('')+'
'; }); - // Build files list — merge new blocks with existing files (corrections preserve untouched files) + // Auto-split: if model returned 1-2 large blocks, split into multiple files + // Runs on BOTH first generation and corrections — monolithic responses always get split + let didAutoSplit=false; + if(codeBlocks.length<=2){ + const totalContent=codeBlocks.map(b=>b.content).join('\n\n'); + if(totalContent.length>2000){ + const split=_splitTfMonolith(totalContent); + if(split){ + codeBlocks.length=0; + split.forEach(f=>codeBlocks.push(f)); + didAutoSplit=true; + // Rebuild HTML with split files + const splitHtml=split.map((f,i)=> + '
'+TF_ICON+' '+escHtml(f.name)+''+ + '
'+ + '
'+escHtml(f.content)+'
' + ).join(''); + // Replace the original single code block HTML + r=r.replace(/
[\s\S]*?<\/pre><\/div>/g,''); + r=splitHtml+r; + console.log('[TF] Auto-split monolithic file into '+split.length+' files'); + } + } + } + // Build files list if(codeBlocks.length){ const newMap={}; codeBlocks.forEach(b=>{ if(newMap[b.name])newMap[b.name]+='\n\n'+b.content; else newMap[b.name]=b.content; }); - // If existing files: merge (update matched, keep unmatched) - if(S.tfFiles.length){ + // If auto-split happened, REPLACE all files (monolithic = full regeneration, not partial correction) + // If model returned multiple named files (no auto-split), use merge logic for corrections + if(didAutoSplit || !S.tfFiles.length){ + S.tfFiles=Object.entries(newMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'})); + }else{ const merged=[]; const usedNew=new Set(); - // Update existing files with new content if name matches for(const f of S.tfFiles){ if(newMap[f.name]!=null){merged.push({name:f.name,content:newMap[f.name],size:newMap[f.name].length,type:'hcl'});usedNew.add(f.name)} else{merged.push(f)} } - // Add any truly new files for(const[name,content] of Object.entries(newMap)){ if(!usedNew.has(name))merged.push({name,content,size:content.length,type:'hcl'}) } S.tfFiles=merged; - }else{ - // First generation: set all - S.tfFiles=Object.entries(newMap).map(([name,content])=>({name,content,size:content.length,type:'hcl'})); } S.tfCode=S.tfFiles.map(f=>'// filename: '+f.name+'\n'+f.content).join('\n\n'); }