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.
This commit is contained in:
641
backend/routes/terraform.py
Normal file
641
backend/routes/terraform.py
Normal file
@@ -0,0 +1,641 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user