feat: session token auth, workspace submenu, retry buttons, terraform fixes, app rename
- Rename app to "AI Agent - Infrastructure & Security Engineer" - OCI session token authentication (SecurityTokenSigner, conditional form, type tags) - Terraform Workspaces sidebar sub-tab (date-grouped accordion, INNER JOIN filter) - Retry button on failed messages (Chat, Terraform, Prompt Generator) - Fix f2 undefined in dedup, oci_core_services false validation, Re-plan tfCode sync - Auto-fix button no longer requires pre-selected model - DRG attachment rules added to Terraform system prompt - Stuck workspace recovery on container restart
This commit is contained in:
212
backend/app.py
212
backend/app.py
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
OCI CIS AI Agent - Backend API v1.1
|
||||
AI Agent - Infrastructure & Security Engineer - Backend API v1.1
|
||||
FastAPI with JWT auth, TOTP MFA, RBAC, OCI GenAI (exact SDK pattern),
|
||||
OCI Account Explorer, MCP Server registry with VectorDB tool integration,
|
||||
Autonomous DB vector storage, CIS reports, chat agent, audit log.
|
||||
@@ -52,7 +52,7 @@ COMPACT_MIN_MESSAGES = 8
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
log = logging.getLogger("agent")
|
||||
|
||||
app = FastAPI(title="OCI CIS AI Agent", version=VERSION)
|
||||
app = FastAPI(title="AI Agent - Infrastructure & Security Engineer", version=VERSION)
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
||||
allow_methods=["*"], allow_headers=["*"])
|
||||
security = HTTPBearer()
|
||||
@@ -390,7 +390,7 @@ def init_db():
|
||||
c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
for col in ["ssh_public_key TEXT"]:
|
||||
for col in ["ssh_public_key TEXT", "auth_type TEXT DEFAULT 'api_key'"]:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
|
||||
except sqlite3.OperationalError:
|
||||
@@ -427,6 +427,16 @@ def init_db():
|
||||
else:
|
||||
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
|
||||
(str(uuid.uuid4()), "OCI Terraform Agent", "terraform", TF_DEFAULT_SYSTEM_PROMPT, 1))
|
||||
# Recover stuck terraform workspaces (interrupted by container restart)
|
||||
stuck = c.execute("SELECT id, status FROM terraform_workspaces WHERE status IN ('planning','applying','destroying')").fetchall()
|
||||
for ws in stuck:
|
||||
prev = 'applied' if ws["status"] == 'destroying' else 'failed'
|
||||
c.execute("UPDATE terraform_workspaces SET status=?, error='Interrompido por restart do container', updated_at=datetime('now') WHERE id=?", (prev, ws["id"]))
|
||||
# Remove stale lock files
|
||||
lock_file = TERRAFORM_DIR / ws["id"] / ".terraform.tfstate.lock.info"
|
||||
if lock_file.exists():
|
||||
lock_file.unlink()
|
||||
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
|
||||
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||||
if not adm:
|
||||
c.execute(
|
||||
@@ -446,7 +456,7 @@ def _totp_verify(secret,code,window=1):
|
||||
c=str((struct.unpack(">I",h[o:o+4])[0]&0x7FFFFFFF)%1_000_000).zfill(6)
|
||||
if hmac.compare_digest(c,code): return True
|
||||
return False
|
||||
def _totp_uri(secret,user): return f"otpauth://totp/OCI-CIS-Agent:{user}?secret={secret}&issuer=OCI-CIS-Agent"
|
||||
def _totp_uri(secret,user): return f"otpauth://totp/AI-Agent:{user}?secret={secret}&issuer=AI-Agent"
|
||||
def _make_token(uid,role,sid):
|
||||
return pyjwt.encode({"sub":uid,"role":role,"sid":sid,"exp":datetime.utcnow()+timedelta(hours=JWT_EXP_H),"iat":datetime.utcnow()},APP_SECRET,algorithm=JWT_ALG)
|
||||
def _enc(v): return base64.b64encode(v.encode()).decode()
|
||||
@@ -469,7 +479,30 @@ def _safe_dec(v):
|
||||
def _get_oci_config(oci_config_id: str) -> dict:
|
||||
import oci
|
||||
config_path = str(OCI_DIR / oci_config_id / "config")
|
||||
return oci.config.from_file(config_path, "DEFAULT")
|
||||
config = oci.config.from_file(config_path, "DEFAULT")
|
||||
return config
|
||||
|
||||
|
||||
def _get_oci_signer(oci_config_id: str):
|
||||
"""Return (config, signer) tuple. For session_token auth, returns SecurityTokenSigner; for api_key returns None (use default)."""
|
||||
import oci
|
||||
config = _get_oci_config(oci_config_id)
|
||||
if config.get("security_token_file"):
|
||||
from oci.auth.signers import SecurityTokenSigner
|
||||
token_path = config["security_token_file"]
|
||||
with open(token_path) as f:
|
||||
token = f.read().strip()
|
||||
signer = SecurityTokenSigner(token, config["key_file"])
|
||||
return config, signer
|
||||
return config, None
|
||||
|
||||
|
||||
def _oci_client(client_class, oci_config_id: str, **kwargs):
|
||||
"""Create an OCI client with correct auth (api_key or session_token)."""
|
||||
config, signer = _get_oci_signer(oci_config_id)
|
||||
if signer:
|
||||
return client_class(config=config, signer=signer, **kwargs)
|
||||
return client_class(config, **kwargs)
|
||||
|
||||
# ── Auth deps ─────────────────────────────────────────────────────────────────
|
||||
async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)):
|
||||
@@ -653,43 +686,79 @@ async def del_user(uid: str, adm=Depends(require("admin"))):
|
||||
@app.post("/api/oci/config")
|
||||
async def save_oci(
|
||||
tenancy_name: str = Form(...), tenancy_ocid: str = Form(...),
|
||||
user_ocid: str = Form(...), fingerprint: str = Form(...),
|
||||
user_ocid: str = Form(""), fingerprint: str = Form(""),
|
||||
region: str = Form(...), compartment_id: str = Form(""),
|
||||
key_passphrase: str = Form(""),
|
||||
ssh_public_key: str = Form(""),
|
||||
private_key: UploadFile = File(...), public_key: Optional[UploadFile] = File(None),
|
||||
auth_type: str = Form("api_key"),
|
||||
security_token: str = Form(""),
|
||||
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
||||
u = Depends(require("admin","user"))
|
||||
):
|
||||
if auth_type not in ("api_key", "session_token"):
|
||||
raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'")
|
||||
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
|
||||
kp = cdir / "oci_api_key.pem"
|
||||
key_bytes = await private_key.read()
|
||||
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||||
# Detect encrypted keys that require passphrase
|
||||
key_text = key_bytes.decode("utf-8", errors="ignore")
|
||||
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
|
||||
if key_is_encrypted and not key_passphrase:
|
||||
# Clean up and warn
|
||||
shutil.rmtree(cdir, ignore_errors=True)
|
||||
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
||||
if public_key: (cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
||||
cfg_file = cdir / "config"
|
||||
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||||
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||||
if key_passphrase:
|
||||
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||||
cfg_file.write_text(cfg_content)
|
||||
cfg_file.chmod(0o600)
|
||||
|
||||
if auth_type == "session_token":
|
||||
# Session token auth: requires tenancy_ocid, region, token, and session key
|
||||
if not security_token.strip():
|
||||
shutil.rmtree(cdir, ignore_errors=True)
|
||||
raise HTTPException(400, "Session Token é obrigatório para autenticação por token.")
|
||||
if not private_key or not private_key.filename:
|
||||
shutil.rmtree(cdir, ignore_errors=True)
|
||||
raise HTTPException(400, "A chave de sessão (.pem) é obrigatória para autenticação por token.")
|
||||
key_bytes = await private_key.read()
|
||||
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||||
# Save security token file
|
||||
token_file = cdir / "token"
|
||||
token_file.write_text(security_token.strip())
|
||||
token_file.chmod(0o600)
|
||||
# Generate config
|
||||
cfg_content = (f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\n"
|
||||
f"key_file={kp}\nsecurity_token_file={token_file}\n")
|
||||
if user_ocid:
|
||||
cfg_content = f"[DEFAULT]\nuser={user_ocid}\n" + cfg_content.replace("[DEFAULT]\n", "")
|
||||
cfg_file = cdir / "config"
|
||||
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||||
# For session_token, fingerprint/user may not be provided — use placeholders
|
||||
user_ocid = user_ocid or "session_token_user"
|
||||
fingerprint = fingerprint or "session_token"
|
||||
else:
|
||||
# API Key auth (original flow)
|
||||
if not user_ocid or not fingerprint:
|
||||
shutil.rmtree(cdir, ignore_errors=True)
|
||||
raise HTTPException(400, "OCID User e Fingerprint são obrigatórios para API Key.")
|
||||
if not private_key or not private_key.filename:
|
||||
shutil.rmtree(cdir, ignore_errors=True)
|
||||
raise HTTPException(400, "Selecione a chave privada (.pem).")
|
||||
key_bytes = await private_key.read()
|
||||
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||||
key_text = key_bytes.decode("utf-8", errors="ignore")
|
||||
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
|
||||
if key_is_encrypted and not key_passphrase:
|
||||
shutil.rmtree(cdir, ignore_errors=True)
|
||||
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
||||
if public_key and public_key.filename:
|
||||
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
||||
cfg_file = cdir / "config"
|
||||
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||||
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||||
if key_passphrase:
|
||||
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||||
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||||
|
||||
with db() as c:
|
||||
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None, ssh_public_key.strip() if ssh_public_key.strip() else None))
|
||||
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name}")
|
||||
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region})", u["id"], u["username"])
|
||||
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key,auth_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None, ssh_public_key.strip() if ssh_public_key.strip() else None, auth_type))
|
||||
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
|
||||
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
|
||||
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
||||
|
||||
@app.get("/api/oci/configs")
|
||||
async def list_oci(u=Depends(current_user)):
|
||||
with db() as c:
|
||||
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,created_at"
|
||||
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,auth_type,created_at"
|
||||
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM oci_configs").fetchall()
|
||||
else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||||
result = []
|
||||
@@ -736,6 +805,8 @@ async def update_oci(
|
||||
region: str = Form(...), compartment_id: str = Form(""),
|
||||
key_passphrase: str = Form(""),
|
||||
ssh_public_key: str = Form(""),
|
||||
auth_type: str = Form(""),
|
||||
security_token: str = Form(""),
|
||||
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
||||
u = Depends(require("admin","user"))
|
||||
):
|
||||
@@ -748,6 +819,11 @@ async def update_oci(
|
||||
user_ocid = user_ocid or _safe_dec(existing["user_ocid"])
|
||||
fingerprint = fingerprint or _safe_dec(existing["fingerprint"])
|
||||
compartment_id = compartment_id or _safe_dec(existing["compartment_id"]) or ""
|
||||
try:
|
||||
existing_auth = existing["auth_type"] or "api_key"
|
||||
except (IndexError, KeyError):
|
||||
existing_auth = "api_key"
|
||||
auth_type = auth_type or existing_auth
|
||||
try:
|
||||
existing_ssh = existing["ssh_public_key"] or ""
|
||||
except (IndexError, KeyError):
|
||||
@@ -759,21 +835,32 @@ async def update_oci(
|
||||
key_bytes = await private_key.read()
|
||||
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||||
key_text = key_bytes.decode("utf-8", errors="ignore")
|
||||
if ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase:
|
||||
if auth_type == "api_key" and ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase:
|
||||
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
||||
if public_key and public_key.filename:
|
||||
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
||||
# Update session token file if provided
|
||||
if security_token.strip():
|
||||
token_file = cdir / "token"
|
||||
token_file.write_text(security_token.strip()); token_file.chmod(0o600)
|
||||
cfg_file = cdir / "config"
|
||||
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||||
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||||
if key_passphrase:
|
||||
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||||
if auth_type == "session_token":
|
||||
token_path = cdir / "token"
|
||||
cfg_content = f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
|
||||
if user_ocid and user_ocid != "session_token_user":
|
||||
cfg_content = f"[DEFAULT]\nuser={user_ocid}\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
|
||||
cfg_content += f"security_token_file={token_path}\n"
|
||||
else:
|
||||
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||||
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||||
if key_passphrase:
|
||||
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||||
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||||
with db() as c:
|
||||
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=? WHERE id=?",
|
||||
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, cid))
|
||||
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name}")
|
||||
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region})", u["id"], u["username"])
|
||||
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=?,auth_type=? WHERE id=?",
|
||||
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, auth_type, cid))
|
||||
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
|
||||
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
|
||||
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
||||
|
||||
@app.post("/api/oci/test/{cid}")
|
||||
@@ -2354,6 +2441,7 @@ Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
|
||||
- Remote peering: APENAS UM LADO do par define `peer_id` e `peer_region_name`. O outro lado é criado SEM `peer_id`. Nunca gere dois RPCs apontando um para o outro (causa Cycle error). Exemplo: RPC_mad1 (sem peer_id) e RPC_mad3 (com peer_id = RPC_mad1.id, peer_region_name = var.region).
|
||||
- Associação de route table com subnet deve ser via `route_table_id` na subnet ou `oci_core_route_table_attachment`.
|
||||
- `oci_network_firewall_network_firewall` exige `network_firewall_policy_id`.
|
||||
- DRG attachments: use `oci_core_drg_attachment` para VCN attachments (tipo padrão). Use `oci_core_drg_attachment_management` SOMENTE para REMOTE_PEERING_CONNECTION (único tipo suportado para management). Nunca duplique resource type+name entre arquivos (ex: dois `oci_core_drg_attachment.vcn_app_x`). Sempre associe `drg_route_table_id` nos VCN attachments para garantir roteamento correto.
|
||||
|
||||
### Proibições absolutas — módulos e variáveis
|
||||
- NUNCA use `module` blocks. Este workspace é flat (um único diretório). Não existem subdiretórios `modules/`. Toda a infraestrutura deve ser definida diretamente com `resource` e `data` blocks.
|
||||
@@ -2426,9 +2514,9 @@ def _validate_tf_resource_types(tf_code: str) -> list:
|
||||
Returns list of dicts with invalid types and suggestions."""
|
||||
import re as _re
|
||||
from difflib import get_close_matches
|
||||
# Extract all resource and data type declarations
|
||||
# Extract only resource type declarations (data sources are read-only and may not be in the schema)
|
||||
used_types = set()
|
||||
for m in _re.finditer(r'(?:resource|data)\s+"(\w+)"', tf_code):
|
||||
for m in _re.finditer(r'resource\s+"(\w+)"', tf_code):
|
||||
used_types.add(m.group(1))
|
||||
if not used_types:
|
||||
return []
|
||||
@@ -3549,7 +3637,7 @@ async def embed_upload_url(
|
||||
if not url.startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "URL inválida — deve começar com http:// ou https://")
|
||||
try:
|
||||
resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 OCI-CIS-Agent/1.0"})
|
||||
resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 AI-Agent/1.0"})
|
||||
resp.raise_for_status()
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}")
|
||||
@@ -4483,7 +4571,7 @@ def _agent_respond(msg, user):
|
||||
return "\n".join(lines)
|
||||
if any(k in m for k in ["cis","benchmark","checks","verificar"]):
|
||||
return ("🔒 **CIS OCI Foundations Benchmark 3.0**\n\n54 controles em 8 domínios:\n• IAM: 17 controles\n• Networking: 8 controles\n• Compute: 3 controles\n• Logging & Monitoring: 18 controles\n• Storage: 6 controles\n• Asset Management: 2 controles\n\nConfigure OCI e execute um relatório na aba **Report**.")
|
||||
return ("Sou o **OCI CIS AI Agent v" + VERSION + "**. Sem modelo GenAI configurado, uso respostas locais.\n\n"
|
||||
return ("Sou o **AI Agent v" + VERSION + "** — Infrastructure & Security Engineer. Sem modelo GenAI configurado, uso respostas locais.\n\n"
|
||||
"Para chat com IA:\n1. Configure **OCI Credentials**\n2. Configure **GenAI** com modelo e região\n3. Selecione o modelo no chat\n\nDigite **ajuda** para ver os comandos.")
|
||||
|
||||
@app.get("/api/chat/sessions")
|
||||
@@ -4835,7 +4923,7 @@ class TfPromptReq(BaseModel):
|
||||
session_id: Optional[str] = None
|
||||
|
||||
@app.post("/api/terraform/generate-prompt")
|
||||
async def tf_generate_prompt(req: TfPromptReq, u=Depends(current_user)):
|
||||
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"):
|
||||
@@ -4911,16 +4999,23 @@ async def tf_generate_prompt(req: TfPromptReq, u=Depends(current_user)):
|
||||
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]
|
||||
try:
|
||||
answer, _, _ = _call_genai(gc, req.message, history=hist)
|
||||
# Save assistant response
|
||||
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"], "assistant", answer, gc.get("model_id"), "done"))
|
||||
return {"prompt": answer, "session_id": sid}
|
||||
except Exception as e:
|
||||
log.error(f"tf_generate_prompt error: {e}")
|
||||
raise HTTPException(500, str(e)[:500])
|
||||
# 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"}
|
||||
|
||||
@app.post("/api/terraform/chat")
|
||||
async def terraform_chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)):
|
||||
@@ -4953,7 +5048,12 @@ async def tf_create_workspace(req: TfWorkspaceReq, u=Depends(current_user)):
|
||||
async def tf_list_workspaces(u=Depends(current_user)):
|
||||
with db() as c:
|
||||
rows = c.execute(
|
||||
"SELECT id,name,session_id,oci_config_id,status,created_at,updated_at FROM terraform_workspaces WHERE user_id=? ORDER BY created_at DESC",
|
||||
"""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]
|
||||
|
||||
@@ -5253,7 +5353,7 @@ def _write_tf_files(wdir: Path, tf_code: str):
|
||||
# Remove monolithic files that are fully duplicated
|
||||
for fname in dupes_in:
|
||||
if all(
|
||||
any(f2 != fname and f2 in [fl for fl in resource_locations.get(key, []) if fl != fname])
|
||||
any(f2 != fname for f2 in resource_locations.get(key, []))
|
||||
for key, flist in resource_locations.items()
|
||||
if fname in flist
|
||||
) and len(files) - 1 >= 2:
|
||||
@@ -5881,7 +5981,7 @@ async def startup():
|
||||
except Exception as e:
|
||||
log.warning(f"Could not populate tf_valid_types: {e}")
|
||||
|
||||
log.info(f"OCI CIS AI Agent v{VERSION} API started")
|
||||
log.info(f"AI Agent v{VERSION} API started")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
Reference in New Issue
Block a user