Files
A-Team-Security-Infra-Agent…/backend/routes/terraform.py
nogueiraguh 1135e9d6a9 refactor: decompose monolith — app.py 10,600→143 lines, 30+ modules
Fase 0 complete: extract all business logic, auth, database, and 176
endpoints from monolithic app.py into dedicated modules.

Structure:
- app.py: FastAPI hub (CORS, routers, startup/shutdown)
- models.py: 13 Pydantic request models
- utils.py: shared utilities (embed status, upload validation, process registries)
- config.py: constants, env vars, model catalogs
- auth/: crypto, jwt_auth, oidc, rate_limit
- database/: db(), init_db(), schema DDL
- services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp
- routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer,
  genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine)

Also: README updated with OCIR auth instructions for manual docker run.

86 tests passing.
2026-04-06 15:20:10 -03:00

642 lines
33 KiB
Python

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