"""Terraform execution — split monolith, write files, plan/apply/destroy.""" import os, json, uuid, asyncio, subprocess, re from pathlib import Path from datetime import datetime from config import TERRAFORM_DIR, OCI_DIR, log from database import db from auth.crypto import _safe_dec from auth.jwt_auth import _audit, _verify_config_access 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. 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: if parts[0].strip(): files['main.tf'] = parts[0].strip() for i in range(1, len(parts), 2): fname = parts[i].strip().replace("/", "_").replace("..", "_") content = parts[i + 1].strip() if i + 1 < len(parts) else "" if fname and content: files[fname] = content else: 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 for f2 in resource_locations.get(key, [])) 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): """Background: run terraform init + plan/apply/destroy in workspace dir.""" log.info(f"Terraform {action} started: wid={wid}") status_col = f"{action}_output" final_ok = {"plan": "planned", "apply": "applied", "destroy": "destroyed"}[action] try: with db() as c: ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=?", (wid,)).fetchone() if not ws: return wdir = TERRAFORM_DIR / wid wdir.mkdir(parents=True, exist_ok=True) # Clean old .tf files (keep .terraform/, state, lock) for old_tf in wdir.glob("*.tf"): old_tf.unlink() # Write .tf files — parse // filename: comments to split into separate files _write_tf_files(wdir, ws["tf_code"] or "") # Auto-generate provider.tf from OCI config # Detect provider aliases declared by the model in generated files import re as _re2 oci_cfg = _get_oci_config(ws["oci_config_id"]) alias_blocks = [] # list of (alias_name, region_ref) # Scan all .tf files for provider "oci" { alias = "..." ... region = ... } provider_block_re = _re2.compile(r'provider\s+"oci"\s*\{', _re2.MULTILINE) for tf_file in sorted(wdir.glob("*.tf")): if tf_file.name == "provider.tf": continue content = tf_file.read_text() # Find each provider "oci" block and extract alias + region blocks_to_remove = [] for m in provider_block_re.finditer(content): start = m.start() # Find matching closing brace depth = 0 end = start for ci in range(m.end() - 1, len(content)): if content[ci] == '{': depth += 1 elif content[ci] == '}': depth -= 1 if depth == 0: end = ci + 1 break block = content[start:end] alias_m = _re2.search(r'alias\s*=\s*"([^"]+)"', block) region_m = _re2.search(r'region\s*=\s*(.+)', block) if alias_m: alias_name = alias_m.group(1) region_ref = region_m.group(1).strip().rstrip("}").strip() if region_m else '"unknown"' alias_blocks.append((alias_name, region_ref)) blocks_to_remove.append((start, end)) # Remove model-generated provider blocks (we centralize in provider.tf) if blocks_to_remove: new_content = content for s, e in reversed(blocks_to_remove): new_content = new_content[:s] + new_content[e:] # Also remove leading comments right before removed blocks new_content = _re2.sub(r'\n(//[^\n]*\n){1,5}\s*\n', '\n\n', new_content) new_content = new_content.strip() if new_content: tf_file.write_text(new_content + "\n") else: tf_file.unlink() # Remove empty file # Scan all .tf files for variable declarations with region-like defaults # so we can map alias providers to the correct variable reference var_region_map = {} # variable_name -> default_value var_re = _re2.compile(r'variable\s+"([^"]*region[^"]*)"\s*\{', _re2.IGNORECASE) for tf_file in sorted(wdir.glob("*.tf")): if tf_file.name in ("provider.tf", "terraform.tfvars"): continue content = tf_file.read_text() for vm in var_re.finditer(content): var_region_map[vm.group(1)] = f'var.{vm.group(1)}' # Check if variable "region" is declared — use it for primary provider has_region_var = "region" in var_region_map # Also scan for provider refs in resource blocks (provider = oci.xxx) for tf_file in sorted(wdir.glob("*.tf")): if tf_file.name == "provider.tf": continue content = tf_file.read_text() for ref_m in _re2.finditer(r'provider\s*=\s*oci\.(\w+)', content): ref_alias = ref_m.group(1) if ref_alias not in [a[0] for a in alias_blocks]: # Try to find a matching region variable for this alias # e.g. alias "mad_3" might match var.region_secondary region_ref = 'var.region' # fallback to primary region var for vname, vref in var_region_map.items(): if vname != "region": # prefer non-primary region vars for aliases region_ref = vref break alias_blocks.append((ref_alias, region_ref)) passphrase = oci_cfg.get('pass_phrase', '') cred_block = f''' tenancy_ocid = "{oci_cfg.get('tenancy', '')}" user_ocid = "{oci_cfg.get('user', '')}" fingerprint = "{oci_cfg.get('fingerprint', '')}" private_key_path = "{oci_cfg.get('key_file', '')}"''' if passphrase: cred_block += f'\n private_key_password = "{passphrase}"' # Primary region: MUST use var.region — model is required to declare it with db() as c: oci_row = c.execute("SELECT region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone() primary_region = oci_row["region"] if oci_row else oci_cfg.get("region", "") if not has_region_var: error_msg = ( f'ERRO: O código Terraform não declarou variable "region". ' f'Isso é obrigatório para garantir que os recursos sejam provisionados na região correta. ' f'Adicione no variables.tf: variable "region" {{ type = string; default = "{primary_region}" }}' ) log.warning(f"Workspace {wid}: missing variable 'region' — blocking plan") with db() as c: c.execute("UPDATE terraform_workspaces SET status='failed', plan_output=?, error=?, updated_at=datetime('now') WHERE id=?", (error_msg, error_msg, wid)) return region_value = 'var.region' provider_tf = f'''terraform {{ required_providers {{ oci = {{ source = "oracle/oci" }} }} }} provider "oci" {{ {cred_block} region = {region_value} }} ''' # Add alias providers with same credentials seen_aliases = set() for alias_name, region_ref in alias_blocks: if alias_name in seen_aliases: continue seen_aliases.add(alias_name) provider_tf += f''' provider "oci" {{ alias = "{alias_name}" {cred_block} region = {region_ref} }} ''' (wdir / "provider.tf").write_text(provider_tf) # Auto-generate terraform.tfvars — scan declared variables and provide values from OCI config with db() as c: oci_row = c.execute("SELECT compartment_id, region, ssh_public_key FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone() comp_id = ws["compartment_id"] if ws["compartment_id"] else (_safe_dec(oci_row["compartment_id"]) if oci_row and oci_row["compartment_id"] else "") try: ssh_pub_key = oci_row["ssh_public_key"] if oci_row else "" except (IndexError, KeyError): ssh_pub_key = "" ssh_pub_key = ssh_pub_key or "" # Scan all declared variables in .tf files declared_vars = set() for tf_file in sorted(wdir.glob("*.tf")): if tf_file.name in ("provider.tf", "terraform.tfvars"): continue content = tf_file.read_text() for vm in _re2.finditer(r'variable\s+"([^"]+)"', content): declared_vars.add(vm.group(1)) # Map known variable names to OCI config values var_values = {"compartment_id": comp_id} oci_var_map = { "tenancy_ocid": oci_cfg.get("tenancy", ""), "tenancy_id": oci_cfg.get("tenancy", ""), "user_ocid": oci_cfg.get("user", ""), "fingerprint": oci_cfg.get("fingerprint", ""), "private_key_path": oci_cfg.get("key_file", ""), "ssh_public_key": ssh_pub_key, "ssh_authorized_keys": ssh_pub_key, } # NOTE: "region" is intentionally NOT included — Terraform uses the default from variable declarations for vname, vval in oci_var_map.items(): if vname in declared_vars and vval: var_values[vname] = vval tfvars_lines = [f'{k} = "{v}"' for k, v in var_values.items()] (wdir / "terraform.tfvars").write_text("\n".join(tfvars_lines) + "\n") output_lines = [] def _update_output(text): output_lines.append(text) with db() as c: c.execute(f"UPDATE terraform_workspaces SET {status_col}=?, updated_at=datetime('now') WHERE id=?", ("\n".join(output_lines[-200:]), wid)) # terraform init _update_output("$ terraform init ...") proc_init = await asyncio.create_subprocess_exec( "terraform", f"-chdir={wdir}", "init", "-no-color", "-input=false", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT) while True: line = await proc_init.stdout.readline() if not line: break _update_output(line.decode(errors="replace").rstrip()) await proc_init.wait() if proc_init.returncode != 0: _update_output(f"\n❌ terraform init failed (exit {proc_init.returncode})") with db() as c: c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?", ("terraform init failed", wid)) return # terraform action _update_output(f"\n$ terraform {action} ...") cmd = ["terraform", f"-chdir={wdir}", action, "-no-color", "-input=false"] if action in ("apply", "destroy"): cmd.append("-auto-approve") proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT) _running_terraform[wid] = proc while True: line = await proc.stdout.readline() if not line: break _update_output(line.decode(errors="replace").rstrip()) await proc.wait() _running_terraform.pop(wid, None) if proc.returncode == 0: output_lines.append(f"\n✅ terraform {action} completed successfully") full_output = "\n".join(output_lines) with db() as c: c.execute(f"UPDATE terraform_workspaces SET {status_col}=?, status=?, updated_at=datetime('now') WHERE id=?", (full_output, final_ok, wid)) _audit(user["id"], user["username"], f"terraform_{action}", wid, f"status={final_ok}") else: output_lines.append(f"\n❌ terraform {action} failed (exit {proc.returncode})") full_output = "\n".join(output_lines) with db() as c: c.execute(f"UPDATE terraform_workspaces SET {status_col}=?, status='failed', error=?, updated_at=datetime('now') WHERE id=?", (full_output, f"terraform {action} failed (exit {proc.returncode})", wid)) except Exception as e: log.error(f"Terraform exec error: {e}") with db() as c: c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?", (str(e)[:500], wid)) # ── Audit ─────────────────────────────────────────────────────────────────────