"""Terraform Agent routes — OCI resource actions, workspace CRUD, plan/apply/destroy.""" import uuid import asyncio import shutil import re from pathlib import Path from datetime import datetime from functools import partial from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks, Request from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.responses import Response from config import TERRAFORM_DIR, _chat_executor, log from database import db from auth.jwt_auth import current_user, _user_from_token, _audit, _verify_config_access from auth.crypto import _safe_dec from services.oci_helpers import _get_oci_config from services.terraform_exec import _split_tf_monolith, _write_tf_files, _terraform_exec from services.genai import ( _call_genai, _TF_RESOURCE_REF_PATH, _load_tf_resource_reference, _regenerate_tf_reference, _get_tf_docs_for_resources, _detect_tf_resource_types, ) from services.chat_background import _chat_start, _chat_background from utils import running_terraform from models import ChatMsg, TfPromptReq, TfWorkspaceReq router = APIRouter() # ── Helpers ────────────────────────────────────────────────────────────────── def _fetch_compartment_resources(oci_config_id: str, compartment_id: str, region: str = None) -> dict: """Fetch existing OCI resources in a compartment for terraform context.""" import oci config = _get_oci_config(oci_config_id) if region: config["region"] = region resources = {} try: vn = oci.core.VirtualNetworkClient(config) vcns = vn.list_vcns(compartment_id).data resources["vcns"] = [{"id": v.id, "display_name": v.display_name, "cidr_blocks": v.cidr_blocks, "state": v.lifecycle_state} for v in vcns if v.lifecycle_state == "AVAILABLE"] subs = vn.list_subnets(compartment_id).data resources["subnets"] = [{"id": s.id, "display_name": s.display_name, "cidr_block": s.cidr_block, "vcn_id": s.vcn_id, "public": not s.prohibit_public_ip_on_vnic, "state": s.lifecycle_state} for s in subs if s.lifecycle_state == "AVAILABLE"] igws = vn.list_internet_gateways(compartment_id).data resources["internet_gateways"] = [{"id": g.id, "display_name": g.display_name, "vcn_id": g.vcn_id, "enabled": g.is_enabled} for g in igws if g.lifecycle_state == "AVAILABLE"] ngws = vn.list_nat_gateways(compartment_id).data resources["nat_gateways"] = [{"id": g.id, "display_name": g.display_name, "vcn_id": g.vcn_id} for g in ngws if g.lifecycle_state == "AVAILABLE"] rts = vn.list_route_tables(compartment_id).data resources["route_tables"] = [{"id": r.id, "display_name": r.display_name, "vcn_id": r.vcn_id} for r in rts if r.lifecycle_state == "AVAILABLE"] sls = vn.list_security_lists(compartment_id).data resources["security_lists"] = [{"id": s.id, "display_name": s.display_name, "vcn_id": s.vcn_id} for s in sls if s.lifecycle_state == "AVAILABLE"] except Exception as e: log.warning(f"Failed to fetch networking resources: {e}") try: compute = oci.core.ComputeClient(config) insts = compute.list_instances(compartment_id).data resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state in ("RUNNING", "STOPPED")] except Exception as e: log.warning(f"Failed to fetch compute resources: {e}") try: db_client = oci.database.DatabaseClient(config) adbs = db_client.list_autonomous_databases(compartment_id).data resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state in ("AVAILABLE", "STOPPED")] except Exception as e: log.warning(f"Failed to fetch database resources: {e}") try: bs = oci.core.BlockstorageClient(config) vols = bs.list_volumes(compartment_id).data resources["block_volumes"] = [{"id": v.id, "display_name": v.display_name, "size_in_gbs": v.size_in_gbs, "state": v.lifecycle_state} for v in vols if v.lifecycle_state == "AVAILABLE"] except Exception as e: log.warning(f"Failed to fetch block storage resources: {e}") try: lb_client = oci.load_balancer.LoadBalancerClient(config) lbs = lb_client.list_load_balancers(compartment_id).data resources["load_balancers"] = [{"id": l.id, "display_name": l.display_name, "shape_name": l.shape_name, "state": l.lifecycle_state} for l in lbs if l.lifecycle_state == "ACTIVE"] except Exception as e: log.warning(f"Failed to fetch load balancer resources: {e}") return resources def _build_resource_context(resources: dict) -> str: """Build a text summary of existing resources for injection into system prompt.""" lines = ["## RECURSOS OCI EXISTENTES NO COMPARTMENT"] total = 0 resource_labels = { "vcns": ("VCNs", lambda r: f" - {r['display_name']} (CIDR: {','.join(r.get('cidr_blocks') or [])}) [OCID: {r['id'][:30]}...]"), "subnets": ("Subnets", lambda r: f" - {r['display_name']} (CIDR: {r['cidr_block']}, {'pública' if r.get('public') else 'privada'}, VCN: {r['vcn_id'][:30]}...)"), "internet_gateways": ("Internet Gateways", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"), "nat_gateways": ("NAT Gateways", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"), "route_tables": ("Route Tables", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"), "security_lists": ("Security Lists", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"), "instances": ("Compute Instances", lambda r: f" - {r['display_name']} (Shape: {r['shape']}, Estado: {r['state']})"), "autonomous_databases": ("Autonomous Databases", lambda r: f" - {r['display_name']} (DB: {r['db_name']}, Free Tier: {r.get('is_free_tier', False)})"), "block_volumes": ("Block Volumes", lambda r: f" - {r['display_name']} ({r.get('size_in_gbs', '?')} GB)"), "load_balancers": ("Load Balancers", lambda r: f" - {r['display_name']} (Shape: {r.get('shape_name', '?')})"), } for key, (label, formatter) in resource_labels.items(): items = resources.get(key, []) if items: total += len(items) lines.append(f"\n**{label}** ({len(items)}):") for item in items[:15]: # limit to 15 per category lines.append(formatter(item)) if len(items) > 15: lines.append(f" ... e mais {len(items) - 15}") if total == 0: lines.append("\nNenhum recurso encontrado neste compartment. Todos os recursos serão novos.") else: lines.append(f"\n**Total: {total} recursos existentes.** REUTILIZE-OS sempre que possível usando data sources.") return "\n".join(lines) 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) # ── Constants ──────────────────────────────────────────────────────────────── TFP_SYSTEM_PROMPT = """Você é um arquiteto sênior de infraestrutura OCI com profundo conhecimento do Terraform OCI Provider. O usuário vai descrever a infraestrutura desejada em linguagem natural. Sua tarefa é gerar um **prompt detalhado e estruturado** que será enviado a um agente Terraform para criar o código HCL. ### Formato do Prompt Gerado Retorne APENAS o prompt estruturado (não gere código Terraform). Use markdown: 1. **Título**: Uma linha descrevendo o projeto (ex: "## Infraestrutura Multi-Region HA para Aplicação Web") 2. **Arquitetura**: Diagrama textual ou descrição da topologia 3. **Recursos por Categoria**: Organizados em seções ## (Networking, Compute, Storage, Database, Security, Observability) - Para cada recurso: nome, especificações técnicas, relacionamentos 4. **Networking**: Sempre inclua CIDRs (10.0.0.0/16 para VCN, /24 para subnets), gateways necessários, route tables, security lists/NSGs 5. **Dependências**: Liste dependências entre recursos (ex: "Compute depende de Subnet, que depende de VCN") 6. **Requisitos Técnicos**: Seção final com regras para o agente Terraform: - Separação em arquivos (provider.tf, variables.tf, vcn.tf, subnets.tf, compute.tf, outputs.tf) - Variáveis obrigatórias (compartment_id, region, ssh_public_key, etc.) - Naming convention (ex: proj-env-resource) - Outputs necessários (IPs, OCIDs, endpoints) - Tags padrão (Environment, Project, ManagedBy=Terraform) ### Regras de Inferência - Se pedir VCN, inclua automaticamente: subnets (pública + privada), internet gateway, NAT gateway, service gateway, route tables, security lists - Se pedir Compute, inclua: boot volume, NSG, cloud-init básico, shape e imagem - Se pedir Database, inclua: subnet privada dedicada, NSG com porta do DB, backup policy - Se pedir OKE, inclua: VCN com topologia para K8s (API endpoint subnet, workers subnet, pods subnet, services LB subnet) - Se mencionar multi-region, use providers aliased e detalhe RPC/DRG - Se mencionar HA/DR, inclua redundância cross-AD ou cross-region - Se mencionar segurança/CIS, inclua Vault, Cloud Guard, WAF, logging, encryption at rest ### Restrições do OCI Provider - RPC (Remote Peering Connection): NÃO usar oci_core_drg_attachment para RPC. O attachment é criado automaticamente pelo recurso oci_core_remote_peering_connection - Cada VCN suporta apenas 1 DRG attachment — não criar duplicados - DRG attachment type para VCN é "VCN", não "REMOTE_PEERING_CONNECTION" ### Restrições do Workspace - NUNCA sugira o uso de `module` blocks. O workspace é flat (diretório único, sem subdiretórios). Toda infraestrutura deve ser definida com `resource` e `data` blocks diretamente. - Variáveis devem ser declaradas SOMENTE em `variables.tf`. Nunca duplicar declarações de variáveis entre arquivos. ### Tom - Seja técnico e preciso - Use português brasileiro - Não gere código, apenas o prompt estruturado""" # ── OCI Resource Actions ───────────────────────────────────────────────────── @router.post("/api/oci/instances/{instance_id}/action") async def oci_instance_action(instance_id: str, req: dict, u=Depends(current_user)): action = req.get("action", "").upper() oci_config_id = req.get("oci_config_id", "") region = req.get("region") if action not in ("START", "STOP"): raise HTTPException(400, "Ação inválida. Use START ou STOP.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) if region: config["region"] = region compute = oci.core.ComputeClient(config) compute.instance_action(instance_id, action) _audit(u["id"], u["username"], f"instance_{action.lower()}", instance_id) return {"ok": True, "action": action, "instance_id": instance_id} except Exception as e: raise HTTPException(500, str(e)[:500]) @router.post("/api/oci/autonomous-databases/{adb_id}/action") async def oci_adb_action(adb_id: str, req: dict, u=Depends(current_user)): action = req.get("action", "").lower() oci_config_id = req.get("oci_config_id", "") region = req.get("region") if action not in ("start", "stop"): raise HTTPException(400, "Ação inválida. Use start ou stop.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) if region: config["region"] = region db_client = oci.database.DatabaseClient(config) if action == "start": db_client.start_autonomous_database(adb_id) else: db_client.stop_autonomous_database(adb_id) _audit(u["id"], u["username"], f"adb_{action}", adb_id) return {"ok": True, "action": action, "adb_id": adb_id} except Exception as e: raise HTTPException(500, str(e)[:500]) @router.post("/api/oci/autonomous-databases/{adb_id}/update-network-access") async def oci_adb_update_network(adb_id: str, req: dict, u=Depends(current_user)): oci_config_id = req.get("oci_config_id", "") ip = req.get("ip", "") region = req.get("region") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") _verify_config_access("oci", oci_config_id, u) if not ip: raise HTTPException(400, "ip obrigatório") try: import oci config = _get_oci_config(oci_config_id) if region: config["region"] = region db_client = oci.database.DatabaseClient(config) adb = db_client.get_autonomous_database(adb_id).data current_acl = adb.whitelisted_ips or [] # Build new ACL: keep existing entries, add new IP if not present ip_cidr = ip if "/" in ip else ip + "/32" new_acl = list(set(current_acl) | {ip_cidr}) db_client.update_autonomous_database( adb_id, oci.database.models.UpdateAutonomousDatabaseDetails(whitelisted_ips=new_acl)) _audit(u["id"], u["username"], "adb_update_network", adb_id, f"ip={ip_cidr}") return {"ok": True, "adb_id": adb_id, "ip_added": ip_cidr, "acl": new_acl} except Exception as e: raise HTTPException(500, str(e)[:500]) @router.post("/api/oci/db-systems/{db_system_id}/action") async def oci_db_system_action(db_system_id: str, req: dict, u=Depends(current_user)): action = req.get("action", "").upper() oci_config_id = req.get("oci_config_id", "") region = req.get("region") if action not in ("START", "STOP"): raise HTTPException(400, "Ação inválida. Use START ou STOP.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) if region: config["region"] = region db_client = oci.database.DatabaseClient(config) if action == "START": db_client.start_db_system(db_system_id) else: db_client.stop_db_system(db_system_id) _audit(u["id"], u["username"], f"db_system_{action.lower()}", db_system_id) return {"ok": True, "action": action, "db_system_id": db_system_id} except Exception as e: raise HTTPException(500, str(e)[:500]) @router.post("/api/oci/mysql-db-systems/{db_system_id}/action") async def oci_mysql_action(db_system_id: str, req: dict, u=Depends(current_user)): action = req.get("action", "").lower() oci_config_id = req.get("oci_config_id", "") region = req.get("region") if action not in ("start", "stop"): raise HTTPException(400, "Ação inválida. Use start ou stop.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) if region: config["region"] = region mysql = oci.mysql.DbSystemClient(config) if action == "start": mysql.start_db_system(db_system_id) else: shutdown_type = req.get("shutdown_type", "SLOW") mysql.stop_db_system(db_system_id, oci.mysql.models.StopDbSystemDetails(shutdown_type=shutdown_type)) _audit(u["id"], u["username"], f"mysql_{action}", db_system_id) return {"ok": True, "action": action, "db_system_id": db_system_id} except Exception as e: raise HTTPException(500, str(e)[:500]) @router.post("/api/oci/container-instances/{ci_id}/action") async def oci_container_instance_action(ci_id: str, req: dict, u=Depends(current_user)): action = req.get("action", "").lower() oci_config_id = req.get("oci_config_id", "") region = req.get("region") if action not in ("start", "stop"): raise HTTPException(400, "Ação inválida. Use start ou stop.") if not oci_config_id: raise HTTPException(400, "oci_config_id obrigatório") _verify_config_access("oci", oci_config_id, u) try: import oci config = _get_oci_config(oci_config_id) if region: config["region"] = region ci = oci.container_instances.ContainerInstanceClient(config) if action == "start": ci.start_container_instance(ci_id) else: ci.stop_container_instance(ci_id) _audit(u["id"], u["username"], f"container_instance_{action}", ci_id) return {"ok": True, "action": action, "container_instance_id": ci_id} except Exception as e: raise HTTPException(500, str(e)[:500]) @router.get("/api/oci/my-ip") async def get_my_ip(request: Request): """Return the caller's public IP address.""" forwarded = request.headers.get("x-forwarded-for", "") ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "") return {"ip": ip} # ── Terraform Resources ────────────────────────────────────────────────────── @router.get("/api/terraform/resources") async def tf_list_resources(oci_config_id: str = Query(...), compartment_id: str = Query(...), region: str = Query(None), u=Depends(current_user)): """List existing OCI resources in a compartment for terraform context.""" _verify_config_access("oci", oci_config_id, u) try: loop = asyncio.get_event_loop() resources = await loop.run_in_executor( _chat_executor, partial(_fetch_compartment_resources, oci_config_id, compartment_id, region)) return resources except Exception as e: raise HTTPException(500, str(e)[:500]) # ── Terraform Generate Prompt ──────────────────────────────────────────────── @router.post("/api/terraform/generate-prompt") async def tf_generate_prompt(req: TfPromptReq, bg: BackgroundTasks, u=Depends(current_user)): gc = None if req.genai_config: if req.genai_config.get("genai_config_id"): with db() as c: row = c.execute("SELECT * FROM genai_configs WHERE id=?", (req.genai_config["genai_config_id"],)).fetchone() if row: gc = dict(row) elif req.genai_config.get("oci_config_id"): oci_id = req.genai_config["oci_config_id"] with db() as c: oc = c.execute("SELECT * FROM oci_configs WHERE id=?", (oci_id,)).fetchone() if oc: region = req.genai_config.get("genai_region") or oc["region"] compartment = _safe_dec(dict(oc).get("compartment_id") or oc["tenancy_ocid"]) gc = { "oci_config_id": oci_id, "model_id": req.genai_config.get("model_id", "openai.gpt-4.1"), "model_ocid": None, "compartment_id": compartment, "genai_region": region, "endpoint": f"https://inference.generativeai.{region}.oci.oraclecloud.com", "serving_type": "ON_DEMAND", "dedicated_endpoint_id": None, "temperature": 0.7, "max_tokens": 4000, "top_p": 0.9, } if not gc: with db() as c: row = c.execute("SELECT * FROM genai_configs WHERE user_id=? AND is_default=1", (u["id"],)).fetchone() if not row: row = c.execute("SELECT * FROM genai_configs WHERE user_id=? LIMIT 1", (u["id"],)).fetchone() if row: gc = dict(row) if not gc: raise HTTPException(400, "Nenhuma configuração GenAI disponível") # Session persistence is_new = not req.session_id sid = req.session_id or str(uuid.uuid4()) with db() as c: c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)", (str(uuid.uuid4()), sid, u["id"], "user", req.message, gc.get("model_id"), "done")) if is_new: title = (req.message or "Prompt Generator")[:80].strip() c.execute("INSERT OR IGNORE INTO chat_sessions (id,user_id,agent_type,title) VALUES (?,?,?,?)", (sid, u["id"], "tf-prompt", title)) else: c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,)) # Load TF resource reference (compact — same as Terraform Agent) tf_ref = _load_tf_resource_reference() system_with_ref = TFP_SYSTEM_PROMPT if tf_ref: sections = [] for line in tf_ref.split('\n'): if line.startswith('## All Resource Types'): break sections.append(line) ref_compact = '\n'.join(sections).strip() if ref_compact: system_with_ref += "\n\n### Referência de Recursos OCI Terraform (gerado do provider schema)\n" + \ "Use EXATAMENTE estes nomes de resource types. Se o recurso não estiver nesta lista, ele NÃO EXISTE no provider.\n\n" + \ ref_compact # Detect resource types from user message + history and fetch official docs detect_text = req.message if req.history: for h in req.history[-3:]: detect_text += "\n" + (h.get("content", "") or "") resource_types = _detect_tf_resource_types(detect_text) if resource_types: docs_ctx = _get_tf_docs_for_resources(resource_types) if docs_ctx: system_with_ref += "\n\n" + docs_ctx log.info(f"TF Prompt Generator: injected {len(resource_types)} resource docs ({len(docs_ctx)} chars)") gc["system_prompt"] = system_with_ref gc["temperature"] = 0.7 gc["max_tokens"] = 4000 # Build history from conversation (normalize roles to lowercase for _call_genai) hist = None if req.history: hist = [{"role": "user" if m.get("role","").upper() in ("USER","user") else "assistant", "content": m.get("content", "")} for m in req.history] # Async background processing — same pattern as Chat/Terraform agents mid = str(uuid.uuid4()) with db() as c: c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)", (mid, sid, u["id"], "assistant", "", gc.get("model_id"), "processing")) def _tfp_background(): try: answer, _, _ = _call_genai(gc, req.message, history=hist) with db() as c: c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (answer, mid)) log.info(f"TF Prompt Generator completed: mid={mid} len={len(answer)}") except Exception as e: log.error(f"tf_generate_prompt error: {e}") with db() as c: c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?", (str(e)[:500], mid)) bg.add_task(_tfp_background) return {"message_id": mid, "session_id": sid, "status": "processing"} # ── Terraform Chat ─────────────────────────────────────────────────────────── @router.post("/api/terraform/chat") async def terraform_chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)): sid, mid_or_result, genai_cfg = _chat_start(msg, u, agent_type="terraform") if genai_cfg is None: return mid_or_result bg.add_task(_chat_background, mid_or_result, sid, msg, dict(u), genai_cfg, None, "terraform") return {"message_id": mid_or_result, "session_id": sid, "status": "processing"} # ── Terraform Workspaces CRUD ──────────────────────────────────────────────── @router.post("/api/terraform/workspaces") async def tf_create_workspace(req: TfWorkspaceReq, u=Depends(current_user)): wid = str(uuid.uuid4()) with db() as c: c.execute( "INSERT INTO terraform_workspaces (id,user_id,session_id,oci_config_id,compartment_id,name,tf_code,status) VALUES (?,?,?,?,?,?,?,?)", (wid, u["id"], req.session_id, req.oci_config_id, req.compartment_id, req.name, req.tf_code, "draft")) return {"id": wid, "status": "draft"} @router.get("/api/terraform/workspaces") async def tf_list_workspaces(u=Depends(current_user)): with db() as c: rows = c.execute( """SELECT tw.id,tw.name,tw.session_id,tw.oci_config_id,tw.compartment_id,tw.status, tw.plan_output,tw.apply_output,tw.destroy_output,tw.error,tw.created_at,tw.updated_at, cs.title as session_title FROM terraform_workspaces tw INNER JOIN chat_sessions cs ON cs.id = tw.session_id AND cs.user_id = tw.user_id WHERE tw.user_id=? ORDER BY tw.updated_at DESC""", (u["id"],)).fetchall() return [dict(r) for r in rows] @router.get("/api/terraform/workspaces/{wid}") async def tf_get_workspace(wid: str, u=Depends(current_user)): with db() as c: r = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not r: raise HTTPException(404) return dict(r) @router.put("/api/terraform/workspaces/{wid}/code") async def tf_update_code(wid: str, req: dict, u=Depends(current_user)): code = req.get("tf_code", "") with db() as c: r = c.execute("SELECT id FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not r: raise HTTPException(404) c.execute("UPDATE terraform_workspaces SET tf_code=?, status='draft', updated_at=datetime('now') WHERE id=?", (code, wid)) return {"ok": True} @router.post("/api/terraform/workspaces/{wid}/plan") async def tf_run_plan(wid: str, bg: BackgroundTasks, u=Depends(current_user)): with db() as c: ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not ws: raise HTTPException(404) if ws["status"] in ("planning", "applying", "destroying"): raise HTTPException(400, f"Workspace is {ws['status']}") with db() as c: c.execute("UPDATE terraform_workspaces SET status='planning', plan_output='', apply_output='', destroy_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,)) bg.add_task(_terraform_exec, wid, "plan", dict(u)) return {"status": "planning"} @router.post("/api/terraform/workspaces/{wid}/apply") async def tf_run_apply(wid: str, bg: BackgroundTasks, u=Depends(current_user)): with db() as c: ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not ws: raise HTTPException(404) if ws["status"] not in ("planned", "applied", "destroyed", "failed"): raise HTTPException(400, f"Cannot apply in status {ws['status']}. Run plan first.") with db() as c: c.execute("UPDATE terraform_workspaces SET status='applying', apply_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,)) bg.add_task(_terraform_exec, wid, "apply", dict(u)) return {"status": "applying"} @router.post("/api/terraform/workspaces/{wid}/destroy") async def tf_run_destroy(wid: str, req: dict, bg: BackgroundTasks, u=Depends(current_user)): if req.get("confirm") != "DESTROY": raise HTTPException(400, "Confirmação obrigatória: envie {\"confirm\": \"DESTROY\"}") with db() as c: ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not ws: raise HTTPException(404) if ws["status"] in ("planning", "applying", "destroying"): raise HTTPException(400, f"Workspace is {ws['status']}") with db() as c: c.execute("UPDATE terraform_workspaces SET status='destroying', destroy_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,)) bg.add_task(_terraform_exec, wid, "destroy", dict(u)) return {"status": "destroying"} @router.get("/api/terraform/workspaces/{wid}/status") async def tf_workspace_status(wid: str, u=Depends(current_user)): with db() as c: r = c.execute("SELECT status, plan_output, apply_output, destroy_output, error, updated_at FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not r: raise HTTPException(404) return dict(r) @router.get("/api/terraform/workspaces/{wid}/download") async def tf_download(wid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))): u = _user_from_token(token) if token else (await current_user(cred) if cred else None) if not u: raise HTTPException(401, "Not authenticated") with db() as c: r = c.execute("SELECT tf_code, name FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone() if not r or not r["tf_code"]: raise HTTPException(404, "No code found") import re as _re parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', r["tf_code"], flags=_re.MULTILINE) if len(parts) >= 3: import io, zipfile buf = io.BytesIO() with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf: if parts[0].strip(): zf.writestr("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: zf.writestr(fname, content) buf.seek(0) ws_name = r["name"] or "terraform" return Response(content=buf.read(), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{ws_name}.zip"'}) return Response(content=r["tf_code"], media_type="text/plain", headers={"Content-Disposition": f'attachment; filename="main.tf"'}) @router.post("/api/terraform/workspaces/{wid}/cancel") async def tf_cancel(wid: str, u=Depends(current_user)): with db() as c: ws = c.execute("SELECT user_id FROM terraform_workspaces WHERE id=?", (wid,)).fetchone() if not ws or ws["user_id"] != u["id"]: raise HTTPException(404) proc = running_terraform.get(wid) if proc: proc.terminate() try: await asyncio.wait_for(proc.wait(), timeout=5) except asyncio.TimeoutError: proc.kill() with db() as c: c.execute("UPDATE terraform_workspaces SET status='failed', error='Cancelado pelo usuário', updated_at=datetime('now') WHERE id=?", (wid,)) running_terraform.pop(wid, None) return {"ok": True} @router.delete("/api/terraform/workspaces/{wid}") async def tf_delete_workspace(wid: str, u=Depends(current_user)): with db() as c: c.execute("DELETE FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])) wdir = TERRAFORM_DIR / wid if wdir.exists(): shutil.rmtree(wdir, ignore_errors=True) return {"ok": True} @router.post("/api/terraform/refresh-reference") async def tf_refresh_reference(u=Depends(current_user)): """Regenerate the OCI Terraform resource reference from provider schema (internal, triggered by UI button).""" loop = asyncio.get_event_loop() try: result = await asyncio.wait_for( loop.run_in_executor(_chat_executor, _regenerate_tf_reference), timeout=300) except asyncio.TimeoutError: log.error("TF reference regeneration timed out after 300s") raise HTTPException(504, "Geração de referência Terraform demorou demais. Tente novamente.") # Add status info p = Path(_TF_RESOURCE_REF_PATH) if p.exists(): content = _load_tf_resource_reference() result["lines"] = content.count('\n') if content else 0 result["updated_at"] = datetime.fromtimestamp(p.stat().st_mtime).strftime('%d/%m/%Y %H:%M') return result