- Add Consult Embeddings sub-menu with chat-like Q&A interface - Fix VECTOR_DISTANCE FLOAT32/FLOAT64 mismatch (array 'd' → 'f') - Decode base64 compartment_id in _resolve_embed_config - Sidebar sub-item navigation for Embeddings hierarchy - Fallback to raw document display when no GenAI config available
5378 lines
278 KiB
Python
5378 lines
278 KiB
Python
"""
|
||
OCI CIS AI Agent - 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.
|
||
"""
|
||
import os, json, uuid, hashlib, hmac, time, base64, struct, secrets, subprocess
|
||
import shutil, asyncio, sqlite3, logging, re, concurrent.futures
|
||
from datetime import datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Optional, List, Dict, Any
|
||
from contextlib import contextmanager
|
||
from functools import partial
|
||
|
||
from fastapi import (
|
||
FastAPI, HTTPException, Depends, Request, UploadFile, File, Form,
|
||
Query, Body, BackgroundTasks
|
||
)
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from pydantic import BaseModel
|
||
import jwt as pyjwt
|
||
|
||
# ── Config ────────────────────────────────────────────────────────────────────
|
||
APP_SECRET = os.environ.get("APP_SECRET", secrets.token_hex(32))
|
||
JWT_ALG = "HS256"
|
||
JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12"))
|
||
DATA = Path(os.environ.get("DATA_DIR", "/data"))
|
||
DB_PATH = DATA / "agent.db"
|
||
OCI_DIR = DATA / "oci_configs"
|
||
REPORTS = DATA / "reports"
|
||
MCP_DIR = DATA / "mcp_servers"
|
||
WALLET_DIR = DATA / "wallets"
|
||
VERSION = "1.1"
|
||
|
||
for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR]:
|
||
d.mkdir(parents=True, exist_ok=True)
|
||
|
||
_running_reports: dict[str, asyncio.subprocess.Process] = {} # rid → subprocess
|
||
_running_terraform: dict[str, asyncio.subprocess.Process] = {} # wid → subprocess
|
||
TERRAFORM_DIR = DATA / "terraform"
|
||
TERRAFORM_DIR.mkdir(parents=True, exist_ok=True)
|
||
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=16, thread_name_prefix="chat")
|
||
|
||
# ── Chat Memory Compaction Settings ──
|
||
COMPACT_TOKEN_THRESHOLD = 6000 # estimated tokens before triggering compaction
|
||
COMPACT_KEEP_RECENT = 20 # recent messages to keep uncompacted (10 interactions = 20 msgs)
|
||
COMPACT_SUMMARY_MAX_TOKENS = 1500
|
||
COMPACT_MIN_MESSAGES = 8
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
log = logging.getLogger("agent")
|
||
|
||
app = FastAPI(title="OCI CIS AI Agent", version=VERSION)
|
||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True,
|
||
allow_methods=["*"], allow_headers=["*"])
|
||
security = HTTPBearer()
|
||
|
||
# ── OCI GenAI Models Catalog ──────────────────────────────────────────────────
|
||
# OCIDs are region-specific; "ocids" maps genai_region → OCID.
|
||
# _call_genai resolves the OCID for the configured region at runtime.
|
||
GENAI_MODELS = {
|
||
# ── Meta ──
|
||
"meta.llama-4-maverick-17b-128e-instruct-fp8": {"provider":"meta","name":"Meta Llama 4 Maverick","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyah6tjdejjashngznsylutuhhvufukzb2g2ls54g2flsfq"}},
|
||
"meta.llama-4-scout-17b-16e-instruct": {"provider":"meta","name":"Meta Llama 4 Scout","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaw23hfc7mtvv5wef3gwvvaguyzqmhb5lx4r5s3y2xzc4a"}},
|
||
# ── Google ──
|
||
"google.gemini-2.5-pro": {"provider":"google","name":"Google Gemini 2.5 Pro","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyargceyuaysrjzo2metq2rinavayxqmpu7tkm6mmfojcvq"}},
|
||
"google.gemini-2.5-flash": {"provider":"google","name":"Google Gemini 2.5 Flash","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaeo4ehrn25guuats5s45hnvswlhxo6riop275l2bkr2vq"}},
|
||
# ── OpenAI ──
|
||
"openai.gpt-5.2": {"provider":"openai","name":"OpenAI GPT-5.2","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya4fw3p5fddnexbfcurnz7spgkqb4mq4a6y5ubyv7777sa"}},
|
||
"openai.gpt-5.1": {"provider":"openai","name":"OpenAI GPT-5.1","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3darth2ozqcfssb2bats5jitpgigllccajasdyqljnkq"}},
|
||
"openai.gpt-5-mini": {"provider":"openai","name":"OpenAI GPT-5 Mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3eain4n6v3edm4ryjvze5hnjouujd4vralxntfalwjaq"}},
|
||
"openai.gpt-4.1": {"provider":"openai","name":"OpenAI GPT-4.1 (Padrão)","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaa6g75r2qqmtzjaooqtlxv4lxkpcqp2jdd6plpq7yq7ea"}},
|
||
"openai.gpt-4.1-mini": {"provider":"openai","name":"OpenAI GPT-4.1 Mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya6pk3sxishpiexm2rb5sf4ytb5tsbz4to2g3g23smidaa"}},
|
||
"openai.gpt-4o": {"provider":"openai","name":"OpenAI GPT-4o","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyah7slrtboxdbfdy5cdspsfts62yumoclpdgwydopse7za"}},
|
||
"openai.o4-mini": {"provider":"openai","name":"OpenAI o4-mini","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya5ivfqp4fxlajoeg2cahlqtuuswagiv6a7dggpigy23bq"}},
|
||
"openai.o3": {"provider":"openai","name":"OpenAI o3","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyalgnrukpjk6wm5zsf4jzkoneahgswhrk7kukkoagwnzma"}},
|
||
# ── xAI ──
|
||
"xai.grok-4": {"provider":"xai","name":"xAI Grok 4","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaldmhg25is4nouena4oa2pj4nvwgfeempo4syiaazukia"}},
|
||
"xai.grok-3": {"provider":"xai","name":"xAI Grok 3","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyag3w2xk76vlahjujj2gdfeuzhflt25gbo3bxidlsqfjla"}},
|
||
"xai.grok-3-mini-fast": {"provider":"xai","name":"xAI Grok 3 Mini Fast","api_format":"GENERIC",
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyauvjoll2repj5pbtkk7pinwj57ex3lkehzpxd6v6rxscq"}},
|
||
}
|
||
|
||
EMBEDDING_MODELS = {
|
||
"cohere.embed-v4.0": {"name":"Cohere Embed v4.0 (Multimodal)","dims":1536,
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyahw4vlsxm7newcqtlgmristnwxlrxox3h7bcnlomjpgwa"}},
|
||
"openai.text-embedding-3-large": {"name":"OpenAI Text Embedding 3 Large","dims":3072,
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3i6o2p5h2mij6unya5bsyc46aqey5hwy3icncawo3vcq"}},
|
||
"openai.text-embedding-3-small": {"name":"OpenAI Text Embedding 3 Small","dims":1536,
|
||
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyarjpjyniixp4tf7kyhr5bfajxmky4sjmbki2hp55ns2pq"}},
|
||
}
|
||
|
||
GENAI_REGIONS = [
|
||
"us-chicago-1","us-ashburn-1","us-phoenix-1","uk-london-1",
|
||
"eu-frankfurt-1","ap-tokyo-1","ap-osaka-1","sa-saopaulo-1",
|
||
"ca-toronto-1","ap-melbourne-1","ap-mumbai-1","eu-amsterdam-1",
|
||
"me-jeddah-1","ap-singapore-1","ap-seoul-1","sa-vinhedo-1",
|
||
]
|
||
|
||
OCI_REGIONS = {
|
||
"Americas": [
|
||
"us-ashburn-1","us-phoenix-1","us-chicago-1","us-sanjose-1","us-saltlake-2",
|
||
"ca-toronto-1","ca-montreal-1",
|
||
"sa-saopaulo-1","sa-vinhedo-1","sa-bogota-1","sa-santiago-1","sa-valparaiso-1",
|
||
"mx-queretaro-1","mx-monterrey-1",
|
||
],
|
||
"Europe": [
|
||
"eu-frankfurt-1","eu-amsterdam-1","eu-zurich-1","eu-madrid-1","eu-marseille-1",
|
||
"eu-milan-1","eu-paris-1","eu-stockholm-1","eu-jovanovac-1","eu-dcc-rome-1",
|
||
"uk-london-1","uk-cardiff-1",
|
||
"il-jerusalem-1",
|
||
],
|
||
"Asia Pacific": [
|
||
"ap-tokyo-1","ap-osaka-1","ap-seoul-1","ap-chuncheon-1",
|
||
"ap-mumbai-1","ap-hyderabad-1",
|
||
"ap-melbourne-1","ap-sydney-1",
|
||
"ap-singapore-1","ap-singapore-2",
|
||
],
|
||
"Middle East & Africa": [
|
||
"me-jeddah-1","me-abudhabi-1","me-dubai-1","me-riyadh-1",
|
||
"af-johannesburg-1",
|
||
],
|
||
}
|
||
|
||
# ── Database ──────────────────────────────────────────────────────────────────
|
||
@contextmanager
|
||
def db():
|
||
conn = sqlite3.connect(str(DB_PATH), timeout=30, check_same_thread=False)
|
||
conn.row_factory = sqlite3.Row
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
conn.execute("PRAGMA foreign_keys=ON")
|
||
try:
|
||
yield conn
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
def init_db():
|
||
with db() as c:
|
||
c.executescript("""
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
first_name TEXT NOT NULL,
|
||
last_name TEXT NOT NULL,
|
||
username TEXT UNIQUE NOT NULL,
|
||
email TEXT,
|
||
password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer',
|
||
mfa_secret TEXT, mfa_enabled INTEGER DEFAULT 0,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
last_login TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
expires_at TEXT NOT NULL, is_active INTEGER DEFAULT 1
|
||
);
|
||
CREATE TABLE IF NOT EXISTS oci_configs (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
tenancy_name TEXT NOT NULL, tenancy_ocid TEXT NOT NULL,
|
||
user_ocid TEXT NOT NULL, fingerprint TEXT NOT NULL,
|
||
region TEXT NOT NULL, key_file_path TEXT NOT NULL,
|
||
compartment_id TEXT,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS genai_configs (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT 'default',
|
||
oci_config_id TEXT NOT NULL,
|
||
model_id TEXT NOT NULL,
|
||
model_ocid TEXT,
|
||
compartment_id TEXT NOT NULL,
|
||
genai_region TEXT NOT NULL,
|
||
endpoint TEXT NOT NULL,
|
||
serving_type TEXT DEFAULT 'ON_DEMAND',
|
||
dedicated_endpoint_id TEXT,
|
||
temperature REAL DEFAULT 1,
|
||
max_tokens INTEGER DEFAULT 6000,
|
||
top_p REAL DEFAULT 0.95,
|
||
top_k INTEGER DEFAULT 1,
|
||
frequency_penalty REAL DEFAULT 0,
|
||
presence_penalty REAL DEFAULT 0,
|
||
is_default INTEGER DEFAULT 0,
|
||
system_prompt TEXT DEFAULT '',
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
FOREIGN KEY (oci_config_id) REFERENCES oci_configs(id)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS reports (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
tenancy_name TEXT NOT NULL, config_id TEXT,
|
||
level INTEGER DEFAULT 2,
|
||
obp_checks INTEGER DEFAULT 0,
|
||
raw_data INTEGER DEFAULT 0,
|
||
redact_output INTEGER DEFAULT 0,
|
||
status TEXT DEFAULT 'pending', progress TEXT DEFAULT '',
|
||
html_path TEXT, json_path TEXT,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
completed_at TEXT, error_msg TEXT
|
||
);
|
||
CREATE TABLE IF NOT EXISTS report_files (
|
||
id TEXT PRIMARY KEY,
|
||
report_id TEXT NOT NULL,
|
||
file_name TEXT NOT NULL,
|
||
file_path TEXT NOT NULL,
|
||
file_type TEXT NOT NULL,
|
||
file_category TEXT NOT NULL,
|
||
file_size INTEGER DEFAULT 0
|
||
);
|
||
CREATE TABLE IF NOT EXISTS adb_vector_configs (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
config_name TEXT NOT NULL,
|
||
dsn TEXT NOT NULL,
|
||
username TEXT NOT NULL,
|
||
password_enc TEXT NOT NULL,
|
||
wallet_dir TEXT,
|
||
wallet_password_enc TEXT,
|
||
table_name TEXT DEFAULT '',
|
||
use_mtls INTEGER DEFAULT 1,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS adb_vector_tables (
|
||
id TEXT PRIMARY KEY,
|
||
adb_config_id TEXT NOT NULL,
|
||
table_name TEXT NOT NULL,
|
||
description TEXT DEFAULT '',
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
FOREIGN KEY (adb_config_id) REFERENCES adb_vector_configs(id) ON DELETE CASCADE,
|
||
UNIQUE(adb_config_id, table_name)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS mcp_servers (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
name TEXT NOT NULL, description TEXT,
|
||
server_type TEXT NOT NULL DEFAULT 'stdio',
|
||
command TEXT, args TEXT, env_vars TEXT,
|
||
url TEXT, module_path TEXT,
|
||
tools TEXT,
|
||
linked_adb_id TEXT,
|
||
is_active INTEGER DEFAULT 1,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL, role TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
model_id TEXT,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS chat_summaries (
|
||
id TEXT PRIMARY KEY,
|
||
session_id TEXT NOT NULL,
|
||
user_id TEXT NOT NULL,
|
||
summary TEXT NOT NULL,
|
||
messages_compacted INTEGER NOT NULL,
|
||
up_to_message_id TEXT NOT NULL,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS audit_log (
|
||
id TEXT PRIMARY KEY, user_id TEXT, username TEXT,
|
||
action TEXT NOT NULL, resource TEXT, details TEXT,
|
||
ip TEXT, created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS config_logs (
|
||
id TEXT PRIMARY KEY,
|
||
config_type TEXT NOT NULL,
|
||
config_id TEXT NOT NULL,
|
||
config_name TEXT,
|
||
severity TEXT NOT NULL,
|
||
action TEXT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
user_id TEXT,
|
||
username TEXT,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS app_settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS system_prompts (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
agent TEXT NOT NULL DEFAULT 'chat',
|
||
content TEXT NOT NULL,
|
||
is_active INTEGER DEFAULT 0,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||
id TEXT PRIMARY KEY,
|
||
session_id TEXT,
|
||
message_id TEXT,
|
||
user_id TEXT,
|
||
severity TEXT NOT NULL,
|
||
source TEXT NOT NULL,
|
||
action TEXT NOT NULL,
|
||
message TEXT NOT NULL,
|
||
created_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS chat_sessions (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
agent_type TEXT NOT NULL DEFAULT 'chat',
|
||
title TEXT DEFAULT '',
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS terraform_workspaces (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||
session_id TEXT NOT NULL,
|
||
oci_config_id TEXT NOT NULL,
|
||
compartment_id TEXT,
|
||
name TEXT DEFAULT 'workspace',
|
||
tf_code TEXT,
|
||
status TEXT DEFAULT 'draft',
|
||
plan_output TEXT, apply_output TEXT, destroy_output TEXT,
|
||
error TEXT,
|
||
created_at TEXT DEFAULT (datetime('now')),
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS tf_valid_types (
|
||
name TEXT PRIMARY KEY,
|
||
kind TEXT NOT NULL DEFAULT 'resource',
|
||
category TEXT DEFAULT '',
|
||
required_args TEXT DEFAULT '',
|
||
blocks TEXT DEFAULT ''
|
||
);
|
||
CREATE TABLE IF NOT EXISTS tf_resource_docs (
|
||
slug TEXT PRIMARY KEY,
|
||
subcategory TEXT DEFAULT '',
|
||
example_usage TEXT DEFAULT '',
|
||
argument_ref TEXT DEFAULT '',
|
||
fetched_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
""")
|
||
c.execute("DELETE FROM config_logs WHERE created_at < datetime('now', '-30 days')")
|
||
c.execute("DELETE FROM chat_logs WHERE created_at < datetime('now', '-30 days')")
|
||
# ── Migrations ──
|
||
for col in ["system_prompt TEXT DEFAULT ''"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE genai_configs ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
for col in ["genai_config_id TEXT", "embedding_model_id TEXT DEFAULT 'cohere.embed-v4.0'"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE adb_vector_configs ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
for col in ["progress TEXT DEFAULT ''", "level INTEGER DEFAULT 2", "obp_checks INTEGER DEFAULT 0",
|
||
"raw_data INTEGER DEFAULT 0", "redact_output INTEGER DEFAULT 0", "worker_pid INTEGER"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE reports ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
for col in ["status TEXT DEFAULT 'done'"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE chat_messages ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
for col in ["compartment_id TEXT"]:
|
||
try:
|
||
c.execute(f"ALTER TABLE terraform_workspaces ADD COLUMN {col}")
|
||
except sqlite3.OperationalError:
|
||
pass
|
||
# Migrate legacy table_name from adb_vector_configs into adb_vector_tables
|
||
try:
|
||
for cfg_row in c.execute("SELECT id, table_name FROM adb_vector_configs WHERE table_name IS NOT NULL AND table_name != ''").fetchall():
|
||
if not c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=?", (cfg_row["id"], cfg_row["table_name"])).fetchone():
|
||
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
|
||
(str(uuid.uuid4()), cfg_row["id"], cfg_row["table_name"], "Migrado automaticamente"))
|
||
except Exception:
|
||
pass
|
||
# Backfill chat_sessions from existing chat_messages
|
||
try:
|
||
c.execute("""INSERT OR IGNORE INTO chat_sessions (id, user_id, agent_type, title, created_at, updated_at)
|
||
SELECT cm.session_id, cm.user_id,
|
||
CASE WHEN tw.id IS NOT NULL THEN 'terraform' ELSE 'chat' END,
|
||
SUBSTR((SELECT content FROM chat_messages cm2 WHERE cm2.session_id=cm.session_id AND cm2.role='user' ORDER BY cm2.created_at ASC LIMIT 1), 1, 80),
|
||
MIN(cm.created_at), MAX(cm.created_at)
|
||
FROM chat_messages cm
|
||
LEFT JOIN terraform_workspaces tw ON tw.session_id = cm.session_id
|
||
WHERE cm.session_id NOT IN (SELECT id FROM chat_sessions)
|
||
GROUP BY cm.session_id""")
|
||
except Exception:
|
||
pass
|
||
# Seed default system prompt if none exists for chat agent
|
||
if not c.execute("SELECT 1 FROM system_prompts WHERE agent='chat'").fetchone():
|
||
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
|
||
(str(uuid.uuid4()), "OCI CIS RAG Agent", "chat", RAG_DEFAULT_SYSTEM_PROMPT, 1))
|
||
tf_row = c.execute("SELECT id FROM system_prompts WHERE agent='terraform' AND is_active=1").fetchone()
|
||
if tf_row:
|
||
# Always sync DB prompt with code default
|
||
c.execute("UPDATE system_prompts SET content=? WHERE id=?", (TF_DEFAULT_SYSTEM_PROMPT, tf_row["id"]))
|
||
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))
|
||
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||
if not adm:
|
||
c.execute(
|
||
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin")
|
||
)
|
||
log.info("Default admin created: admin / admin123")
|
||
|
||
# ── Crypto helpers ────────────────────────────────────────────────────────────
|
||
def _hash_pw(pw): salt=secrets.token_hex(16); h=hashlib.pbkdf2_hmac("sha256",pw.encode(),salt.encode(),100_000); return f"{salt}:{h.hex()}"
|
||
def _verify_pw(pw,stored): salt,h=stored.split(":"); return hmac.compare_digest(hashlib.pbkdf2_hmac("sha256",pw.encode(),salt.encode(),100_000).hex(),h)
|
||
def _totp_secret(): return base64.b32encode(secrets.token_bytes(20)).decode()
|
||
def _totp_verify(secret,code,window=1):
|
||
key=base64.b32decode(secret); now=int(time.time())//30
|
||
for off in range(-window,window+1):
|
||
msg=struct.pack(">Q",now+off); h=hmac.new(key,msg,hashlib.sha1).digest(); o=h[-1]&0x0F
|
||
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 _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()
|
||
def _dec(v): return base64.b64decode(v.encode()).decode()
|
||
def _mask(v, show=6):
|
||
"""Mask a sensitive value, showing only the last `show` characters."""
|
||
if not v or len(v) <= show:
|
||
return "•" * 8
|
||
return "•" * min(len(v) - show, 20) + v[-show:]
|
||
def _safe_dec(v):
|
||
"""Decrypt a value, returning as-is if not encrypted (migration compat)."""
|
||
if not v:
|
||
return v
|
||
try:
|
||
return _dec(v)
|
||
except Exception:
|
||
return v
|
||
|
||
# ── OCI SDK Client Helper ─────────────────────────────────────────────────────
|
||
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")
|
||
|
||
# ── Auth deps ─────────────────────────────────────────────────────────────────
|
||
async def current_user(cred: HTTPAuthorizationCredentials = Depends(security)):
|
||
try: p = pyjwt.decode(cred.credentials, APP_SECRET, algorithms=[JWT_ALG])
|
||
except pyjwt.ExpiredSignatureError: raise HTTPException(401, "Token expirado")
|
||
except pyjwt.InvalidTokenError: raise HTTPException(401, "Token inválido")
|
||
with db() as c:
|
||
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
|
||
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
|
||
if not u or not s: raise HTTPException(401, "Sessão inválida")
|
||
return dict(u)
|
||
|
||
def _user_from_token(token: str):
|
||
"""Resolve user from a raw JWT token string (for query-param auth on downloads)."""
|
||
try: p = pyjwt.decode(token, APP_SECRET, algorithms=[JWT_ALG])
|
||
except Exception: raise HTTPException(401, "Token inválido")
|
||
with db() as c:
|
||
u = c.execute("SELECT * FROM users WHERE id=? AND is_active=1", (p["sub"],)).fetchone()
|
||
s = c.execute("SELECT * FROM sessions WHERE id=? AND is_active=1", (p["sid"],)).fetchone()
|
||
if not u or not s: raise HTTPException(401, "Sessão inválida")
|
||
return dict(u)
|
||
|
||
def require(*roles):
|
||
async def dep(u=Depends(current_user)):
|
||
if u["role"] not in roles: raise HTTPException(403, f"Requer role: {', '.join(roles)}")
|
||
return u
|
||
return dep
|
||
|
||
def _audit(uid, uname, action, resource=None, details=None, ip=None):
|
||
with db() as c:
|
||
c.execute("INSERT INTO audit_log (id,user_id,username,action,resource,details,ip) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), uid, uname, action, resource, details, ip))
|
||
|
||
def _config_log(config_type, config_id, config_name, severity, action, message, uid=None, uname=None):
|
||
with db() as c:
|
||
c.execute("INSERT INTO config_logs (id,config_type,config_id,config_name,severity,action,message,user_id,username) VALUES (?,?,?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), config_type, config_id, config_name or "", severity, action, str(message)[:2000], uid, uname))
|
||
|
||
def _chat_log(sid, mid, uid, severity, source, action, message):
|
||
with db() as c:
|
||
c.execute("INSERT INTO chat_logs (id,session_id,message_id,user_id,severity,source,action,message) VALUES (?,?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), sid, mid, uid, severity, source, action, str(message)[:2000]))
|
||
|
||
# ── Models ────────────────────────────────────────────────────────────────────
|
||
class LoginReq(BaseModel):
|
||
username: str; password: str; totp_code: Optional[str] = None
|
||
class RegisterReq(BaseModel):
|
||
first_name: str; last_name: str; username: str; email: str; password: str; role: str = "viewer"
|
||
class TOTPVerify(BaseModel):
|
||
totp_code: str
|
||
class ChangePwReq(BaseModel):
|
||
current_password: str; new_password: str
|
||
class UserUpdateReq(BaseModel):
|
||
email: Optional[str] = None; role: Optional[str] = None; is_active: Optional[bool] = None
|
||
class ChatMsg(BaseModel):
|
||
message: str; session_id: Optional[str] = None
|
||
# Option A: saved preset
|
||
genai_config_id: Optional[str] = None
|
||
# Option B: direct model selection (inline)
|
||
model_id: Optional[str] = None; oci_config_id: Optional[str] = None
|
||
genai_region: Optional[str] = None; compartment_id: Optional[str] = None
|
||
temperature: Optional[float] = None; max_tokens: Optional[int] = None
|
||
top_p: Optional[float] = None; top_k: Optional[int] = None
|
||
frequency_penalty: Optional[float] = None; presence_penalty: Optional[float] = None
|
||
use_tools: Optional[bool] = True
|
||
class RunReportReq(BaseModel):
|
||
config_id: str; regions: Optional[List[str]] = None
|
||
level: int = 2; obp: bool = False; raw: bool = False; redact_output: bool = False
|
||
class GenAIConfigReq(BaseModel):
|
||
name: str = "default"
|
||
oci_config_id: str; model_id: str; model_ocid: Optional[str] = None
|
||
compartment_id: str; genai_region: str
|
||
endpoint: Optional[str] = None
|
||
serving_type: str = "ON_DEMAND"; dedicated_endpoint_id: Optional[str] = None
|
||
temperature: float = 1; max_tokens: int = 6000; top_p: float = 0.95
|
||
top_k: int = 1; frequency_penalty: float = 0; presence_penalty: float = 0
|
||
is_default: bool = False
|
||
class IngestDocReq(BaseModel):
|
||
adb_config_id: str; documents: List[Dict[str, Any]]; table_name: Optional[str] = None
|
||
class MCPServerReq(BaseModel):
|
||
name: str; description: Optional[str] = None; server_type: str = "stdio"
|
||
command: Optional[str] = None; args: Optional[List[str]] = None
|
||
env_vars: Optional[Dict[str,str]] = None; url: Optional[str] = None
|
||
module_path: Optional[str] = None; tools: Optional[List[dict]] = None
|
||
linked_adb_id: Optional[str] = None
|
||
|
||
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
||
@app.post("/api/auth/login")
|
||
async def login(req: LoginReq, request: Request):
|
||
with db() as c:
|
||
u = c.execute("SELECT * FROM users WHERE username=? AND is_active=1", (req.username,)).fetchone()
|
||
if not u or not _verify_pw(req.password, u["password_hash"]): raise HTTPException(401, "Credenciais inválidas")
|
||
u = dict(u)
|
||
if u["mfa_enabled"]:
|
||
if not req.totp_code: return {"mfa_required": True, "message": "Código MFA necessário"}
|
||
if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(401, "Código MFA inválido")
|
||
sid = str(uuid.uuid4()); exp = (datetime.utcnow()+timedelta(hours=JWT_EXP_H)).isoformat()
|
||
with db() as c:
|
||
c.execute("INSERT INTO sessions (id,user_id,expires_at) VALUES (?,?,?)", (sid, u["id"], exp))
|
||
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (u["id"],))
|
||
_audit(u["id"], u["username"], "login", ip=request.client.host if request.client else None)
|
||
return {"token":_make_token(u["id"],u["role"],sid),
|
||
"user":{"id":u["id"],"first_name":u["first_name"],"last_name":u["last_name"],"username":u["username"],"email":u["email"],"role":u["role"],"mfa_enabled":bool(u["mfa_enabled"])}}
|
||
|
||
@app.post("/api/auth/logout")
|
||
async def logout_ep(u=Depends(current_user)):
|
||
with db() as c: c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],))
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/auth/register")
|
||
async def register(req: RegisterReq, adm=Depends(require("admin"))):
|
||
if req.role not in ("admin","user","viewer"): raise HTTPException(400, "Role inválida")
|
||
uid = str(uuid.uuid4())
|
||
with db() as c:
|
||
if c.execute("SELECT 1 FROM users WHERE username=?", (req.username,)).fetchone(): raise HTTPException(400, "Usuário já existe")
|
||
c.execute("INSERT INTO users (id,first_name,last_name,username,email,password_hash,role) VALUES (?,?,?,?,?,?,?)",
|
||
(uid, req.first_name, req.last_name, req.username, req.email, _hash_pw(req.password), req.role))
|
||
_audit(adm["id"], adm["username"], "create_user", uid, f"user={req.username} role={req.role}")
|
||
return {"id": uid, "username": req.username, "role": req.role}
|
||
|
||
@app.post("/api/auth/change-password")
|
||
async def change_pw(req: ChangePwReq, u=Depends(current_user)):
|
||
if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta")
|
||
with db() as c: c.execute("UPDATE users SET password_hash=? WHERE id=?", (_hash_pw(req.new_password), u["id"]))
|
||
return {"ok": True}
|
||
|
||
# ── MFA ───────────────────────────────────────────────────────────────────────
|
||
@app.post("/api/mfa/setup")
|
||
async def mfa_setup(u=Depends(current_user)):
|
||
sec = _totp_secret()
|
||
with db() as c: c.execute("UPDATE users SET mfa_secret=? WHERE id=?", (sec, u["id"]))
|
||
return {"secret": sec, "uri": _totp_uri(sec, u["username"])}
|
||
|
||
@app.post("/api/mfa/verify")
|
||
async def mfa_verify(req: TOTPVerify, u=Depends(current_user)):
|
||
if not u.get("mfa_secret"): raise HTTPException(400, "Chame /api/mfa/setup primeiro")
|
||
if not _totp_verify(u["mfa_secret"], req.totp_code): raise HTTPException(400, "Código inválido")
|
||
with db() as c: c.execute("UPDATE users SET mfa_enabled=1 WHERE id=?", (u["id"],))
|
||
return {"ok": True, "message": "MFA ativado"}
|
||
|
||
@app.post("/api/mfa/disable/{user_id}")
|
||
async def mfa_disable(user_id: str, adm=Depends(require("admin"))):
|
||
with db() as c: c.execute("UPDATE users SET mfa_enabled=0,mfa_secret=NULL WHERE id=?", (user_id,))
|
||
_audit(adm["id"], adm["username"], "disable_mfa", user_id)
|
||
return {"ok": True}
|
||
|
||
# ── Users ─────────────────────────────────────────────────────────────────────
|
||
@app.get("/api/users")
|
||
async def list_users(u=Depends(require("admin"))):
|
||
with db() as c: rows = c.execute("SELECT id,first_name,last_name,username,email,role,mfa_enabled,is_active,created_at,last_login FROM users").fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.get("/api/users/me")
|
||
async def me(u=Depends(current_user)):
|
||
return {k: u[k] for k in ("id","first_name","last_name","username","email","role","mfa_enabled")}
|
||
|
||
@app.put("/api/users/{uid}")
|
||
async def update_user(uid: str, req: UserUpdateReq, adm=Depends(require("admin"))):
|
||
sets, vals = [], []
|
||
if req.email is not None: sets.append("email=?"); vals.append(req.email)
|
||
if req.role is not None:
|
||
if req.role not in ("admin","user","viewer"): raise HTTPException(400, "Role inválida")
|
||
sets.append("role=?"); vals.append(req.role)
|
||
if req.is_active is not None: sets.append("is_active=?"); vals.append(int(req.is_active))
|
||
if sets:
|
||
vals.append(uid)
|
||
with db() as c: c.execute(f"UPDATE users SET {','.join(sets)} WHERE id=?", vals)
|
||
_audit(adm["id"], adm["username"], "update_user", uid)
|
||
return {"ok": True}
|
||
|
||
@app.delete("/api/users/{uid}")
|
||
async def del_user(uid: str, adm=Depends(require("admin"))):
|
||
if uid == adm["id"]: raise HTTPException(400, "Não pode desativar a si mesmo")
|
||
with db() as c: c.execute("UPDATE users SET is_active=0 WHERE id=?", (uid,))
|
||
_audit(adm["id"], adm["username"], "deactivate_user", uid)
|
||
return {"ok": True}
|
||
|
||
# ── OCI Config ────────────────────────────────────────────────────────────────
|
||
@app.post("/api/oci/config")
|
||
async def save_oci(
|
||
tenancy_name: str = Form(...), tenancy_ocid: str = Form(...),
|
||
user_ocid: str = Form(...), fingerprint: str = Form(...),
|
||
region: str = Form(...), compartment_id: str = Form(""),
|
||
key_passphrase: str = Form(""),
|
||
private_key: UploadFile = File(...), public_key: Optional[UploadFile] = File(None),
|
||
u = Depends(require("admin","user"))
|
||
):
|
||
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)
|
||
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) 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))
|
||
_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"])
|
||
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,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 = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
# Decrypt and mask sensitive fields for display
|
||
try:
|
||
d["tenancy_ocid"] = _mask(_dec(d["tenancy_ocid"]))
|
||
except Exception:
|
||
d["tenancy_ocid"] = _mask(d["tenancy_ocid"])
|
||
try:
|
||
d["user_ocid"] = _mask(_dec(d["user_ocid"]))
|
||
except Exception:
|
||
d["user_ocid"] = _mask(d["user_ocid"])
|
||
d["fingerprint"] = "•" * 20 # fingerprint fully masked
|
||
if d.get("compartment_id"):
|
||
try:
|
||
d["compartment_id"] = _mask(_dec(d["compartment_id"]))
|
||
except Exception:
|
||
d["compartment_id"] = _mask(d["compartment_id"])
|
||
result.append(d)
|
||
return result
|
||
|
||
@app.delete("/api/oci/configs/{cid}")
|
||
async def del_oci(cid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cfg=c.execute("SELECT * FROM oci_configs WHERE id=?",(cid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
if u["role"]!="admin" and cfg["user_id"]!=u["id"]: raise HTTPException(403)
|
||
c.execute("DELETE FROM oci_configs WHERE id=?",(cid,))
|
||
d=OCI_DIR/cid
|
||
if d.exists(): shutil.rmtree(d)
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/oci/configs/{cid}")
|
||
async def update_oci(
|
||
cid: str,
|
||
tenancy_name: str = Form(...), tenancy_ocid: str = Form(""),
|
||
user_ocid: str = Form(""), fingerprint: str = Form(""),
|
||
region: str = Form(...), compartment_id: str = Form(""),
|
||
key_passphrase: str = Form(""),
|
||
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
||
u = Depends(require("admin","user"))
|
||
):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
# Keep existing encrypted values if not provided (empty = keep current)
|
||
tenancy_ocid = tenancy_ocid or _safe_dec(existing["tenancy_ocid"])
|
||
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 ""
|
||
cdir = OCI_DIR / cid; cdir.mkdir(parents=True, exist_ok=True)
|
||
kp = cdir / "oci_api_key.pem"
|
||
if private_key and private_key.filename:
|
||
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:
|
||
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("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=? WHERE id=?",
|
||
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else 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"])
|
||
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
||
|
||
@app.post("/api/oci/test/{cid}")
|
||
async def test_oci(cid: str, u=Depends(require("admin","user"))):
|
||
cp = OCI_DIR / cid / "config"
|
||
cname = None
|
||
with db() as c:
|
||
row = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
if row: cname = row["tenancy_name"]
|
||
if not cp.exists():
|
||
_config_log("oci", cid, cname, "error", "test", "Config não encontrada", u["id"], u["username"])
|
||
return {"status":"error","message":"Config não encontrada"}
|
||
try:
|
||
r = subprocess.run(["oci","iam","region","list","--config-file",str(cp),"--output","json"],
|
||
capture_output=True, text=True, timeout=30, stdin=subprocess.DEVNULL)
|
||
if r.returncode == 0:
|
||
_config_log("oci", cid, cname, "success", "test", "Conexão OK", u["id"], u["username"])
|
||
return {"status":"success","message":"Conexão OK","data":json.loads(r.stdout)}
|
||
msg = r.stderr[:500]
|
||
if "passphrase" in msg.lower() or "getpass" in msg.lower():
|
||
msg = "A chave privada requer passphrase. Recadastre a credencial informando a Key Passphrase."
|
||
_config_log("oci", cid, cname, "error", "test", msg, u["id"], u["username"])
|
||
return {"status":"error","message":msg}
|
||
except FileNotFoundError:
|
||
_config_log("oci", cid, cname, "error", "test", "OCI CLI não disponível no container.", u["id"], u["username"])
|
||
return {"status":"error","message":"OCI CLI não disponível no container."}
|
||
except subprocess.TimeoutExpired:
|
||
_config_log("oci", cid, cname, "error", "test", "Timeout na conexão", u["id"], u["username"])
|
||
return {"status":"error","message":"Timeout na conexão"}
|
||
except Exception as e:
|
||
_config_log("oci", cid, cname, "error", "test", str(e)[:500], u["id"], u["username"])
|
||
return {"status":"error","message":str(e)[:500]}
|
||
|
||
# ── OCI Account Explorer ──────────────────────────────────────────────────────
|
||
@app.get("/api/oci/explore/{cid}/compartments")
|
||
async def explore_compartments(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
root = _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
|
||
comps = identity.list_compartments(root, compartment_id_in_subtree=True).data
|
||
return [{"id":cp.id,"name":cp.name,"lifecycle_state":cp.lifecycle_state,"description":cp.description or ""} for cp in comps if cp.lifecycle_state == "ACTIVE"]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/regions")
|
||
async def explore_regions(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
regions = identity.list_region_subscriptions(_safe_dec(cfg["tenancy_ocid"])).data
|
||
return [{"name":r.region_name,"key":r.region_key,"status":r.status,"is_home":r.is_home_region} for r in regions]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
def _explore_comp(cid):
|
||
"""Helper: resolve compartment from config."""
|
||
with db() as c:
|
||
cfg = c.execute("SELECT tenancy_ocid,compartment_id FROM oci_configs WHERE id=?", (cid,)).fetchone()
|
||
return cfg, _safe_dec(cfg["compartment_id"]) or _safe_dec(cfg["tenancy_ocid"])
|
||
|
||
@app.get("/api/oci/explore/{cid}/compartment-tree")
|
||
async def explore_compartment_tree(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
cfg, default_comp = _explore_comp(cid)
|
||
tenancy = _safe_dec(cfg["tenancy_ocid"]) or default_comp
|
||
user_comp = _safe_dec(cfg["compartment_id"])
|
||
# Use tenancy as root for the tree
|
||
tree = [{"id": tenancy, "name": "root (tenancy)", "parent_id": None}]
|
||
try:
|
||
comps = identity.list_compartments(tenancy, compartment_id_in_subtree=True).data
|
||
seen = {tenancy}
|
||
for cp in comps:
|
||
if cp.lifecycle_state == "ACTIVE":
|
||
tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id,
|
||
"lifecycle_state":cp.lifecycle_state})
|
||
seen.add(cp.id)
|
||
# If user's configured compartment is not in the tree, add it explicitly
|
||
if user_comp and user_comp != tenancy and user_comp not in seen:
|
||
try:
|
||
cp = identity.get_compartment(user_comp).data
|
||
tree.append({"id":cp.id, "name":cp.name, "parent_id":cp.compartment_id,
|
||
"lifecycle_state":cp.lifecycle_state})
|
||
except Exception:
|
||
tree.append({"id":user_comp, "name":"Meu Compartment", "parent_id":tenancy,
|
||
"lifecycle_state":"ACTIVE"})
|
||
except Exception:
|
||
# If listing from tenancy fails (no permission), list from user's compartment
|
||
if user_comp and user_comp != tenancy:
|
||
try:
|
||
cp = identity.get_compartment(user_comp).data
|
||
tree = [{"id":cp.id, "name":cp.name, "parent_id": None, "lifecycle_state":cp.lifecycle_state}]
|
||
sub = identity.list_compartments(user_comp, compartment_id_in_subtree=True).data
|
||
tree += [{"id":s.id, "name":s.name, "parent_id":s.compartment_id,
|
||
"lifecycle_state":s.lifecycle_state} for s in sub if s.lifecycle_state == "ACTIVE"]
|
||
except Exception:
|
||
tree = [{"id":user_comp, "name":"Meu Compartment", "parent_id": None, "lifecycle_state":"ACTIVE"}]
|
||
return tree
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/vcns")
|
||
async def explore_vcns(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
vcns = vn.list_vcns(comp).data
|
||
return [{"id":v.id,"display_name":v.display_name,"cidr_blocks":v.cidr_blocks,"lifecycle_state":v.lifecycle_state} for v in vcns]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/instances")
|
||
async def explore_instances(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
compute = oci.core.ComputeClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
insts = compute.list_instances(comp).data
|
||
return [{"id":i.id,"display_name":i.display_name,"shape":i.shape,"lifecycle_state":i.lifecycle_state,"region":i.region,"time_created":str(i.time_created)} for i in insts]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/databases")
|
||
async def explore_databases(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
db_client = oci.database.DatabaseClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
adbs = db_client.list_autonomous_databases(comp).data
|
||
return [{"id":a.id,"display_name":a.display_name,"db_name":a.db_name,"lifecycle_state":a.lifecycle_state,
|
||
"cpu_core_count":a.cpu_core_count,"data_storage_size_in_tbs":a.data_storage_size_in_tbs,
|
||
"is_free_tier":a.is_free_tier,"whitelisted_ips":a.whitelisted_ips or []} for a in adbs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/buckets")
|
||
async def explore_buckets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
os_client = oci.object_storage.ObjectStorageClient(config)
|
||
namespace = os_client.get_namespace().data
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
buckets = os_client.list_buckets(namespace, comp).data
|
||
return [{"name":b.name,"namespace":b.namespace,"time_created":str(b.time_created)} for b in buckets]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/subnets")
|
||
async def explore_subnets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
subs = vn.list_subnets(comp).data
|
||
return [{"id":s.id,"display_name":s.display_name,"cidr_block":s.cidr_block,"vcn_id":s.vcn_id,
|
||
"lifecycle_state":s.lifecycle_state,"prohibit_public_ip_on_vnic":s.prohibit_public_ip_on_vnic} for s in subs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/security_lists")
|
||
async def explore_security_lists(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
sls = vn.list_security_lists(comp).data
|
||
def _fmt_rule(r, direction, idx):
|
||
proto_map = {"6":"TCP","17":"UDP","1":"ICMP","all":"ALL"}
|
||
proto = proto_map.get(r.protocol, r.protocol)
|
||
src_dst = r.source if direction == "ingress" else r.destination
|
||
ports = ""
|
||
if r.tcp_options:
|
||
dp = r.tcp_options.destination_port_range
|
||
if dp: ports = f"{dp.min}-{dp.max}" if dp.min != dp.max else str(dp.min)
|
||
elif r.udp_options:
|
||
dp = r.udp_options.destination_port_range
|
||
if dp: ports = f"{dp.min}-{dp.max}" if dp.min != dp.max else str(dp.min)
|
||
return {"direction":direction,"protocol":proto,"source_dest":src_dst or "","ports":ports,
|
||
"stateless":r.is_stateless,"description":getattr(r,'description','') or "","rule_index":idx}
|
||
result = []
|
||
for s in sls:
|
||
ingress = [_fmt_rule(r,"ingress",i) for i,r in enumerate(s.ingress_security_rules or [])]
|
||
egress = [_fmt_rule(r,"egress",i) for i,r in enumerate(s.egress_security_rules or [])]
|
||
result.append({"id":s.id,"display_name":s.display_name,"vcn_id":s.vcn_id,"lifecycle_state":s.lifecycle_state,
|
||
"ingress_count":len(ingress),"egress_count":len(egress),"rules":ingress+egress})
|
||
return result
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.post("/api/oci/explore/{cid}/security_lists/{sl_id}/rules")
|
||
async def add_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(require("admin", "user"))):
|
||
"""Add a rule to a Security List (appends to existing rules)."""
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
region = req.get("region")
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
sl = vn.get_security_list(sl_id).data
|
||
direction = req.get("direction", "ingress").lower()
|
||
protocol = str(req.get("protocol", "6"))
|
||
source_dest = req.get("source_dest", "0.0.0.0/0")
|
||
port_min = req.get("port_min")
|
||
port_max = req.get("port_max")
|
||
description = req.get("description", "").strip() or None
|
||
is_stateless = req.get("is_stateless", False)
|
||
tcp_opts = None
|
||
udp_opts = None
|
||
if protocol == "6" and port_min is not None:
|
||
tcp_opts = oci.core.models.TcpOptions(
|
||
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
|
||
elif protocol == "17" and port_min is not None:
|
||
udp_opts = oci.core.models.UdpOptions(
|
||
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
|
||
ingress_rules = list(sl.ingress_security_rules or [])
|
||
egress_rules = list(sl.egress_security_rules or [])
|
||
if direction == "ingress":
|
||
ingress_rules.append(oci.core.models.IngressSecurityRule(
|
||
protocol=protocol, source=source_dest, source_type="CIDR_BLOCK",
|
||
is_stateless=is_stateless, description=description,
|
||
tcp_options=tcp_opts, udp_options=udp_opts))
|
||
else:
|
||
egress_rules.append(oci.core.models.EgressSecurityRule(
|
||
protocol=protocol, destination=source_dest, destination_type="CIDR_BLOCK",
|
||
is_stateless=is_stateless, description=description,
|
||
tcp_options=tcp_opts, udp_options=udp_opts))
|
||
update = oci.core.models.UpdateSecurityListDetails(
|
||
ingress_security_rules=ingress_rules, egress_security_rules=egress_rules)
|
||
vn.update_security_list(sl_id, update)
|
||
_audit(u["id"], u["username"], "seclist_add_rule", sl_id,
|
||
f"{direction} {protocol} {source_dest} port={port_min or 'ALL'}")
|
||
return {"ok": True, "message": "Regra adicionada com sucesso"}
|
||
except Exception as e:
|
||
raise HTTPException(400, str(e)[:500])
|
||
|
||
@app.delete("/api/oci/explore/{cid}/security_lists/{sl_id}/rules")
|
||
async def remove_security_list_rule(cid: str, sl_id: str, req: dict, u=Depends(require("admin", "user"))):
|
||
"""Remove a rule from a Security List by index."""
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
region = req.get("region")
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
sl = vn.get_security_list(sl_id).data
|
||
direction = req.get("direction", "ingress").lower()
|
||
rule_index = req.get("rule_index")
|
||
if rule_index is None: raise HTTPException(400, "rule_index obrigatório")
|
||
ingress_rules = list(sl.ingress_security_rules or [])
|
||
egress_rules = list(sl.egress_security_rules or [])
|
||
if direction == "ingress":
|
||
if 0 <= rule_index < len(ingress_rules):
|
||
removed = ingress_rules.pop(rule_index)
|
||
desc = f"ingress idx={rule_index} src={removed.source}"
|
||
else:
|
||
raise HTTPException(400, "Índice inválido")
|
||
else:
|
||
if 0 <= rule_index < len(egress_rules):
|
||
removed = egress_rules.pop(rule_index)
|
||
desc = f"egress idx={rule_index} dst={removed.destination}"
|
||
else:
|
||
raise HTTPException(400, "Índice inválido")
|
||
update = oci.core.models.UpdateSecurityListDetails(
|
||
ingress_security_rules=ingress_rules, egress_security_rules=egress_rules)
|
||
vn.update_security_list(sl_id, update)
|
||
_audit(u["id"], u["username"], "seclist_remove_rule", sl_id, desc)
|
||
return {"ok": True, "message": "Regra removida com sucesso"}
|
||
except Exception as e:
|
||
raise HTTPException(400, str(e)[:500])
|
||
|
||
@app.get("/api/oci/explore/{cid}/block_volumes")
|
||
async def explore_block_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
bs = oci.core.BlockstorageClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
vols = bs.list_volumes(compartment_id=comp).data
|
||
return [{"id":v.id,"display_name":v.display_name,"size_in_gbs":v.size_in_gbs,
|
||
"lifecycle_state":v.lifecycle_state,"vpus_per_gb":v.vpus_per_gb} for v in vols]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/load_balancers")
|
||
async def explore_load_balancers(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
lb = oci.load_balancer.LoadBalancerClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
lbs = lb.list_load_balancers(comp).data
|
||
return [{"id":l.id,"display_name":l.display_name,"lifecycle_state":l.lifecycle_state,
|
||
"shape_name":l.shape_name,"ip_addresses":[ip.ip_address for ip in (l.ip_addresses or [])]} for l in lbs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/functions")
|
||
async def explore_functions(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
fn = oci.functions.FunctionsManagementClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
apps = fn.list_applications(comp).data
|
||
result = []
|
||
for a in apps:
|
||
fns = fn.list_functions(a.id).data
|
||
for f in fns:
|
||
result.append({"id":f.id,"display_name":f.display_name,"application":a.display_name,
|
||
"lifecycle_state":f.lifecycle_state,"memory_in_mbs":f.memory_in_mbs,
|
||
"timeout_in_seconds":f.timeout_in_seconds,"image":f.image})
|
||
return result
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/container_instances")
|
||
async def explore_container_instances(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
ci = oci.container_instances.ContainerInstanceClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
cis = ci.list_container_instances(comp).data
|
||
return [{"id":c.id,"display_name":c.display_name,"lifecycle_state":c.lifecycle_state,
|
||
"container_count":c.container_count,"shape":c.shape,
|
||
"time_created":str(c.time_created)} for c in cis]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/nsgs")
|
||
async def explore_nsgs(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
nsgs = vn.list_network_security_groups(compartment_id=comp).data
|
||
result = []
|
||
proto_map = {"1": "ICMP", "6": "TCP", "17": "UDP", "all": "ALL"}
|
||
for n in nsgs:
|
||
rules_data = oci.pagination.list_call_get_all_results(
|
||
vn.list_network_security_group_security_rules, n.id).data
|
||
rules = []
|
||
for r in rules_data:
|
||
ports = ""
|
||
if r.tcp_options and r.tcp_options.destination_port_range:
|
||
pr = r.tcp_options.destination_port_range
|
||
ports = str(pr.min) if pr.min == pr.max else f"{pr.min}-{pr.max}"
|
||
elif r.udp_options and r.udp_options.destination_port_range:
|
||
pr = r.udp_options.destination_port_range
|
||
ports = str(pr.min) if pr.min == pr.max else f"{pr.min}-{pr.max}"
|
||
src_dst = ""
|
||
src_dst_type = ""
|
||
if r.direction == "INGRESS":
|
||
src_dst = r.source or ""
|
||
src_dst_type = r.source_type or ""
|
||
else:
|
||
src_dst = r.destination or ""
|
||
src_dst_type = r.destination_type or ""
|
||
rules.append({
|
||
"id": r.id, "direction": r.direction,
|
||
"protocol": proto_map.get(r.protocol, r.protocol),
|
||
"protocol_num": r.protocol,
|
||
"source_dest": src_dst, "source_dest_type": src_dst_type,
|
||
"ports": ports, "is_stateless": r.is_stateless or False,
|
||
"description": r.description or ""
|
||
})
|
||
result.append({"id": n.id, "display_name": n.display_name, "vcn_id": n.vcn_id,
|
||
"lifecycle_state": n.lifecycle_state, "time_created": str(n.time_created),
|
||
"rule_count": len(rules), "rules": rules})
|
||
return result
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.post("/api/oci/explore/{cid}/nsgs/{nsg_id}/rules")
|
||
async def add_nsg_rule(cid: str, nsg_id: str, req: dict, u=Depends(require("admin", "user"))):
|
||
"""Add a security rule to an NSG."""
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
region = req.get("region")
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
direction = req.get("direction", "INGRESS").upper()
|
||
protocol = str(req.get("protocol", "6"))
|
||
source_dest = req.get("source_dest", "0.0.0.0/0")
|
||
source_dest_type = req.get("source_dest_type", "CIDR_BLOCK")
|
||
port_min = req.get("port_min")
|
||
port_max = req.get("port_max")
|
||
description = req.get("description", "")
|
||
is_stateless = req.get("is_stateless", False)
|
||
rule = {"direction": direction, "protocol": protocol, "is_stateless": is_stateless, "description": description}
|
||
if direction == "INGRESS":
|
||
rule["source"] = source_dest
|
||
rule["source_type"] = source_dest_type
|
||
else:
|
||
rule["destination"] = source_dest
|
||
rule["destination_type"] = source_dest_type
|
||
if protocol == "6" and port_min is not None:
|
||
rule["tcp_options"] = oci.core.models.TcpOptions(
|
||
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
|
||
elif protocol == "17" and port_min is not None:
|
||
rule["udp_options"] = oci.core.models.UdpOptions(
|
||
destination_port_range=oci.core.models.PortRange(min=int(port_min), max=int(port_max or port_min)))
|
||
details = oci.core.models.AddNetworkSecurityGroupSecurityRulesDetails(
|
||
security_rules=[oci.core.models.AddSecurityRuleDetails(**rule)])
|
||
resp = vn.add_network_security_group_security_rules(nsg_id, details)
|
||
_audit(u["id"], u["username"], "nsg_add_rule", nsg_id, f"{direction} {protocol} {source_dest} {port_min or 'ALL'}")
|
||
added = [{"id": r.id} for r in (resp.data.security_rules or [])]
|
||
return {"ok": True, "message": "Regra adicionada com sucesso", "rules_added": added}
|
||
except Exception as e:
|
||
raise HTTPException(400, str(e)[:500])
|
||
|
||
@app.delete("/api/oci/explore/{cid}/nsgs/{nsg_id}/rules/{rule_id}")
|
||
async def remove_nsg_rule(cid: str, nsg_id: str, rule_id: str, region: str = Query(None), u=Depends(require("admin", "user"))):
|
||
"""Remove a security rule from an NSG."""
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
details = oci.core.models.RemoveNetworkSecurityGroupSecurityRulesDetails(security_rule_ids=[rule_id])
|
||
vn.remove_network_security_group_security_rules(nsg_id, details)
|
||
_audit(u["id"], u["username"], "nsg_remove_rule", nsg_id, f"rule_id={rule_id}")
|
||
return {"ok": True, "message": "Regra removida com sucesso"}
|
||
except Exception as e:
|
||
raise HTTPException(400, str(e)[:500])
|
||
|
||
@app.get("/api/oci/explore/{cid}/route_tables")
|
||
async def explore_route_tables(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
rts = vn.list_route_tables(comp).data
|
||
result = []
|
||
for rt in rts:
|
||
routes = []
|
||
for r in (rt.route_rules or []):
|
||
target_type = r.network_entity_id.split(".")[1] if r.network_entity_id and "." in r.network_entity_id else "unknown"
|
||
routes.append({"destination":r.destination or "","destination_type":r.destination_type or "",
|
||
"target_id":r.network_entity_id or "","target_type":target_type,
|
||
"description":getattr(r,'description','') or ""})
|
||
result.append({"id":rt.id,"display_name":rt.display_name,"vcn_id":rt.vcn_id,"lifecycle_state":rt.lifecycle_state,
|
||
"route_count":len(routes),"routes":routes})
|
||
return result
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/nat_gateways")
|
||
async def explore_nat_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
ngs = vn.list_nat_gateways(comp).data
|
||
return [{"id":n.id,"display_name":n.display_name,"vcn_id":n.vcn_id,"lifecycle_state":n.lifecycle_state,
|
||
"nat_ip":n.nat_ip,"block_traffic":n.block_traffic} for n in ngs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/internet_gateways")
|
||
async def explore_internet_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
igs = vn.list_internet_gateways(comp).data
|
||
return [{"id":g.id,"display_name":g.display_name,"vcn_id":g.vcn_id,"lifecycle_state":g.lifecycle_state,
|
||
"is_enabled":g.is_enabled} for g in igs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/service_gateways")
|
||
async def explore_service_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
sgs = vn.list_service_gateways(comp).data
|
||
return [{"id":g.id,"display_name":g.display_name,"vcn_id":g.vcn_id,"lifecycle_state":g.lifecycle_state,
|
||
"services":[s.service_name for s in (g.services or [])]} for g in sgs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/public_ips")
|
||
async def explore_public_ips(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
vn = oci.core.VirtualNetworkClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
ips = vn.list_public_ips(scope="REGION", compartment_id=comp).data
|
||
return [{"id":ip.id,"display_name":ip.display_name or "—","ip_address":ip.ip_address,
|
||
"lifecycle_state":ip.lifecycle_state,"lifetime":ip.lifetime,
|
||
"assigned_entity_type":ip.assigned_entity_type} for ip in ips]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/boot_volumes")
|
||
async def explore_boot_volumes(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
identity = oci.identity.IdentityClient(config)
|
||
bs = oci.core.BlockstorageClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
ads = identity.list_availability_domains(comp).data
|
||
result = []
|
||
for ad in ads:
|
||
bvs = bs.list_boot_volumes(availability_domain=ad.name, compartment_id=comp).data
|
||
for v in bvs:
|
||
result.append({"id":v.id,"display_name":v.display_name,"size_in_gbs":v.size_in_gbs,
|
||
"lifecycle_state":v.lifecycle_state,"availability_domain":ad.name,
|
||
"vpus_per_gb":v.vpus_per_gb})
|
||
return result
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/instance_pools")
|
||
async def explore_instance_pools(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
cm = oci.core.ComputeManagementClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
pools = cm.list_instance_pools(comp).data
|
||
return [{"id":p.id,"display_name":p.display_name,"lifecycle_state":p.lifecycle_state,
|
||
"size":p.size,"time_created":str(p.time_created)} for p in pools]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/oke_clusters")
|
||
async def explore_oke_clusters(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
ce = oci.container_engine.ContainerEngineClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
clusters = ce.list_clusters(comp).data
|
||
return [{"id":c.id,"name":c.name,"lifecycle_state":c.lifecycle_state,
|
||
"kubernetes_version":c.kubernetes_version,"vcn_id":c.vcn_id,
|
||
"endpoint_config":c.endpoint_config.is_public_ip_enabled if c.endpoint_config else None} for c in clusters]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/file_systems")
|
||
async def explore_file_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
identity = oci.identity.IdentityClient(config)
|
||
fss = oci.file_storage.FileStorageClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
ads = identity.list_availability_domains(comp).data
|
||
result = []
|
||
for ad in ads:
|
||
fs_list = fss.list_file_systems(comp, ad.name).data
|
||
for f in fs_list:
|
||
result.append({"id":f.id,"display_name":f.display_name,"lifecycle_state":f.lifecycle_state,
|
||
"availability_domain":ad.name,"metered_bytes":f.metered_bytes,
|
||
"time_created":str(f.time_created)})
|
||
return result
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/mount_targets")
|
||
async def explore_mount_targets(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
identity = oci.identity.IdentityClient(config)
|
||
fss = oci.file_storage.FileStorageClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
ads = identity.list_availability_domains(comp).data
|
||
result = []
|
||
for ad in ads:
|
||
mts = fss.list_mount_targets(comp, ad.name).data
|
||
for m in mts:
|
||
result.append({"id":m.id,"display_name":m.display_name,"lifecycle_state":m.lifecycle_state,
|
||
"availability_domain":ad.name,"subnet_id":m.subnet_id,
|
||
"private_ip_ids":m.private_ip_ids})
|
||
return result
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/db_systems")
|
||
async def explore_db_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
db_client = oci.database.DatabaseClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
dbs = db_client.list_db_systems(comp).data
|
||
return [{"id":d.id,"display_name":d.display_name,"lifecycle_state":d.lifecycle_state,
|
||
"shape":d.shape,"database_edition":d.database_edition,
|
||
"cpu_core_count":d.cpu_core_count,"node_count":d.node_count} for d in dbs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/mysql_db_systems")
|
||
async def explore_mysql_db_systems(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
mysql = oci.mysql.DbSystemClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
dbs = mysql.list_db_systems(comp).data
|
||
return [{"id":d.id,"display_name":d.display_name,"lifecycle_state":d.lifecycle_state,
|
||
"shape_name":d.shape_name,"mysql_version":d.mysql_version,
|
||
"is_highly_available":d.is_highly_available} for d in dbs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/alarms")
|
||
async def explore_alarms(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
mon = oci.monitoring.MonitoringClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
alarms = mon.list_alarms(comp).data
|
||
return [{"id":a.id,"display_name":a.display_name,"lifecycle_state":a.lifecycle_state,
|
||
"severity":a.severity,"namespace":a.namespace,"is_enabled":a.is_enabled} for a in alarms]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/log_groups")
|
||
async def explore_log_groups(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
logging_client = oci.logging.LoggingManagementClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
lgs = logging_client.list_log_groups(comp).data
|
||
return [{"id":g.id,"display_name":g.display_name,"time_created":str(g.time_created),
|
||
"description":g.description or ""} for g in lgs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/iam_users")
|
||
async def explore_iam_users(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
cfg, _ = _explore_comp(cid)
|
||
tenancy = _safe_dec(cfg["tenancy_ocid"])
|
||
users = identity.list_users(tenancy).data
|
||
return [{"id":u2.id,"name":u2.name,"email":u2.email or "","lifecycle_state":u2.lifecycle_state,
|
||
"is_mfa_activated":u2.is_mfa_activated,"time_created":str(u2.time_created),
|
||
"last_successful_login_time":str(u2.last_successful_login_time) if u2.last_successful_login_time else None} for u2 in users]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/iam_groups")
|
||
async def explore_iam_groups(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
cfg, _ = _explore_comp(cid)
|
||
tenancy = _safe_dec(cfg["tenancy_ocid"])
|
||
groups = identity.list_groups(tenancy).data
|
||
return [{"id":g.id,"name":g.name,"description":g.description or "","lifecycle_state":g.lifecycle_state,
|
||
"time_created":str(g.time_created)} for g in groups]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/iam_policies")
|
||
async def explore_iam_policies(cid: str, compartment_id: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
cfg, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or _safe_dec(cfg["tenancy_ocid"]) or default_comp
|
||
policies = identity.list_policies(comp).data
|
||
return [{"id":p.id,"name":p.name,"description":p.description or "","lifecycle_state":p.lifecycle_state,
|
||
"statements":p.statements,"time_created":str(p.time_created)} for p in policies]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/iam_dynamic_groups")
|
||
async def explore_iam_dynamic_groups(cid: str, u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
identity = oci.identity.IdentityClient(config)
|
||
cfg, _ = _explore_comp(cid)
|
||
tenancy = _safe_dec(cfg["tenancy_ocid"])
|
||
dgs = identity.list_dynamic_groups(tenancy).data
|
||
return [{"id":g.id,"name":g.name,"description":g.description or "","lifecycle_state":g.lifecycle_state,
|
||
"matching_rule":g.matching_rule,"time_created":str(g.time_created)} for g in dgs]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/vaults")
|
||
async def explore_vaults(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
kms = oci.key_management.KmsVaultClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
vaults = kms.list_vaults(comp).data
|
||
return [{"id":v.id,"display_name":v.display_name,"lifecycle_state":v.lifecycle_state,
|
||
"vault_type":v.vault_type,"crypto_endpoint":v.crypto_endpoint,
|
||
"time_created":str(v.time_created)} for v in vaults]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/dns_zones")
|
||
async def explore_dns_zones(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
dns = oci.dns.DnsClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
zones = dns.list_zones(comp).data
|
||
return [{"id":z.id,"name":z.name,"lifecycle_state":z.lifecycle_state,
|
||
"zone_type":z.zone_type,"serial":z.serial,
|
||
"time_created":str(z.time_created)} for z in zones]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/api_gateways")
|
||
async def explore_api_gateways(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
apigw = oci.apigateway.GatewayClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
gws = apigw.list_gateways(comp).data
|
||
return [{"id":g.id,"display_name":g.display_name,"lifecycle_state":g.lifecycle_state,
|
||
"endpoint_type":g.endpoint_type,"hostname":g.hostname,
|
||
"subnet_id":g.subnet_id,"time_created":str(g.time_created)} for g in gws]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/notification_topics")
|
||
async def explore_notification_topics(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
ons = oci.ons.NotificationControlPlaneClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
topics = ons.list_topics(comp).data
|
||
return [{"id":t.topic_id,"name":t.name,"lifecycle_state":t.lifecycle_state,
|
||
"description":t.description or "","api_endpoint":t.api_endpoint,
|
||
"time_created":str(t.time_created)} for t in topics]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/events_rules")
|
||
async def explore_events_rules(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
events = oci.events.EventsClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
rules = events.list_rules(comp).data
|
||
return [{"id":r.id,"display_name":r.display_name,"lifecycle_state":r.lifecycle_state,
|
||
"condition":r.condition,"is_enabled":r.is_enabled,
|
||
"description":r.description or "","time_created":str(r.time_created)} for r in rules]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/network_firewalls")
|
||
async def explore_network_firewalls(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
fw = oci.network_firewall.NetworkFirewallClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
firewalls = fw.list_network_firewalls(compartment_id=comp).data
|
||
return [{"id":f.id,"display_name":f.display_name,"lifecycle_state":f.lifecycle_state,
|
||
"subnet_id":f.subnet_id,"network_firewall_policy_id":f.network_firewall_policy_id,
|
||
"time_created":str(f.time_created)} for f in firewalls]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
@app.get("/api/oci/explore/{cid}/network_firewall_policies")
|
||
async def explore_network_firewall_policies(cid: str, compartment_id: str = Query(None), region: str = Query(None), u=Depends(current_user)):
|
||
try:
|
||
import oci
|
||
config = _get_oci_config(cid)
|
||
if region: config["region"] = region
|
||
fw = oci.network_firewall.NetworkFirewallClient(config)
|
||
_, default_comp = _explore_comp(cid)
|
||
comp = compartment_id or default_comp
|
||
policies = fw.list_network_firewall_policies(compartment_id=comp).data
|
||
return [{"id":p.id,"display_name":p.display_name,"lifecycle_state":p.lifecycle_state,
|
||
"time_created":str(p.time_created)} for p in policies]
|
||
except Exception as e:
|
||
return {"error": str(e)[:500]}
|
||
|
||
# ── OCI GenAI Config & Chat ───────────────────────────────────────────────────
|
||
@app.get("/api/genai/models")
|
||
async def list_genai_models(u=Depends(current_user)):
|
||
return {"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS}
|
||
|
||
@app.post("/api/genai/config")
|
||
async def save_genai(req: GenAIConfigReq, u=Depends(require("admin","user"))):
|
||
gid = str(uuid.uuid4())
|
||
ep = req.endpoint or f"https://inference.generativeai.{req.genai_region}.oci.oraclecloud.com"
|
||
with db() as c:
|
||
if req.is_default:
|
||
c.execute("UPDATE genai_configs SET is_default=0 WHERE user_id=?", (u["id"],))
|
||
c.execute(
|
||
"""INSERT INTO genai_configs (id,user_id,name,oci_config_id,model_id,model_ocid,compartment_id,
|
||
genai_region,endpoint,serving_type,dedicated_endpoint_id,temperature,max_tokens,top_p,top_k,
|
||
frequency_penalty,presence_penalty,is_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||
(gid, u["id"], req.name, req.oci_config_id, req.model_id, req.model_ocid,
|
||
req.compartment_id, req.genai_region, ep, req.serving_type, req.dedicated_endpoint_id,
|
||
req.temperature, req.max_tokens, req.top_p, req.top_k,
|
||
req.frequency_penalty, req.presence_penalty, int(req.is_default)))
|
||
_audit(u["id"], u["username"], "save_genai_config", gid, req.model_id)
|
||
_config_log("genai", gid, req.name, "success", "save", f"Modelo salvo: {req.model_id} ({req.genai_region})", u["id"], u["username"])
|
||
return {"id": gid, "model_id": req.model_id, "endpoint": ep}
|
||
|
||
@app.get("/api/genai/configs")
|
||
async def list_genai(u=Depends(current_user)):
|
||
with db() as c:
|
||
if u["role"]=="admin": rows=c.execute("SELECT * FROM genai_configs").fetchall()
|
||
else: rows=c.execute("SELECT * FROM genai_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.delete("/api/genai/configs/{gid}")
|
||
async def del_genai(gid: str, u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("DELETE FROM genai_configs WHERE id=?", (gid,))
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/genai/configs/{gid}")
|
||
async def update_genai(gid: str, req: GenAIConfigReq, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM genai_configs WHERE id=?", (gid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
ep = req.endpoint or f"https://inference.generativeai.{req.genai_region}.oci.oraclecloud.com"
|
||
with db() as c:
|
||
if req.is_default:
|
||
c.execute("UPDATE genai_configs SET is_default=0 WHERE user_id=?", (u["id"],))
|
||
c.execute(
|
||
"""UPDATE genai_configs SET name=?,oci_config_id=?,model_id=?,model_ocid=?,compartment_id=?,
|
||
genai_region=?,endpoint=?,serving_type=?,dedicated_endpoint_id=?,temperature=?,max_tokens=?,
|
||
top_p=?,top_k=?,frequency_penalty=?,presence_penalty=?,is_default=? WHERE id=?""",
|
||
(req.name, req.oci_config_id, req.model_id, req.model_ocid,
|
||
req.compartment_id, req.genai_region, ep, req.serving_type, req.dedicated_endpoint_id,
|
||
req.temperature, req.max_tokens, req.top_p, req.top_k,
|
||
req.frequency_penalty, req.presence_penalty, int(req.is_default), gid))
|
||
_audit(u["id"], u["username"], "update_genai_config", gid, req.model_id)
|
||
_config_log("genai", gid, req.name, "success", "save", f"Modelo atualizado: {req.model_id} ({req.genai_region})", u["id"], u["username"])
|
||
return {"id": gid, "model_id": req.model_id, "endpoint": ep}
|
||
|
||
@app.post("/api/genai/test/{gid}")
|
||
async def test_genai(gid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
gc = c.execute("SELECT * FROM genai_configs WHERE id=?",(gid,)).fetchone()
|
||
if not gc: raise HTTPException(404)
|
||
gname = gc["name"]
|
||
try:
|
||
resp, _, _ = _call_genai(dict(gc), "Say 'connection successful' in one short sentence.")
|
||
_config_log("genai", gid, gname, "success", "test", f"GenAI OK: {resp[:200]}", u["id"], u["username"])
|
||
return {"status":"success","message":"GenAI OK","response":resp[:300]}
|
||
except Exception as e:
|
||
msg = str(e)[:500]
|
||
_config_log("genai", gid, gname, "error", "test", msg, u["id"], u["username"])
|
||
return {"status":"error","message":msg}
|
||
|
||
# ── Chat Memory Compaction ─────────────────────────────────────────────────────
|
||
|
||
def _estimate_tokens(text: str) -> int:
|
||
return len(text) // 4
|
||
|
||
def _estimate_history_tokens(history: list) -> int:
|
||
return sum(_estimate_tokens(h["content"]) for h in history)
|
||
|
||
def _should_compact(history: list) -> bool:
|
||
if len(history) < COMPACT_MIN_MESSAGES:
|
||
return False
|
||
return _estimate_history_tokens(history) > COMPACT_TOKEN_THRESHOLD
|
||
|
||
def _generate_summary(genai_cfg: dict, messages_to_summarize: list) -> str:
|
||
"""Use the same GenAI model to summarize older conversation messages."""
|
||
conversation_text = ""
|
||
for m in messages_to_summarize:
|
||
role_label = "Usuário" if m["role"] == "user" else "Assistente"
|
||
# Truncate very long messages in the summary input
|
||
content = m["content"][:3000] if len(m["content"]) > 3000 else m["content"]
|
||
conversation_text += f"{role_label}: {content}\n\n"
|
||
|
||
summary_prompt = (
|
||
"Resuma a conversa abaixo de forma compacta (máximo 2-3 parágrafos curtos) que capture:\n"
|
||
"- Tópicos discutidos e perguntas do usuário\n"
|
||
"- Decisões tomadas, conclusões e resultados importantes\n"
|
||
"- Resultados de ferramentas/tools executadas (checks PASS/FAIL, scores, recursos encontrados)\n"
|
||
"- Contexto OCI/CIS relevante (compartments, regiões, config_ids usados)\n"
|
||
"- Dados numéricos importantes (scores, contagens, OCIDs mencionados)\n\n"
|
||
"Seja conciso mas preserve TODA informação acionável e dados concretos. "
|
||
"Não inclua saudações ou formalidades.\n\n"
|
||
"CONVERSA:\n" + conversation_text
|
||
)
|
||
|
||
summary_cfg = dict(genai_cfg)
|
||
summary_cfg["system_prompt"] = "Você é um sumarizador. Responda apenas com o resumo."
|
||
summary_cfg["max_tokens"] = COMPACT_SUMMARY_MAX_TOKENS
|
||
|
||
try:
|
||
text, _, _ = _call_genai(summary_cfg, summary_prompt, history=None, tools=None)
|
||
return text.strip()
|
||
except Exception as e:
|
||
log.warning(f"Summary generation failed: {e}")
|
||
return ""
|
||
|
||
def _compact_history(session_id: str, user_id: str, genai_cfg: dict, history: list) -> list:
|
||
"""Compact conversation history by summarizing older messages."""
|
||
if len(history) <= COMPACT_KEEP_RECENT:
|
||
return history
|
||
|
||
messages_to_summarize = history[:-COMPACT_KEEP_RECENT]
|
||
recent_messages = history[-COMPACT_KEEP_RECENT:]
|
||
|
||
# Check for existing summary
|
||
with db() as c:
|
||
row = c.execute(
|
||
"SELECT summary, messages_compacted FROM chat_summaries "
|
||
"WHERE session_id=? ORDER BY created_at DESC LIMIT 1",
|
||
(session_id,)
|
||
).fetchone()
|
||
if row:
|
||
existing_summary = row["summary"]
|
||
prev_compacted = row["messages_compacted"]
|
||
# Reuse if no new messages to summarize
|
||
if len(messages_to_summarize) <= prev_compacted:
|
||
return [{"role": "assistant", "content": f"[Resumo da conversa anterior: {existing_summary}]"}] + recent_messages
|
||
else:
|
||
existing_summary = None
|
||
|
||
# Include existing summary as prefix for incremental compaction
|
||
if existing_summary:
|
||
messages_to_summarize = [{"role": "assistant", "content": f"[Resumo anterior: {existing_summary}]"}] + messages_to_summarize
|
||
|
||
summary_text = _generate_summary(genai_cfg, messages_to_summarize)
|
||
|
||
if not summary_text:
|
||
# Fallback: truncate keeping recent messages that fit in budget
|
||
truncated = []
|
||
budget = 6000
|
||
for m in reversed(history):
|
||
t = _estimate_tokens(m["content"])
|
||
if budget - t < 0:
|
||
break
|
||
truncated.insert(0, m)
|
||
budget -= t
|
||
return truncated
|
||
|
||
# Save summary to DB
|
||
actual_count = len(messages_to_summarize) - (1 if existing_summary else 0)
|
||
with db() as c:
|
||
last_msg = c.execute(
|
||
"SELECT id FROM chat_messages WHERE session_id=? AND role IN ('user','assistant') "
|
||
"ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||
(session_id, max(0, actual_count - 1))
|
||
).fetchone()
|
||
last_id = last_msg["id"] if last_msg else "unknown"
|
||
c.execute(
|
||
"INSERT INTO chat_summaries (id,session_id,user_id,summary,messages_compacted,up_to_message_id) VALUES (?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), session_id, user_id, summary_text, actual_count, last_id)
|
||
)
|
||
|
||
log.info(f"Generated summary for session {session_id}: {len(summary_text)} chars, {actual_count} msgs compacted")
|
||
return [{"role": "assistant", "content": f"[Resumo da conversa anterior: {summary_text}]"}] + recent_messages
|
||
|
||
# ── GenAI Call ─────────────────────────────────────────────────────────────────
|
||
|
||
def _call_genai(gc: dict, message: str, history: list = None, tools: list = None,
|
||
tool_results_cohere: list = None,
|
||
extra_messages: list = None,
|
||
attachments: list = None) -> tuple:
|
||
"""
|
||
Call OCI Generative AI with optional tool use support.
|
||
Returns (text, tool_calls, tool_calls_raw) tuple.
|
||
tool_calls is a list of dicts or None. tool_calls_raw is the raw OCI SDK objects (for Generic format continuations).
|
||
extra_messages: list of raw OCI SDK message objects to append after the user message (for tool use loop accumulation).
|
||
"""
|
||
import oci
|
||
|
||
# Load OCI config from stored credentials (same as ~/.oci/config)
|
||
config_path = str(OCI_DIR / gc["oci_config_id"] / "config")
|
||
config = oci.config.from_file(config_path, "DEFAULT")
|
||
|
||
# Service endpoint - built from region
|
||
endpoint = gc["endpoint"]
|
||
|
||
# Create inference client with retry strategy and timeout
|
||
generative_ai_inference_client = oci.generative_ai_inference.GenerativeAiInferenceClient(
|
||
config=config,
|
||
service_endpoint=endpoint,
|
||
retry_strategy=oci.retry.NoneRetryStrategy(),
|
||
timeout=(10, 600)
|
||
)
|
||
|
||
# System prompt
|
||
system_prompt = gc.get("system_prompt", "")
|
||
|
||
# Build ChatDetails
|
||
chat_detail = oci.generative_ai_inference.models.ChatDetails()
|
||
|
||
# Determine API format from model catalog
|
||
model_info = GENAI_MODELS.get(gc["model_id"], {})
|
||
api_format = model_info.get("api_format", "GENERIC")
|
||
|
||
if api_format == "COHERE":
|
||
# ── Cohere models (CohereChatRequest) ──
|
||
chat_request = oci.generative_ai_inference.models.CohereChatRequest()
|
||
chat_request.api_format = oci.generative_ai_inference.models.BaseChatRequest.API_FORMAT_COHERE
|
||
if system_prompt:
|
||
chat_request.preamble_override = system_prompt
|
||
chat_request.message = message
|
||
chat_request.max_tokens = int(gc.get("max_tokens", 6000))
|
||
chat_request.temperature = float(gc.get("temperature", 1))
|
||
chat_request.frequency_penalty = float(gc.get("frequency_penalty", 0))
|
||
chat_request.presence_penalty = float(gc.get("presence_penalty", 0))
|
||
chat_request.top_p = float(gc.get("top_p", 0.95))
|
||
chat_request.top_k = int(gc.get("top_k", 1))
|
||
if history:
|
||
chat_history = []
|
||
for h in history:
|
||
entry = oci.generative_ai_inference.models.CohereMessage()
|
||
entry.role = "USER" if h["role"] == "user" else "CHATBOT"
|
||
entry.message = h["content"]
|
||
chat_history.append(entry)
|
||
chat_request.chat_history = chat_history
|
||
# Tool use support for Cohere
|
||
if tools:
|
||
cohere_tools = []
|
||
for t in tools:
|
||
props = t.get("input_schema", {}).get("properties", {})
|
||
required = t.get("input_schema", {}).get("required", [])
|
||
param_defs = {}
|
||
for k, v in props.items():
|
||
pd = oci.generative_ai_inference.models.CohereParameterDefinition()
|
||
pd.type = v.get("type", "str")
|
||
pd.description = v.get("description", "")
|
||
pd.is_required = k in required
|
||
param_defs[k] = pd
|
||
ct = oci.generative_ai_inference.models.CohereTool()
|
||
ct.name = t["name"]
|
||
ct.description = t.get("description", "")
|
||
ct.parameter_definitions = param_defs if param_defs else None
|
||
cohere_tools.append(ct)
|
||
chat_request.tools = cohere_tools
|
||
if tool_results_cohere:
|
||
chat_request.tool_results = tool_results_cohere
|
||
else:
|
||
# ── Generic format (Meta Llama, Google, xAI, OpenAI) ──
|
||
chat_request = oci.generative_ai_inference.models.GenericChatRequest()
|
||
chat_request.api_format = oci.generative_ai_inference.models.BaseChatRequest.API_FORMAT_GENERIC
|
||
provider = model_info.get("provider", "")
|
||
is_openai = provider == "openai"
|
||
is_xai = provider == "xai"
|
||
# OpenAI models use max_completion_tokens instead of max_tokens
|
||
# and do not support top_k
|
||
if is_openai:
|
||
chat_request.max_completion_tokens = int(gc.get("max_tokens", 6000))
|
||
else:
|
||
chat_request.max_tokens = int(gc.get("max_tokens", 6000))
|
||
if not is_xai:
|
||
chat_request.top_k = int(gc.get("top_k", 1))
|
||
chat_request.temperature = float(gc.get("temperature", 1))
|
||
if not is_xai:
|
||
chat_request.frequency_penalty = float(gc.get("frequency_penalty", 0))
|
||
chat_request.presence_penalty = float(gc.get("presence_penalty", 0))
|
||
chat_request.top_p = float(gc.get("top_p", 0.95))
|
||
|
||
messages = []
|
||
if system_prompt:
|
||
sys_content = oci.generative_ai_inference.models.TextContent()
|
||
sys_content.text = system_prompt
|
||
sys_msg = oci.generative_ai_inference.models.Message()
|
||
sys_msg.role = "SYSTEM"
|
||
sys_msg.content = [sys_content]
|
||
messages.append(sys_msg)
|
||
if history:
|
||
for h in history:
|
||
content = oci.generative_ai_inference.models.TextContent()
|
||
content.text = h["content"]
|
||
msg = oci.generative_ai_inference.models.Message()
|
||
msg.role = "USER" if h["role"] == "user" else "ASSISTANT"
|
||
msg.content = [content]
|
||
messages.append(msg)
|
||
|
||
# Current user message (with optional multimodal attachments)
|
||
content_parts = []
|
||
if attachments:
|
||
for att in attachments:
|
||
if att["type"] == "image":
|
||
img_url = oci.generative_ai_inference.models.ImageUrl()
|
||
img_url.url = att["data_uri"]
|
||
img_url.detail = "AUTO"
|
||
img_content = oci.generative_ai_inference.models.ImageContent()
|
||
img_content.image_url = img_url
|
||
content_parts.append(img_content)
|
||
elif att["type"] == "document":
|
||
doc_url = oci.generative_ai_inference.models.DocumentUrl()
|
||
doc_url.url = att["data_uri"]
|
||
doc_content = oci.generative_ai_inference.models.DocumentContent()
|
||
doc_content.document_url = doc_url
|
||
content_parts.append(doc_content)
|
||
text_content = oci.generative_ai_inference.models.TextContent()
|
||
text_content.text = message
|
||
content_parts.append(text_content)
|
||
user_message = oci.generative_ai_inference.models.Message()
|
||
user_message.role = "USER"
|
||
user_message.content = content_parts
|
||
messages.append(user_message)
|
||
|
||
# Append accumulated tool use loop messages (assistant+tool_calls → tool_results → ...)
|
||
if extra_messages:
|
||
messages.extend(extra_messages)
|
||
|
||
chat_request.messages = messages
|
||
|
||
# Tool definitions for Generic format
|
||
if tools:
|
||
generic_tools = []
|
||
for t in tools:
|
||
fd = oci.generative_ai_inference.models.FunctionDefinition()
|
||
fd.name = t["name"]
|
||
fd.description = t.get("description", "")
|
||
fd.parameters = t.get("input_schema")
|
||
generic_tools.append(fd)
|
||
chat_request.tools = generic_tools
|
||
|
||
# Serving mode - resolve OCID: explicit model_ocid > catalog ocid for region > short model_id
|
||
model_ref = gc.get("model_ocid") or ""
|
||
if not model_ref:
|
||
region = gc.get("genai_region", "")
|
||
ocids = model_info.get("ocids", {})
|
||
model_ref = ocids.get(region) or gc["model_id"]
|
||
log.info(f"GenAI call: model_id={gc.get('model_id')}, model_ref={model_ref[:60]}...")
|
||
if gc.get("serving_type") == "DEDICATED" and gc.get("dedicated_endpoint_id"):
|
||
chat_detail.serving_mode = oci.generative_ai_inference.models.DedicatedServingMode(
|
||
endpoint_id=gc["dedicated_endpoint_id"]
|
||
)
|
||
else:
|
||
chat_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(
|
||
model_id=model_ref
|
||
)
|
||
|
||
chat_detail.chat_request = chat_request
|
||
chat_detail.compartment_id = gc["compartment_id"]
|
||
|
||
# Execute
|
||
chat_response = generative_ai_inference_client.chat(chat_detail)
|
||
|
||
# Extract text and tool_calls from response
|
||
resp = chat_response.data.chat_response
|
||
if api_format == "COHERE":
|
||
text = resp.text if hasattr(resp, 'text') else ""
|
||
tool_calls = None
|
||
if hasattr(resp, 'tool_calls') and resp.tool_calls:
|
||
tool_calls = []
|
||
for tc in resp.tool_calls:
|
||
tool_calls.append({
|
||
"id": tc.name, # Cohere uses name as identifier
|
||
"name": tc.name,
|
||
"arguments": tc.parameters if isinstance(tc.parameters, dict) else {}
|
||
})
|
||
return (text or "", tool_calls, None)
|
||
else:
|
||
text = ""
|
||
tool_calls = None
|
||
tool_calls_raw = None # raw content list from assistant message for continuation
|
||
if hasattr(resp, 'choices') and resp.choices:
|
||
choice = resp.choices[0]
|
||
if hasattr(choice, 'message') and choice.message:
|
||
# Check for tool calls
|
||
if hasattr(choice.message, 'tool_calls') and choice.message.tool_calls:
|
||
tool_calls = []
|
||
for tc in choice.message.tool_calls:
|
||
args = {}
|
||
if hasattr(tc, 'arguments') and tc.arguments:
|
||
try: args = json.loads(tc.arguments) if isinstance(tc.arguments, str) else tc.arguments
|
||
except: args = {}
|
||
tool_calls.append({
|
||
"id": tc.id if hasattr(tc, 'id') else tc.name,
|
||
"name": tc.name if hasattr(tc, 'name') else "",
|
||
"arguments": args
|
||
})
|
||
# Preserve raw tool_calls for building assistant message in next iteration
|
||
tool_calls_raw = choice.message.tool_calls
|
||
# Extract text from content
|
||
if hasattr(choice.message, 'content') and choice.message.content:
|
||
contents = choice.message.content
|
||
if isinstance(contents, str):
|
||
text = contents
|
||
elif isinstance(contents, list):
|
||
text_parts = []
|
||
for c in contents:
|
||
if isinstance(c, str):
|
||
text_parts.append(c)
|
||
elif hasattr(c, 'text') and c.text:
|
||
text_parts.append(c.text)
|
||
elif isinstance(c, dict) and c.get('text'):
|
||
text_parts.append(c['text'])
|
||
text = "\n".join(text_parts)
|
||
else:
|
||
text = str(contents)
|
||
# Fallback: check reasoning_content (some models like GPT-5.2 use this)
|
||
if not text and hasattr(resp, 'choices') and resp.choices:
|
||
choice = resp.choices[0]
|
||
if hasattr(choice, 'message') and choice.message:
|
||
msg_obj = choice.message
|
||
if hasattr(msg_obj, 'reasoning_content') and msg_obj.reasoning_content:
|
||
rc = msg_obj.reasoning_content
|
||
if isinstance(rc, str):
|
||
text = rc
|
||
elif isinstance(rc, list):
|
||
text_parts = []
|
||
for c in rc:
|
||
if isinstance(c, str):
|
||
text_parts.append(c)
|
||
elif hasattr(c, 'text') and c.text:
|
||
text_parts.append(c.text)
|
||
elif isinstance(c, dict) and c.get('text'):
|
||
text_parts.append(c['text'])
|
||
text = "\n".join(text_parts)
|
||
else:
|
||
text = str(rc)
|
||
if text:
|
||
log.info(f"GenAI: extracted text from reasoning_content ({len(text)} chars)")
|
||
# Last resort: try choice.text
|
||
if not text:
|
||
if hasattr(choice, 'text') and choice.text:
|
||
text = choice.text
|
||
if not text and not tool_calls:
|
||
raw_content = getattr(getattr(resp.choices[0], 'message', None), 'content', None) if hasattr(resp, 'choices') and resp.choices else None
|
||
# Also dump message attrs for debugging
|
||
msg_attrs = []
|
||
if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') and resp.choices[0].message:
|
||
msg_attrs = [a for a in dir(resp.choices[0].message) if not a.startswith('_')]
|
||
reasoning = getattr(resp.choices[0].message, 'reasoning_content', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None
|
||
refusal = getattr(resp.choices[0].message, 'refusal', None) if hasattr(resp, 'choices') and resp.choices and hasattr(resp.choices[0], 'message') else None
|
||
finish_reason = getattr(resp.choices[0], 'finish_reason', None) if hasattr(resp, 'choices') and resp.choices else None
|
||
choice_attrs = [a for a in dir(resp.choices[0]) if not a.startswith('_')] if hasattr(resp, 'choices') and resp.choices else []
|
||
log.warning(f"GenAI returned empty response. model={gc.get('model_id')}, finish_reason={finish_reason}, content_repr: {repr(raw_content)[:500]}, reasoning: {repr(reasoning)[:500] if reasoning else None}, refusal: {repr(refusal)[:200] if refusal else None}, choice_attrs: {choice_attrs}")
|
||
return (text or "", tool_calls, tool_calls_raw)
|
||
|
||
# ── RAG Helpers ───────────────────────────────────────────────────────────────
|
||
RAG_CONTEXT_TEMPLATE = """--- CONTEXTO RECUPERADO (Vector Search) ---
|
||
{context}
|
||
--- FIM DO CONTEXTO ---
|
||
|
||
Pergunta do usuário: {question}"""
|
||
|
||
RAG_DEFAULT_SYSTEM_PROMPT = """Você é um assistente RAG especializado em Oracle Cloud Infrastructure (OCI).
|
||
|
||
### Escopo e restrições
|
||
- Responda **SOMENTE** perguntas relacionadas a Oracle Cloud (OCI), CIS Benchmark, CIS Report e itens de inventário OCI (IAM/AssetManagement/Networking/StorageBlock/FileStorageService/Objectstore/Compute/LoggingandMonitoring).
|
||
- Se a pergunta não for desse escopo, recuse educadamente e peça uma pergunta dentro do tema.
|
||
|
||
### Bases vetorizadas disponíveis (contexto recuperado automaticamente)
|
||
- **CIS_REPORT**: report coletado na tenancy (compliance, findings, critérios/auditoria, evidências).
|
||
- **CIS_RECOMMENDATIONS**: recomendações práticas de correção/remediação para itens não compliant.
|
||
- **Inventários OCI** (itens não compliance): inventidentityandaccess, inventassetmanagement, inventnetworking, inventstorageblock, inventfilestorageservice, inventobjectstorage, inventcomputeinstances, inventloggingandmonitoring.
|
||
|
||
### Resolução de divergências
|
||
- Para **critérios, exigência e auditoria**, priorize documentos do **CIS_REPORT**.
|
||
- Para **passos de remediação (como corrigir)**, priorize documentos do **CIS_RECOMMENDATIONS**.
|
||
- Se houver conflito, declare: "Há divergência entre bases; para auditoria usei CIS_REPORT e para correção usei CIS_RECOMMENDATIONS", citando evidências.
|
||
|
||
### Regras de fidelidade
|
||
- Responda **somente** com informações suportadas por evidências do contexto recuperado.
|
||
- Se não houver evidência suficiente: **"Não encontrei nas minhas bases"** e peça dados para refinar (Recommendation #, seção, recurso, OCID, etc.).
|
||
- **Não invente** páginas, comandos, políticas, valores ou números.
|
||
- Use somente **1–3 linhas** de evidência por item para manter respostas compactas.
|
||
|
||
### Formato de resposta (compacto, porém completo)
|
||
- **Tenancy:** <tenancy>
|
||
- **Recomendação <número> — <título>**
|
||
- **Seção/Capítulo:** …
|
||
- **Nível/Tipo:** Manual/Automated (se disponível)
|
||
- **O que isso exige (CIS):** …
|
||
- **Como auditar (critério):** …
|
||
- **Como corrigir (remediação):** (somente se solicitado ou relevante)
|
||
- Passos (Console/OCI CLI **apenas se estiverem nas evidências**)
|
||
- Observações/alertas
|
||
- **Evidência (citação curta):** "…"
|
||
- **Fontes consultadas:**
|
||
- CIS_REPORT: <metadata essenciais>
|
||
- CIS_RECOMMENDATIONS: <metadata essenciais> (se usado)"""
|
||
|
||
TF_DEFAULT_SYSTEM_PROMPT = """Você gera somente Terraform HCL para Oracle Cloud Infrastructure (OCI) usando o provider oracle/oci.
|
||
Se pedirem AWS, Azure, GCP ou outro provider, recuse educadamente.
|
||
|
||
### Regras
|
||
- Gere código production-ready, com variáveis, defaults sensatos, tags e outputs úteis. Evite ficar solicitando ao usuário para ficar interagindo a todo momento, entregue o que ele precisa e faça o mínimo de interações para entender o que o usuário precisa. Explique de forma clara o que foi gerado e o que foi arrumado para que fosse possível gerar o código do terraform.
|
||
- Use sempre a sintaxe mais recente do provider OCI.
|
||
- Não inclua `terraform { required_providers }` nem `provider "oci"` principal.
|
||
- Em multi-região, use provider aliases para regiões adicionais e `provider = oci.alias` nos recursos.
|
||
- Para novas gerações, gere sempre múltiplos arquivos: no mínimo `variables.tf`, 1 arquivo de recursos e `outputs.tf`.
|
||
- Cada bloco deve começar com `// filename: nome.tf`.
|
||
- Nunca declare o mesmo recurso (tipo + nome) em mais de um arquivo.
|
||
- Use `var.compartment_id` nos recursos e declare sempre `variable "region"` em `variables.tf`.
|
||
- Se houver "RECURSOS OCI EXISTENTES NO COMPARTMENT", reutilize recursos com data sources sempre que possível.
|
||
- Se houver "ARQUIVOS TERRAFORM ATUAIS NO WORKSPACE", gere somente os arquivos corrigidos, com os mesmos nomes.
|
||
- Sempre responda com blocos `hcl`, depois **Resource Plan** (`+` criar, `~` reutilizar), uma breve explicação e uma seção `✅ Validação`.
|
||
- Valide: referências cruzadas, CIDRs, route tables, security lists, dependências, segurança e variáveis.
|
||
- Use apenas resource types válidos do OCI. Exemplos corretos:
|
||
- `oci_network_firewall_network_firewall`
|
||
- `oci_network_firewall_network_firewall_policy`
|
||
- `oci_core_drg_attachment`
|
||
- `oci_core_drg_attachment_management`
|
||
- `oci_core_drg_route_table`
|
||
- `oci_core_drg_route_distribution`
|
||
- `oci_core_drg_route_distribution_statement`
|
||
- `oci_core_drg_route_table_route_rule`
|
||
- `oci_core_remote_peering_connection`
|
||
- Nunca invente recursos inexistentes.
|
||
- Use sempre HCL multi-line, nunca blocos inline com múltiplos argumentos.
|
||
|
||
### Regras críticas
|
||
- `oci_core_drg_route_distribution_statement` deve usar `match_criteria`.
|
||
- 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`.
|
||
"""
|
||
|
||
# ── Terraform OCI Resource Reference (generated at build time, updatable at runtime) ──
|
||
_TF_RESOURCE_REF_PATH = "/data/oci_tf_resource_reference.txt"
|
||
_tf_resource_ref_cache: dict = {"content": None, "mtime": 0}
|
||
|
||
|
||
def _populate_tf_valid_types():
|
||
"""Parse the OCI TF resource reference file and populate tf_valid_types table in SQLite."""
|
||
p = Path(_TF_RESOURCE_REF_PATH)
|
||
if not p.exists():
|
||
return 0
|
||
content = p.read_text()
|
||
import re as _re
|
||
entries = []
|
||
current_category = ""
|
||
for line in content.split('\n'):
|
||
if line.startswith('## '):
|
||
current_category = line[3:].strip()
|
||
continue
|
||
# Resource lines: - **oci_xxx** required: ... blocks: ...
|
||
m = _re.match(r'^- \*\*(\w+)\*\*\s*(.*)', line)
|
||
if m:
|
||
name = m.group(1)
|
||
rest = m.group(2).strip()
|
||
required = ""
|
||
blocks = ""
|
||
# Split by "blocks:" to separate required from blocks
|
||
blk_parts = rest.split('blocks:')
|
||
if blk_parts[0].strip().startswith('required:'):
|
||
required = blk_parts[0].replace('required:', '').strip()
|
||
if len(blk_parts) > 1:
|
||
blocks = blk_parts[1].strip()
|
||
entries.append((name, 'resource', current_category, required, blocks))
|
||
continue
|
||
# Data source lines (in "All Resource Types" or "All Data Sources" section): - oci_xxx
|
||
m2 = _re.match(r'^- (\w+)\s*$', line)
|
||
if m2:
|
||
name = m2.group(1)
|
||
if current_category.startswith('All Data'):
|
||
entries.append((name, 'data', '', '', ''))
|
||
elif current_category.startswith('All Resource'):
|
||
entries.append((name, 'resource', '', '', ''))
|
||
if not entries:
|
||
return 0
|
||
with db() as c:
|
||
c.execute("DELETE FROM tf_valid_types")
|
||
# Insert detailed entries first (with required_args), then bulk lists with IGNORE to not overwrite
|
||
detailed = [e for e in entries if e[3] or e[4]] # has required_args or blocks
|
||
bulk = [e for e in entries if not e[3] and not e[4]]
|
||
if detailed:
|
||
c.executemany(
|
||
"INSERT OR REPLACE INTO tf_valid_types (name, kind, category, required_args, blocks) VALUES (?,?,?,?,?)",
|
||
detailed)
|
||
if bulk:
|
||
c.executemany(
|
||
"INSERT OR IGNORE INTO tf_valid_types (name, kind, category, required_args, blocks) VALUES (?,?,?,?,?)",
|
||
bulk)
|
||
log.info(f"Populated tf_valid_types: {len(entries)} entries")
|
||
return len(entries)
|
||
|
||
|
||
def _validate_tf_resource_types(tf_code: str) -> list:
|
||
"""Validate resource/data types in generated TF code against the tf_valid_types table.
|
||
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
|
||
used_types = set()
|
||
for m in _re.finditer(r'(?:resource|data)\s+"(\w+)"', tf_code):
|
||
used_types.add(m.group(1))
|
||
if not used_types:
|
||
return []
|
||
# Load valid types from DB
|
||
with db() as c:
|
||
rows = c.execute("SELECT name, kind, required_args FROM tf_valid_types").fetchall()
|
||
valid_names = {r["name"] for r in rows}
|
||
valid_required = {r["name"]: r["required_args"] for r in rows}
|
||
if not valid_names:
|
||
return [] # No reference loaded
|
||
# Check each used type
|
||
errors = []
|
||
for t in sorted(used_types):
|
||
if t not in valid_names:
|
||
# Find closest matches
|
||
suggestions = get_close_matches(t, valid_names, n=3, cutoff=0.6)
|
||
errors.append({
|
||
"type": t,
|
||
"suggestions": suggestions,
|
||
"message": f"Resource type '{t}' NÃO EXISTE no provider oracle/oci."
|
||
})
|
||
return errors
|
||
|
||
def _load_tf_resource_reference() -> str:
|
||
"""Load the OCI Terraform resource reference file, cached by mtime."""
|
||
try:
|
||
p = Path(_TF_RESOURCE_REF_PATH)
|
||
if not p.exists():
|
||
return ""
|
||
mtime = p.stat().st_mtime
|
||
if _tf_resource_ref_cache["content"] and _tf_resource_ref_cache["mtime"] == mtime:
|
||
return _tf_resource_ref_cache["content"]
|
||
content = p.read_text()
|
||
_tf_resource_ref_cache["content"] = content
|
||
_tf_resource_ref_cache["mtime"] = mtime
|
||
log.info(f"Loaded TF resource reference: {len(content)} chars, {content.count(chr(10))} lines")
|
||
return content
|
||
except Exception as e:
|
||
log.warning(f"Failed to load TF resource reference: {e}")
|
||
return ""
|
||
|
||
def _regenerate_tf_reference() -> dict:
|
||
"""Regenerate the OCI Terraform resource reference file."""
|
||
try:
|
||
result = subprocess.run(
|
||
["python", "/app/gen_tf_reference.py"],
|
||
capture_output=True, text=True, timeout=300
|
||
)
|
||
_tf_resource_ref_cache["content"] = None # invalidate cache
|
||
if result.returncode == 0:
|
||
ref = _load_tf_resource_reference()
|
||
return {"ok": True, "output": result.stdout.strip(), "lines": ref.count('\n')}
|
||
return {"ok": False, "error": result.stderr.strip()}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
# ── Terraform Resource Docs (on-demand from GitHub, cached in SQLite) ──
|
||
_TF_DOC_BASE_URL = "https://raw.githubusercontent.com/oracle/terraform-provider-oci/master/website/docs/r/"
|
||
|
||
|
||
def _fetch_tf_resource_doc(slug: str) -> dict | None:
|
||
"""Fetch a single resource doc from GitHub, parse Example Usage + Argument Reference, cache in SQLite."""
|
||
import re as _re
|
||
# Check cache first
|
||
with db() as c:
|
||
row = c.execute("SELECT example_usage, argument_ref FROM tf_resource_docs WHERE slug=?", (slug,)).fetchone()
|
||
if row and (row["example_usage"] or row["argument_ref"]):
|
||
return {"slug": slug, "example": row["example_usage"], "args": row["argument_ref"]}
|
||
# Fetch from GitHub
|
||
try:
|
||
import urllib.request
|
||
url = f"{_TF_DOC_BASE_URL}{slug}.html.markdown"
|
||
req = urllib.request.Request(url, headers={"User-Agent": "oci-cis-agent/2.1"})
|
||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||
if resp.status != 200:
|
||
return None
|
||
md = resp.read().decode("utf-8")
|
||
# Extract Example Usage section
|
||
example = ""
|
||
m = _re.search(r'## Example Usage\s*\n(.*?)(?=\n## )', md, _re.DOTALL)
|
||
if m:
|
||
example = m.group(1).strip()
|
||
# Extract Argument Reference section
|
||
args = ""
|
||
m2 = _re.search(r'## Argument Reference\s*\n(.*?)(?=\n## )', md, _re.DOTALL)
|
||
if m2:
|
||
args = m2.group(1).strip()
|
||
# Cache in SQLite
|
||
subcategory = ""
|
||
m3 = _re.search(r'subcategory:\s*"([^"]+)"', md)
|
||
if m3:
|
||
subcategory = m3.group(1)
|
||
with db() as c:
|
||
c.execute(
|
||
"INSERT OR REPLACE INTO tf_resource_docs (slug, subcategory, example_usage, argument_ref) VALUES (?,?,?,?)",
|
||
(slug, subcategory, example, args))
|
||
log.info(f"Fetched TF doc: {slug} (example={len(example)} chars, args={len(args)} chars)")
|
||
return {"slug": slug, "example": example, "args": args}
|
||
except Exception as e:
|
||
log.warning(f"Failed to fetch TF doc for {slug}: {e}")
|
||
return None
|
||
|
||
|
||
def _get_tf_docs_for_resources(resource_types: list[str]) -> str:
|
||
"""Given a list of oci_xxx resource types, fetch their docs and build a context string."""
|
||
docs_parts = []
|
||
for rtype in resource_types[:10]: # limit to 10 to keep context manageable
|
||
# Convert oci_core_vcn -> core_vcn
|
||
slug = rtype.replace("oci_", "", 1) if rtype.startswith("oci_") else rtype
|
||
doc = _fetch_tf_resource_doc(slug)
|
||
if doc and (doc["example"] or doc["args"]):
|
||
part = f"#### {rtype}\n"
|
||
if doc["example"]:
|
||
part += f"**Example Usage:**\n{doc['example']}\n\n"
|
||
if doc["args"]:
|
||
part += f"**Arguments:**\n{doc['args']}\n"
|
||
docs_parts.append(part)
|
||
if not docs_parts:
|
||
return ""
|
||
return "### Documentação Oficial dos Recursos (registry.terraform.io)\n" + \
|
||
"Use exatamente os argumentos e estrutura mostrados nos exemplos abaixo.\n\n" + \
|
||
"\n---\n".join(docs_parts)
|
||
|
||
|
||
def _detect_tf_resource_types(text: str) -> list[str]:
|
||
"""Detect OCI resource types mentioned or implied in a user message + conversation."""
|
||
import re as _re
|
||
# Direct mentions of oci_ resource types
|
||
explicit = set(_re.findall(r'\boci_\w+', text))
|
||
# Keyword-to-resource mapping for common infra requests
|
||
keyword_map = {
|
||
'vcn': ['oci_core_vcn', 'oci_core_subnet', 'oci_core_internet_gateway', 'oci_core_nat_gateway',
|
||
'oci_core_route_table', 'oci_core_security_list'],
|
||
'subnet': ['oci_core_subnet'],
|
||
'instance': ['oci_core_instance', 'oci_core_instance_configuration'],
|
||
'compute': ['oci_core_instance'],
|
||
'load.?balancer': ['oci_load_balancer_load_balancer', 'oci_load_balancer_backend_set',
|
||
'oci_load_balancer_listener'],
|
||
'nlb': ['oci_network_load_balancer_network_load_balancer', 'oci_network_load_balancer_backend_set',
|
||
'oci_network_load_balancer_listener'],
|
||
'firewall': ['oci_network_firewall_network_firewall', 'oci_network_firewall_network_firewall_policy'],
|
||
'drg': ['oci_core_drg', 'oci_core_drg_attachment', 'oci_core_drg_route_table'],
|
||
'peering|rpc': ['oci_core_remote_peering_connection'],
|
||
'bucket|object.?storage': ['oci_objectstorage_bucket', 'oci_objectstorage_namespace_metadata'],
|
||
'autonomous|adb': ['oci_database_autonomous_database'],
|
||
'db.?system': ['oci_database_db_system'],
|
||
'mysql': ['oci_mysql_mysql_db_system'],
|
||
'oke|kubernetes|cluster': ['oci_containerengine_cluster', 'oci_containerengine_node_pool'],
|
||
'dns': ['oci_dns_zone', 'oci_dns_record'],
|
||
'vault|kms': ['oci_kms_vault', 'oci_kms_key'],
|
||
'waf': ['oci_waf_web_app_firewall', 'oci_waf_web_app_firewall_policy'],
|
||
'api.?gateway': ['oci_apigateway_gateway', 'oci_apigateway_deployment'],
|
||
'function|serverless': ['oci_functions_application', 'oci_functions_function'],
|
||
'nsg': ['oci_core_network_security_group', 'oci_core_network_security_group_security_rule'],
|
||
'bastion': ['oci_bastion_bastion', 'oci_bastion_session'],
|
||
'file.?storage': ['oci_file_storage_file_system', 'oci_file_storage_mount_target',
|
||
'oci_file_storage_export'],
|
||
'block.?volume': ['oci_core_volume', 'oci_core_volume_attachment'],
|
||
'policy|iam': ['oci_identity_policy', 'oci_identity_compartment', 'oci_identity_group',
|
||
'oci_identity_dynamic_group'],
|
||
}
|
||
text_lower = text.lower()
|
||
for pattern, resources in keyword_map.items():
|
||
if _re.search(pattern, text_lower):
|
||
explicit.update(resources)
|
||
return sorted(explicit)
|
||
|
||
|
||
def _get_adb_connection(cfg: dict):
|
||
"""Create an oracledb connection from an adb_vector_configs row."""
|
||
import oracledb
|
||
params = {"user": cfg["username"], "password": _dec(cfg["password_enc"]), "dsn": cfg["dsn"]}
|
||
if cfg["use_mtls"] and cfg.get("wallet_dir"):
|
||
params["config_dir"] = cfg["wallet_dir"]
|
||
params["wallet_location"] = cfg["wallet_dir"]
|
||
params["wallet_password"] = _dec(cfg["wallet_password_enc"]) if cfg.get("wallet_password_enc") else ""
|
||
return oracledb.connect(**params)
|
||
|
||
def _resolve_embed_config(oci_config_id: str = None, genai_cfg: dict = None) -> dict:
|
||
"""Resolve embedding config from genai_cfg or oci_config. Returns dict with oci_config_id, endpoint, genai_region, compartment_id."""
|
||
if genai_cfg:
|
||
return genai_cfg
|
||
if oci_config_id:
|
||
with db() as c:
|
||
# Try linked genai config first
|
||
gc = c.execute("SELECT * FROM genai_configs WHERE oci_config_id=? ORDER BY is_default DESC, created_at DESC",
|
||
(oci_config_id,)).fetchone()
|
||
if gc: return dict(gc)
|
||
# Fallback: build from oci_config directly
|
||
oc = c.execute("SELECT * FROM oci_configs WHERE id=?", (oci_config_id,)).fetchone()
|
||
if oc:
|
||
return {
|
||
"oci_config_id": oc["id"],
|
||
"genai_region": oc["region"],
|
||
"endpoint": f"https://inference.generativeai.{oc['region']}.oci.oraclecloud.com",
|
||
"compartment_id": _safe_dec(dict(oc).get("compartment_id") or oc["tenancy_ocid"]),
|
||
}
|
||
# Last resort: any genai config
|
||
with db() as c:
|
||
gc = c.execute("SELECT * FROM genai_configs ORDER BY is_default DESC, created_at DESC").fetchone()
|
||
if gc: return dict(gc)
|
||
# Or any oci config
|
||
oc = c.execute("SELECT * FROM oci_configs ORDER BY created_at DESC").fetchone()
|
||
if oc:
|
||
return {
|
||
"oci_config_id": oc["id"],
|
||
"genai_region": oc["region"],
|
||
"endpoint": f"https://inference.generativeai.{oc['region']}.oci.oraclecloud.com",
|
||
"compartment_id": _safe_dec(dict(oc).get("compartment_id") or oc["tenancy_ocid"]),
|
||
}
|
||
raise HTTPException(400, "Nenhuma credencial OCI configurada para gerar embeddings.")
|
||
|
||
def _embed_text(text: str, genai_cfg: dict, embedding_model_id: str) -> list:
|
||
"""Generate embedding using OCI GenAI embed endpoint."""
|
||
import oci
|
||
config_path = str(OCI_DIR / genai_cfg["oci_config_id"] / "config")
|
||
config = oci.config.from_file(config_path, "DEFAULT")
|
||
endpoint = genai_cfg.get("endpoint") or f"https://inference.generativeai.{genai_cfg.get('genai_region','us-ashburn-1')}.oci.oraclecloud.com"
|
||
client = oci.generative_ai_inference.GenerativeAiInferenceClient(
|
||
config=config, service_endpoint=endpoint,
|
||
retry_strategy=oci.retry.NoneRetryStrategy(), timeout=(10, 120)
|
||
)
|
||
embed_detail = oci.generative_ai_inference.models.EmbedTextDetails()
|
||
embed_detail.inputs = [text]
|
||
emb_info = EMBEDDING_MODELS.get(embedding_model_id, {})
|
||
region = genai_cfg.get("genai_region", "")
|
||
emb_ref = emb_info.get("ocids", {}).get(region) or embedding_model_id
|
||
embed_detail.serving_mode = oci.generative_ai_inference.models.OnDemandServingMode(model_id=emb_ref)
|
||
embed_detail.compartment_id = genai_cfg.get("compartment_id", "")
|
||
embed_detail.truncate = "NONE"
|
||
embed_detail.input_type = "SEARCH_QUERY"
|
||
response = client.embed_text(embed_detail)
|
||
return response.data.embeddings[0]
|
||
|
||
def _vector_search(cfg: dict, query_embedding: list, top_k: int = 5, table_name: str = None, tenancy: str = None) -> list:
|
||
"""Search ADB vector store using cosine similarity. Returns top-K documents.
|
||
If tenancy is provided, filters METADATA JSON to match that tenancy only."""
|
||
import array
|
||
table_name = table_name or cfg.get("table_name", "")
|
||
conn = _get_adb_connection(cfg)
|
||
try:
|
||
cur = conn.cursor()
|
||
vec = array.array('f', query_embedding)
|
||
if tenancy:
|
||
# Filter by tenancy in METADATA JSON field using LIKE for broad compatibility
|
||
# Matches both structured JSON {"tenancy":"X",...} and legacy "tenancy: X, ..."
|
||
tenancy_filter = f'%"tenancy":"{tenancy}"%'
|
||
tenancy_filter_legacy = f'%tenancy: {tenancy}%'
|
||
cur.execute(f"""
|
||
SELECT ID, TEXT, METADATA,
|
||
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
|
||
FROM "{table_name}"
|
||
WHERE (METADATA LIKE :3 OR METADATA LIKE :4)
|
||
ORDER BY distance ASC
|
||
FETCH FIRST :2 ROWS ONLY
|
||
""", [vec, top_k, tenancy_filter, tenancy_filter_legacy])
|
||
else:
|
||
cur.execute(f"""
|
||
SELECT ID, TEXT, METADATA,
|
||
VECTOR_DISTANCE(EMBEDDING, :1, COSINE) AS distance
|
||
FROM "{table_name}"
|
||
ORDER BY distance ASC
|
||
FETCH FIRST :2 ROWS ONLY
|
||
""", [vec, top_k])
|
||
results = []
|
||
for row in cur:
|
||
content = row[1]
|
||
if hasattr(content, 'read'):
|
||
content = content.read()
|
||
results.append({
|
||
"id": row[0], "content": content or "",
|
||
"metadata": row[2], "distance": float(row[3])
|
||
})
|
||
cur.close()
|
||
return results
|
||
finally:
|
||
conn.close()
|
||
|
||
def _build_rag_context(documents: list) -> str:
|
||
"""Format retrieved documents into a context string for the LLM prompt."""
|
||
if not documents:
|
||
return ""
|
||
parts = []
|
||
for i, doc in enumerate(documents, 1):
|
||
source = doc.get("source", "unknown")
|
||
content = doc.get("content", "")
|
||
if len(content) > 2000:
|
||
content = content[:2000] + "..."
|
||
parts.append(f"[Document {i} | Source: {source}]\n{content}")
|
||
return "\n\n---\n\n".join(parts)
|
||
|
||
def _get_active_adb_configs(user_id: str) -> list[dict]:
|
||
"""Get all active ADB vector configs. GenAI config is resolved automatically if not linked."""
|
||
with db() as c:
|
||
rows = c.execute(
|
||
"SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1 ORDER BY created_at DESC",
|
||
(user_id,)
|
||
).fetchall()
|
||
if not rows:
|
||
rows = c.execute(
|
||
"SELECT * FROM adb_vector_configs WHERE is_active=1 ORDER BY created_at DESC"
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
def _get_tables_for_config(adb_config_id: str, active_only: bool = True) -> list[dict]:
|
||
"""Get all registered vector tables for an ADB config."""
|
||
with db() as c:
|
||
sql = "SELECT * FROM adb_vector_tables WHERE adb_config_id=?"
|
||
if active_only:
|
||
sql += " AND is_active=1"
|
||
sql += " ORDER BY created_at ASC"
|
||
rows = c.execute(sql, (adb_config_id,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# ── MCP Servers ───────────────────────────────────────────────────────────────
|
||
@app.post("/api/mcp/servers")
|
||
async def register_mcp(req: MCPServerReq, u=Depends(require("admin","user"))):
|
||
mid = str(uuid.uuid4())
|
||
with db() as c:
|
||
c.execute(
|
||
"INSERT INTO mcp_servers (id,user_id,name,description,server_type,command,args,env_vars,url,module_path,tools,linked_adb_id) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(mid, u["id"], req.name, req.description, req.server_type,
|
||
req.command, json.dumps(req.args) if req.args else None,
|
||
json.dumps(req.env_vars) if req.env_vars else None, req.url, req.module_path,
|
||
json.dumps(req.tools) if req.tools else None, req.linked_adb_id))
|
||
_audit(u["id"], u["username"], "register_mcp", mid, req.name)
|
||
_config_log("mcp", mid, req.name, "success", "save", f"MCP registrado: {req.name} ({req.server_type})", u["id"], u["username"])
|
||
return {"id": mid, "name": req.name, "server_type": req.server_type}
|
||
|
||
@app.get("/api/mcp/servers")
|
||
async def list_mcp(u=Depends(current_user)):
|
||
with db() as c:
|
||
if u["role"]=="admin": rows=c.execute("SELECT * FROM mcp_servers ORDER BY created_at DESC").fetchall()
|
||
else: rows=c.execute("SELECT * FROM mcp_servers WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall()
|
||
res = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
for k in ("args","env_vars","tools"):
|
||
if d.get(k):
|
||
try: d[k] = json.loads(d[k])
|
||
except: pass
|
||
res.append(d)
|
||
return res
|
||
|
||
@app.delete("/api/mcp/servers/{mid}")
|
||
async def del_mcp(mid: str, u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("DELETE FROM mcp_servers WHERE id=?", (mid,))
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/mcp/servers/{mid}")
|
||
async def update_mcp(mid: str, req: MCPServerReq, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM mcp_servers WHERE id=?", (mid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
with db() as c:
|
||
c.execute(
|
||
"UPDATE mcp_servers SET name=?,description=?,server_type=?,command=?,args=?,env_vars=?,url=?,tools=?,linked_adb_id=? WHERE id=?",
|
||
(req.name, req.description, req.server_type, req.command,
|
||
json.dumps(req.args) if req.args else None,
|
||
json.dumps(req.env_vars) if req.env_vars else None, req.url,
|
||
json.dumps(req.tools) if req.tools else None, req.linked_adb_id, mid))
|
||
_audit(u["id"], u["username"], "update_mcp", mid, req.name)
|
||
_config_log("mcp", mid, req.name, "success", "save", f"MCP atualizado: {req.name} ({req.server_type})", u["id"], u["username"])
|
||
return {"id": mid, "name": req.name, "server_type": req.server_type}
|
||
|
||
@app.put("/api/mcp/servers/{mid}/toggle")
|
||
async def toggle_mcp(mid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cur = c.execute("SELECT is_active FROM mcp_servers WHERE id=?",(mid,)).fetchone()
|
||
if not cur: raise HTTPException(404)
|
||
c.execute("UPDATE mcp_servers SET is_active=? WHERE id=?",(0 if cur["is_active"] else 1, mid))
|
||
return {"ok": True, "is_active": not cur["is_active"]}
|
||
|
||
@app.post("/api/mcp/servers/{mid}/upload")
|
||
async def upload_mcp_file(mid: str, file: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||
sdir = MCP_DIR / mid; sdir.mkdir(parents=True, exist_ok=True)
|
||
fp = sdir / file.filename
|
||
fp.write_bytes(await file.read())
|
||
with db() as c: c.execute("UPDATE mcp_servers SET module_path=? WHERE id=?", (str(fp), mid))
|
||
return {"ok": True, "path": str(fp)}
|
||
|
||
@app.put("/api/mcp/servers/{mid}/link-adb")
|
||
async def link_mcp_adb(mid: str, adb_id: str = Query(...), u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("UPDATE mcp_servers SET linked_adb_id=? WHERE id=?", (adb_id, mid))
|
||
return {"ok": True, "mcp_id": mid, "linked_adb_id": adb_id}
|
||
|
||
@app.post("/api/mcp/servers/{mid}/discover-tools")
|
||
async def discover_mcp_tools(mid: str, u=Depends(require("admin","user"))):
|
||
"""Connect to MCP server and discover available tools."""
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM mcp_servers WHERE id=?", (mid,)).fetchone()
|
||
if not row: raise HTTPException(404)
|
||
mcp_srv = dict(row)
|
||
try:
|
||
discovered = await _discover_mcp_tools(mcp_srv)
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Erro ao descobrir tools: {str(e)[:500]}")
|
||
# Merge with existing tools (keep manually added ones)
|
||
existing = []
|
||
if mcp_srv.get("tools"):
|
||
try: existing = json.loads(mcp_srv["tools"]) if isinstance(mcp_srv["tools"], str) else mcp_srv["tools"]
|
||
except: pass
|
||
existing_names = {t["name"] for t in existing if isinstance(t, dict)}
|
||
for dt in discovered:
|
||
if dt["name"] not in existing_names:
|
||
existing.append(dt)
|
||
else:
|
||
# Update existing tool with discovered schema
|
||
for i, et in enumerate(existing):
|
||
if isinstance(et, dict) and et["name"] == dt["name"]:
|
||
existing[i] = dt
|
||
break
|
||
with db() as c:
|
||
c.execute("UPDATE mcp_servers SET tools=? WHERE id=?", (json.dumps(existing), mid))
|
||
_audit(u["id"], u["username"], "discover_mcp_tools", mid, f"{len(discovered)} tools found")
|
||
return {"ok": True, "discovered": len(discovered), "total": len(existing), "tools": existing}
|
||
|
||
@app.put("/api/mcp/servers/{mid}/tools")
|
||
async def update_mcp_tools(mid: str, req: dict, u=Depends(require("admin","user"))):
|
||
"""Manually update tools for an MCP server."""
|
||
with db() as c:
|
||
row = c.execute("SELECT id FROM mcp_servers WHERE id=?", (mid,)).fetchone()
|
||
if not row: raise HTTPException(404)
|
||
tools = req.get("tools", [])
|
||
if not isinstance(tools, list): raise HTTPException(400, "tools must be a list")
|
||
for t in tools:
|
||
if not isinstance(t, dict) or "name" not in t:
|
||
raise HTTPException(400, "Each tool must have a 'name' field")
|
||
with db() as c:
|
||
c.execute("UPDATE mcp_servers SET tools=? WHERE id=?", (json.dumps(tools), mid))
|
||
return {"ok": True, "tools": tools}
|
||
|
||
async def _discover_mcp_tools(mcp_srv: dict) -> list[dict]:
|
||
"""Connect to MCP server and list available tools."""
|
||
from mcp import ClientSession
|
||
if mcp_srv["server_type"] in ("stdio", "module"):
|
||
from mcp.client.stdio import stdio_client, StdioServerParameters
|
||
cmd = mcp_srv.get("command") or "python3"
|
||
args_raw = mcp_srv.get("args")
|
||
args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or [])
|
||
env_raw = mcp_srv.get("env_vars")
|
||
env = json.loads(env_raw) if isinstance(env_raw, str) else (env_raw or None)
|
||
params = StdioServerParameters(command=cmd, args=args, env=env)
|
||
async with stdio_client(params) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.list_tools()
|
||
return [{"name": t.name, "description": t.description or "",
|
||
"input_schema": t.inputSchema if hasattr(t, 'inputSchema') else {}} for t in result.tools]
|
||
elif mcp_srv["server_type"] == "sse":
|
||
from mcp.client.sse import sse_client
|
||
async with sse_client(mcp_srv["url"]) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.list_tools()
|
||
return [{"name": t.name, "description": t.description or "",
|
||
"input_schema": t.inputSchema if hasattr(t, 'inputSchema') else {}} for t in result.tools]
|
||
return []
|
||
|
||
async def _execute_mcp_tool(mcp_srv: dict, tool_name: str, arguments: dict) -> str:
|
||
"""Connect to MCP server and execute a specific tool."""
|
||
from mcp import ClientSession
|
||
if mcp_srv["server_type"] in ("stdio", "module"):
|
||
from mcp.client.stdio import stdio_client, StdioServerParameters
|
||
cmd = mcp_srv.get("command") or "python3"
|
||
args_raw = mcp_srv.get("args")
|
||
args = json.loads(args_raw) if isinstance(args_raw, str) else (args_raw or [])
|
||
env_raw = mcp_srv.get("env_vars")
|
||
env = json.loads(env_raw) if isinstance(env_raw, str) else (env_raw or None)
|
||
params = StdioServerParameters(command=cmd, args=args, env=env)
|
||
async with stdio_client(params) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.call_tool(tool_name, arguments)
|
||
parts = []
|
||
for c in result.content:
|
||
if hasattr(c, 'text'):
|
||
parts.append(c.text)
|
||
elif hasattr(c, 'data'):
|
||
parts.append(str(c.data))
|
||
return "\n".join(parts) if parts else "Tool executed successfully (no output)"
|
||
elif mcp_srv["server_type"] == "sse":
|
||
from mcp.client.sse import sse_client
|
||
async with sse_client(mcp_srv["url"]) as streams:
|
||
async with ClientSession(*streams) as session:
|
||
await session.initialize()
|
||
result = await session.call_tool(tool_name, arguments)
|
||
parts = []
|
||
for c in result.content:
|
||
if hasattr(c, 'text'):
|
||
parts.append(c.text)
|
||
elif hasattr(c, 'data'):
|
||
parts.append(str(c.data))
|
||
return "\n".join(parts) if parts else "Tool executed successfully (no output)"
|
||
raise ValueError(f"Unsupported MCP server type: {mcp_srv['server_type']}")
|
||
|
||
def _get_active_mcp_tools(user_id: str) -> list[dict]:
|
||
"""Get all tools from active MCP servers for a user, with server reference."""
|
||
with db() as c:
|
||
rows = c.execute(
|
||
"SELECT * FROM mcp_servers WHERE is_active=1 AND (user_id=? OR EXISTS (SELECT 1 FROM users WHERE id=? AND role='admin'))",
|
||
(user_id, user_id)
|
||
).fetchall()
|
||
result = []
|
||
for r in rows:
|
||
srv = dict(r)
|
||
tools_raw = srv.get("tools")
|
||
if not tools_raw:
|
||
continue
|
||
try:
|
||
tools = json.loads(tools_raw) if isinstance(tools_raw, str) else tools_raw
|
||
except:
|
||
continue
|
||
for t in tools:
|
||
if isinstance(t, dict) and t.get("name"):
|
||
result.append({"server": srv, "tool": t})
|
||
return result
|
||
|
||
# ── ADB Vector DB Config ─────────────────────────────────────────────────────
|
||
@app.post("/api/adb/parse-wallet")
|
||
async def parse_wallet(wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||
"""Extract DSN names from tnsnames.ora inside wallet ZIP (temporary parse, no save)."""
|
||
import zipfile, tempfile
|
||
try:
|
||
data = await wallet.read()
|
||
with tempfile.TemporaryDirectory() as tmp:
|
||
zp = Path(tmp) / "wallet.zip"
|
||
zp.write_bytes(data)
|
||
with zipfile.ZipFile(str(zp), 'r') as z:
|
||
z.extractall(tmp)
|
||
tns = Path(tmp) / "tnsnames.ora"
|
||
if not tns.exists():
|
||
raise HTTPException(400, "Wallet ZIP não contém tnsnames.ora")
|
||
tns_text = tns.read_text(errors="ignore")
|
||
dsn_names = re.findall(r'^(\w[\w\-.]*)\s*=', tns_text, re.MULTILINE)
|
||
if not dsn_names:
|
||
raise HTTPException(400, "Nenhum DSN encontrado no tnsnames.ora")
|
||
return {"dsn_names": dsn_names, "files": [n for n in os.listdir(tmp) if n != "wallet.zip"]}
|
||
except zipfile.BadZipFile:
|
||
raise HTTPException(400, "Arquivo não é um ZIP válido")
|
||
|
||
@app.post("/api/adb/config")
|
||
async def save_adb(
|
||
config_name: str = Form(...), dsn: str = Form(...), username: str = Form(...),
|
||
password: str = Form(...), wallet_password: str = Form(""),
|
||
table_name: str = Form(""), use_mtls: str = Form("true"),
|
||
genai_config_id: str = Form(""), embedding_model_id: str = Form("cohere.embed-v4.0"),
|
||
wallet: Optional[UploadFile] = File(None),
|
||
u=Depends(require("admin","user"))
|
||
):
|
||
vid = str(uuid.uuid4())
|
||
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
|
||
with db() as c:
|
||
c.execute(
|
||
"INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_password_enc,table_name,use_mtls,genai_config_id,embedding_model_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||
(vid, u["id"], config_name, dsn, username, _enc(password),
|
||
_enc(wallet_password) if wallet_password else None, table_name, int(use_mtls_bool),
|
||
genai_config_id or None, embedding_model_id))
|
||
# Auto-save wallet if provided
|
||
if wallet and wallet.filename:
|
||
import zipfile
|
||
wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True)
|
||
zp = wdir / "wallet.zip"
|
||
zp.write_bytes(await wallet.read())
|
||
with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir))
|
||
with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid))
|
||
_config_log("adb", vid, config_name, "success", "upload", f"Wallet enviada com a conexão", u["id"], u["username"])
|
||
_audit(u["id"], u["username"], "save_adb_config", vid, config_name)
|
||
_config_log("adb", vid, config_name, "success", "save", f"Conexão salva: {config_name} ({dsn})", u["id"], u["username"])
|
||
return {"id": vid, "config_name": config_name}
|
||
|
||
@app.post("/api/adb/{vid}/upload-wallet")
|
||
async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||
cname = None
|
||
with db() as c:
|
||
row = c.execute("SELECT config_name FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if row: cname = row["config_name"]
|
||
try:
|
||
wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True)
|
||
zp = wdir / "wallet.zip"
|
||
zp.write_bytes(await wallet.read())
|
||
import zipfile
|
||
with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir))
|
||
with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid))
|
||
files = os.listdir(str(wdir))
|
||
_config_log("adb", vid, cname, "success", "upload", f"Wallet enviada: {', '.join(files)}", u["id"], u["username"])
|
||
return {"ok": True, "wallet_dir": str(wdir), "files": files}
|
||
except Exception as e:
|
||
_config_log("adb", vid, cname, "error", "upload", str(e)[:500], u["id"], u["username"])
|
||
raise HTTPException(500, str(e)[:500])
|
||
|
||
@app.get("/api/adb/configs")
|
||
async def list_adb(u=Depends(current_user)):
|
||
with db() as c:
|
||
cols = "id,config_name,dsn,username,table_name,use_mtls,is_active,wallet_dir,genai_config_id,embedding_model_id,created_at"
|
||
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM adb_vector_configs").fetchall()
|
||
else: rows=c.execute(f"SELECT {cols} FROM adb_vector_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||
result = []
|
||
for r in rows:
|
||
d = dict(r)
|
||
d["tables"] = _get_tables_for_config(d["id"], active_only=False)
|
||
result.append(d)
|
||
return result
|
||
|
||
@app.post("/api/adb/test/{vid}")
|
||
async def test_adb(vid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?",(vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
cname = cfg["config_name"]
|
||
try:
|
||
conn = _get_adb_connection(dict(cfg))
|
||
cur = conn.cursor(); cur.execute("SELECT 1 FROM DUAL"); cur.close(); conn.close()
|
||
_config_log("adb", vid, cname, "success", "test", "Conexão Autonomous DB OK!", u["id"], u["username"])
|
||
return {"status":"success","message":"Conexão Autonomous DB OK!"}
|
||
except ImportError:
|
||
_config_log("adb", vid, cname, "error", "test", "python-oracledb não disponível no container.", u["id"], u["username"])
|
||
return {"status":"error","message":"python-oracledb não disponível no container."}
|
||
except Exception as e:
|
||
msg = str(e)[:500]
|
||
_config_log("adb", vid, cname, "error", "test", msg, u["id"], u["username"])
|
||
return {"status":"error","message":msg}
|
||
|
||
@app.post("/api/adb/{vid}/tables/check")
|
||
async def check_adb_tables(vid: str, u=Depends(require("admin","user"))):
|
||
"""Check which registered tables exist in ADB and list all user tables."""
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
registered = _get_tables_for_config(vid, active_only=False)
|
||
reg_names_upper = {t["table_name"].upper() for t in registered}
|
||
try:
|
||
conn = _get_adb_connection(dict(cfg))
|
||
cur = conn.cursor()
|
||
cur.execute("SELECT USER FROM DUAL")
|
||
current_user_name = cur.fetchone()[0]
|
||
cur.execute("SELECT table_name FROM user_tables ORDER BY table_name")
|
||
adb_tables = [r[0] for r in cur.fetchall()]
|
||
adb_lookup = {t.upper(): t for t in adb_tables}
|
||
results = []
|
||
for t in registered:
|
||
tname_reg = t["table_name"]
|
||
tname_upper = tname_reg.upper()
|
||
real_name = adb_lookup.get(tname_upper)
|
||
if real_name:
|
||
try:
|
||
cur.execute(f'SELECT COUNT(*) FROM "{real_name}"')
|
||
cnt = cur.fetchone()[0]
|
||
case_note = f" (ADB: {real_name})" if real_name != tname_reg else ""
|
||
results.append({"table": tname_reg, "adb_name": real_name, "status": "ok", "rows": cnt, "message": f"{cnt} registros{case_note}"})
|
||
except Exception as e:
|
||
results.append({"table": tname_reg, "status": "error", "message": str(e)[:200]})
|
||
else:
|
||
results.append({"table": tname_reg, "status": "not_found", "message": f"Não existe no schema {current_user_name}"})
|
||
unregistered = [t for t in adb_tables if t.upper() not in reg_names_upper]
|
||
cur.close()
|
||
conn.close()
|
||
return {
|
||
"status": "success",
|
||
"schema_user": current_user_name,
|
||
"adb_tables_total": len(adb_tables),
|
||
"registered": results,
|
||
"unregistered_in_adb": unregistered[:50]
|
||
}
|
||
except Exception as e:
|
||
return {"status": "error", "message": f"Erro de conexão: {str(e)[:500]}"}
|
||
|
||
@app.delete("/api/adb/configs/{vid}")
|
||
async def del_adb(vid: str, u=Depends(require("admin","user"))):
|
||
with db() as c: c.execute("DELETE FROM adb_vector_configs WHERE id=?", (vid,))
|
||
d = WALLET_DIR / vid
|
||
if d.exists(): shutil.rmtree(d)
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/adb/configs/{vid}")
|
||
async def update_adb(
|
||
vid: str,
|
||
config_name: str = Form(...), dsn: str = Form(...), username: str = Form(...),
|
||
password: str = Form(""), wallet_password: str = Form(""),
|
||
table_name: str = Form(""), use_mtls: str = Form("true"),
|
||
genai_config_id: str = Form(""), embedding_model_id: str = Form("cohere.embed-v4.0"),
|
||
wallet: Optional[UploadFile] = File(None),
|
||
u=Depends(require("admin","user"))
|
||
):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not existing: raise HTTPException(404)
|
||
if u["role"] != "admin" and existing["user_id"] != u["id"]: raise HTTPException(403)
|
||
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
|
||
sets = "config_name=?,dsn=?,username=?,table_name=?,use_mtls=?,genai_config_id=?,embedding_model_id=?"
|
||
vals = [config_name, dsn, username, table_name, int(use_mtls_bool), genai_config_id or None, embedding_model_id]
|
||
if password:
|
||
sets += ",password_enc=?"
|
||
vals.append(_enc(password))
|
||
if wallet_password:
|
||
sets += ",wallet_password_enc=?"
|
||
vals.append(_enc(wallet_password))
|
||
vals.append(vid)
|
||
with db() as c:
|
||
c.execute(f"UPDATE adb_vector_configs SET {sets} WHERE id=?", vals)
|
||
if wallet and wallet.filename:
|
||
import zipfile
|
||
wdir = WALLET_DIR / vid; wdir.mkdir(parents=True, exist_ok=True)
|
||
zp = wdir / "wallet.zip"
|
||
zp.write_bytes(await wallet.read())
|
||
with zipfile.ZipFile(str(zp), 'r') as z: z.extractall(str(wdir))
|
||
with db() as c: c.execute("UPDATE adb_vector_configs SET wallet_dir=? WHERE id=?", (str(wdir), vid))
|
||
_config_log("adb", vid, config_name, "success", "upload", "Wallet atualizado", u["id"], u["username"])
|
||
_audit(u["id"], u["username"], "update_adb_config", vid, config_name)
|
||
_config_log("adb", vid, config_name, "success", "save", f"Conexão atualizada: {config_name} ({dsn})", u["id"], u["username"])
|
||
return {"id": vid, "config_name": config_name}
|
||
|
||
_TABLE_NAME_RE = re.compile(r'^[A-Za-z][A-Za-z0-9_]{0,127}$')
|
||
|
||
@app.get("/api/adb/{vid}/tables")
|
||
async def list_adb_tables(vid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT id FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
return _get_tables_for_config(vid, active_only=False)
|
||
|
||
@app.post("/api/adb/{vid}/tables")
|
||
async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))):
|
||
table_name = req.get("table_name", "").strip()
|
||
description = req.get("description", "").strip()
|
||
if not table_name: raise HTTPException(400, "table_name é obrigatório")
|
||
if not _TABLE_NAME_RE.match(table_name): raise HTTPException(400, "Nome da tabela inválido. Use letras maiúsculas, números e underscores.")
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "ADB config not found")
|
||
tid = str(uuid.uuid4())
|
||
try:
|
||
with db() as c:
|
||
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
|
||
(tid, vid, table_name, description))
|
||
except sqlite3.IntegrityError:
|
||
raise HTTPException(409, f"Tabela '{table_name}' já registrada nesta conexão")
|
||
_config_log("adb", vid, cfg["config_name"], "success", "add_table", f"Tabela '{table_name}' registrada", u["id"], u["username"])
|
||
return {"id": tid, "table_name": table_name}
|
||
|
||
@app.delete("/api/adb/{vid}/tables/{tid}")
|
||
async def remove_adb_table(vid: str, tid: str, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone()
|
||
if not row: raise HTTPException(404, "Table entry not found")
|
||
with db() as c:
|
||
c.execute("DELETE FROM adb_vector_tables WHERE id=?", (tid,))
|
||
return {"ok": True}
|
||
|
||
@app.put("/api/adb/{vid}/tables/{tid}")
|
||
async def update_adb_table(vid: str, tid: str, req: dict, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM adb_vector_tables WHERE id=? AND adb_config_id=?", (tid, vid)).fetchone()
|
||
if not row: raise HTTPException(404, "Table entry not found")
|
||
sets, vals = [], []
|
||
if "table_name" in req:
|
||
tn = req["table_name"].strip()
|
||
if not tn: raise HTTPException(400, "table_name é obrigatório")
|
||
if not _TABLE_NAME_RE.match(tn): raise HTTPException(400, "Nome da tabela inválido")
|
||
sets.append("table_name=?"); vals.append(tn)
|
||
if "description" in req:
|
||
sets.append("description=?"); vals.append(req["description"])
|
||
if "is_active" in req:
|
||
sets.append("is_active=?"); vals.append(int(req["is_active"]))
|
||
if not sets: return {"ok": True}
|
||
vals.append(tid)
|
||
try:
|
||
with db() as c:
|
||
c.execute(f"UPDATE adb_vector_tables SET {','.join(sets)} WHERE id=?", vals)
|
||
except sqlite3.IntegrityError:
|
||
raise HTTPException(409, "Tabela com este nome já existe nesta conexão")
|
||
return {"ok": True}
|
||
|
||
@app.post("/api/adb/{vid}/ingest")
|
||
async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "ADB config not found")
|
||
cfg = dict(cfg)
|
||
if not cfg.get("genai_config_id"):
|
||
raise HTTPException(400, "GenAI config not linked to this ADB connection")
|
||
with db() as c:
|
||
gc = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone()
|
||
if not gc: raise HTTPException(400, "Linked GenAI config not found")
|
||
bg.add_task(_ingest_documents_task, cfg, dict(gc), req.documents, u["id"], u["username"], table_name=req.table_name)
|
||
return {"ok": True, "message": f"Ingestão iniciada para {len(req.documents)} documentos", "config_id": vid}
|
||
|
||
def _build_metadata_json(tenancy: str = "", compartments: str = "", section: str = "",
|
||
report_date: str = "", extra: dict = None) -> str:
|
||
"""Build a structured JSON metadata string for vector embeddings."""
|
||
meta = {}
|
||
if tenancy:
|
||
meta["tenancy"] = tenancy
|
||
if compartments:
|
||
meta["compartments"] = compartments
|
||
if section:
|
||
meta["section"] = section
|
||
if report_date:
|
||
meta["report_date"] = report_date
|
||
if extra:
|
||
meta.update(extra)
|
||
return json.dumps(meta, ensure_ascii=False) if meta else ""
|
||
|
||
|
||
def _auto_register_table(adb_config_id: str, table_name: str, description: str = ""):
|
||
"""Auto-register a table in adb_vector_tables if not already present."""
|
||
if not table_name:
|
||
return
|
||
with db() as c:
|
||
exists = c.execute("SELECT 1 FROM adb_vector_tables WHERE adb_config_id=? AND table_name=? COLLATE NOCASE",
|
||
(adb_config_id, table_name)).fetchone()
|
||
if not exists:
|
||
c.execute("INSERT INTO adb_vector_tables (id, adb_config_id, table_name, description) VALUES (?,?,?,?)",
|
||
(str(uuid.uuid4()), adb_config_id, table_name, description))
|
||
log.info(f"Auto-registered table '{table_name}' for ADB config {adb_config_id}")
|
||
|
||
def _ingest_documents_task(cfg: dict, genai_cfg: dict, documents: list, user_id: str, username: str,
|
||
table_name: str = None, tenancy: str = None, compartments: str = None,
|
||
report_date: str = None):
|
||
"""Background task: embed and insert documents into ADB via OCI GenAI.
|
||
Tenancy and compartments are stored in METADATA as structured JSON for filtering."""
|
||
import array
|
||
emb_model = cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||
table_name = table_name or cfg.get("table_name", "")
|
||
# Auto-register table so it appears in multi-table RAG search
|
||
_auto_register_table(cfg["id"], table_name)
|
||
conn = _get_adb_connection(cfg)
|
||
try:
|
||
cur = conn.cursor()
|
||
inserted = 0
|
||
for doc in documents:
|
||
try:
|
||
content = doc.get("content", "")
|
||
if not content: continue
|
||
embedding = _embed_text(content, genai_cfg, emb_model)
|
||
vec = array.array('d', embedding)
|
||
# Build structured metadata with tenancy isolation
|
||
doc_tenancy = tenancy or doc.get("tenancy", "")
|
||
doc_compartments = compartments or doc.get("compartments", "")
|
||
metadata = _build_metadata_json(
|
||
tenancy=doc_tenancy,
|
||
compartments=doc_compartments,
|
||
section=doc.get("section", ""),
|
||
report_date=report_date or "",
|
||
extra={"legacy_metadata": doc.get("metadata", "")} if doc.get("metadata") else None
|
||
)
|
||
cur.execute(f"""
|
||
INSERT INTO "{table_name}" (ID, TEXT, EMBEDDING, METADATA)
|
||
VALUES (:1, :2, :3, :4)
|
||
""", [str(uuid.uuid4()), content, vec, metadata])
|
||
inserted += 1
|
||
except Exception as e:
|
||
log.error(f"Failed to ingest document: {e}")
|
||
conn.commit()
|
||
cur.close()
|
||
log.info(f"Ingested {inserted}/{len(documents)} documents into {table_name}" +
|
||
(f" (tenancy={tenancy})" if tenancy else ""))
|
||
_audit(user_id, username, "ingest_documents", cfg["id"], f"{inserted} documents")
|
||
_config_log("adb", cfg["id"], cfg.get("config_name"), "success", "ingest",
|
||
f"{inserted}/{len(documents)} documentos ingeridos em {table_name}" +
|
||
(f" (tenancy: {tenancy})" if tenancy else ""), user_id, username)
|
||
except Exception as e:
|
||
log.error(f"Ingestion task failed: {e}")
|
||
_config_log("adb", cfg["id"], cfg.get("config_name"), "error", "ingest", str(e)[:500], user_id, username)
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── Embeddings ────────────────────────────────────────────────────────────────
|
||
def _chunk_report_by_section(report_data: dict) -> list:
|
||
"""Chunk a CIS report into documents grouped by section."""
|
||
if isinstance(report_data, str):
|
||
report_data = json.loads(report_data)
|
||
if isinstance(report_data, list):
|
||
report_data = {"findings": {str(i): item for i, item in enumerate(report_data)}, "tenancy": "unknown"}
|
||
findings = report_data.get("findings", {})
|
||
tenancy = report_data.get("tenancy", "unknown")
|
||
generated_at = report_data.get("generated_at", "")
|
||
regions = report_data.get("regions", [])
|
||
compartments = report_data.get("compartments", [])
|
||
# Build context header for each chunk
|
||
ctx_parts = [f"Tenancy: {tenancy}"]
|
||
if regions:
|
||
ctx_parts.append(f"Regions: {', '.join(regions)}")
|
||
if compartments:
|
||
ctx_parts.append(f"Compartments: {', '.join(compartments[:50])}")
|
||
ctx_header = "\n".join(ctx_parts)
|
||
sections = {}
|
||
for cid, check in findings.items():
|
||
sec = check.get("section", "Other")
|
||
sections.setdefault(sec, [])
|
||
sections[sec].append(check)
|
||
documents = []
|
||
for section_name, checks in sections.items():
|
||
passed = sum(1 for c in checks if c.get("status") == "PASS")
|
||
failed = sum(1 for c in checks if c.get("status") == "FAIL")
|
||
review = sum(1 for c in checks if c.get("status") == "REVIEW")
|
||
lines = [ctx_header, "", f"Section: {section_name}", f"Total checks: {len(checks)}, Passed: {passed}, Failed: {failed}, Review: {review}", ""]
|
||
for c in checks:
|
||
status = c.get("status", "REVIEW")
|
||
lines.append(f"- [{c.get('id', '')}] {c.get('title', '')} — Status: {status}")
|
||
if c.get("findings"):
|
||
for f in c["findings"]:
|
||
lines.append(f" Finding: {f}")
|
||
documents.append({
|
||
"content": "\n".join(lines),
|
||
"source": f"CIS Report - {tenancy} - {generated_at}",
|
||
"section": section_name,
|
||
"tenancy": tenancy,
|
||
"compartments": ", ".join(compartments[:50]),
|
||
"metadata": f"tenancy: {tenancy}, section: {section_name}, total: {len(checks)}, passed: {passed}, failed: {failed}, review: {review}"
|
||
})
|
||
return documents
|
||
|
||
def _chunk_text_file(text: str, filename: str, chunk_size: int = 1000) -> list:
|
||
"""Split text into chunks by paragraphs or fixed size."""
|
||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||
documents = []
|
||
current_chunk = ""
|
||
chunk_num = 1
|
||
for para in paragraphs:
|
||
if len(current_chunk) + len(para) + 2 > chunk_size and current_chunk:
|
||
documents.append({"content": current_chunk, "source": filename, "metadata": f"chunk: {chunk_num}"})
|
||
chunk_num += 1
|
||
current_chunk = para
|
||
else:
|
||
current_chunk = current_chunk + "\n\n" + para if current_chunk else para
|
||
if current_chunk:
|
||
documents.append({"content": current_chunk, "source": filename, "metadata": f"chunk: {chunk_num}"})
|
||
return documents
|
||
|
||
def _get_adb_and_genai(vid: str, oci_config_id: str = None):
|
||
"""Load ADB config and resolve embed config.
|
||
Priority: ADB.genai_config_id → genai by oci_config_id → oci_config directly → any available."""
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "ADB config not found")
|
||
cfg = dict(cfg)
|
||
genai_cfg = None
|
||
if cfg.get("genai_config_id"):
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM genai_configs WHERE id=?", (cfg["genai_config_id"],)).fetchone()
|
||
if row: genai_cfg = dict(row)
|
||
gc = _resolve_embed_config(oci_config_id=oci_config_id, genai_cfg=genai_cfg)
|
||
return cfg, gc
|
||
|
||
@app.get("/api/embeddings/preview/{rid}")
|
||
async def preview_report_chunks(rid: str, u=Depends(current_user)):
|
||
"""Preview the chunks that will be generated from a CIS report before embedding."""
|
||
with db() as c:
|
||
r = c.execute("SELECT json_path,tenancy_name FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404, "Report not found or not completed")
|
||
json_path = r["json_path"]
|
||
if not json_path or not Path(json_path).exists():
|
||
raise HTTPException(400, "Report JSON file not found")
|
||
try:
|
||
report_data = json.loads(Path(json_path).read_text())
|
||
except Exception:
|
||
raise HTTPException(400, "Invalid report data")
|
||
documents = _chunk_report_by_section(report_data)
|
||
rd = report_data if isinstance(report_data, dict) else {}
|
||
return {"tenancy": rd.get("tenancy", "unknown"),
|
||
"regions": rd.get("regions", []),
|
||
"compartments": rd.get("compartments", []),
|
||
"total_chunks": len(documents),
|
||
"chunks": documents}
|
||
|
||
@app.post("/api/embeddings/report/{rid}")
|
||
async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||
vid = req.get("adb_config_id")
|
||
if not vid: raise HTTPException(400, "adb_config_id is required")
|
||
with db() as c:
|
||
r = c.execute("SELECT json_path,tenancy_name,config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404, "Report not found or not completed")
|
||
json_path = r["json_path"]
|
||
if not json_path or not Path(json_path).exists():
|
||
raise HTTPException(400, "Report JSON file not found")
|
||
try:
|
||
report_data = json.loads(Path(json_path).read_text())
|
||
except Exception:
|
||
raise HTTPException(400, "Invalid report data")
|
||
documents = _chunk_report_by_section(report_data)
|
||
if not documents: raise HTTPException(400, "No sections found in report")
|
||
# Optional section filter — embed only a specific section
|
||
section_filter = req.get("section")
|
||
if section_filter:
|
||
documents = [d for d in documents if d.get("section") == section_filter]
|
||
if not documents: raise HTTPException(400, f"Section '{section_filter}' not found in report")
|
||
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r.get("config_id"))
|
||
target_table = req.get("table_name") or None
|
||
# Extract tenancy and compartments for isolation
|
||
tenancy = report_data.get("tenancy", r["tenancy_name"] or "unknown")
|
||
report_date = report_data.get("generated_at", "")
|
||
compartments_list = report_data.get("compartments", [])
|
||
compartments_str = json.dumps(compartments_list) if compartments_list else ""
|
||
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"],
|
||
table_name=target_table, tenancy=tenancy, compartments=compartments_str,
|
||
report_date=report_date)
|
||
label = f"section={section_filter}" if section_filter else f"{len(documents)} sections"
|
||
_audit(u["id"], u["username"], "embed_report", rid, f"{label}, tenancy={tenancy}")
|
||
return {"ok": True, "message": f"Embedding de {len(documents)} seção(ões) iniciado (tenancy: {tenancy})", "sections": len(documents), "tenancy": tenancy}
|
||
|
||
def _extract_pdf_text(file_bytes: bytes) -> str:
|
||
"""Extract text from a PDF file using PyPDF2 or pdfplumber."""
|
||
try:
|
||
import PyPDF2
|
||
import io
|
||
reader = PyPDF2.PdfReader(io.BytesIO(file_bytes))
|
||
pages = []
|
||
for page in reader.pages:
|
||
text = page.extract_text()
|
||
if text:
|
||
pages.append(text.strip())
|
||
return "\n\n".join(pages)
|
||
except ImportError:
|
||
pass
|
||
try:
|
||
import pdfplumber
|
||
import io
|
||
pages = []
|
||
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf:
|
||
for page in pdf.pages:
|
||
text = page.extract_text()
|
||
if text:
|
||
pages.append(text.strip())
|
||
return "\n\n".join(pages)
|
||
except ImportError:
|
||
raise HTTPException(400, "PDF support requires PyPDF2 or pdfplumber. Install: pip install PyPDF2")
|
||
|
||
@app.post("/api/embeddings/upload")
|
||
async def embed_upload(adb_config_id: str = Form(...), table_name: str = Form(""), file: UploadFile = File(...), bg: BackgroundTasks = None, u=Depends(require("admin","user"))):
|
||
fname = file.filename.lower()
|
||
allowed = ('.txt', '.pdf', '.csv', '.json', '.md')
|
||
if not any(fname.endswith(ext) for ext in allowed):
|
||
raise HTTPException(400, f"Formatos aceitos: {', '.join(allowed)}")
|
||
raw = await file.read()
|
||
if fname.endswith('.pdf'):
|
||
content = _extract_pdf_text(raw)
|
||
else:
|
||
content = raw.decode("utf-8", errors="replace")
|
||
if not content.strip(): raise HTTPException(400, "File is empty")
|
||
documents = _chunk_text_file(content, file.filename)
|
||
if not documents: raise HTTPException(400, "No content chunks found")
|
||
cfg, gc = _get_adb_and_genai(adb_config_id)
|
||
target_table = table_name.strip() or None
|
||
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table)
|
||
_audit(u["id"], u["username"], "embed_upload", file.filename, f"{len(documents)} chunks")
|
||
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "filename": file.filename}
|
||
|
||
def _extract_text_from_html(html: str) -> str:
|
||
"""Extract readable text from HTML, stripping tags and scripts."""
|
||
import re as _re
|
||
text = _re.sub(r'<script[^>]*>[\s\S]*?</script>', ' ', html, flags=_re.IGNORECASE)
|
||
text = _re.sub(r'<style[^>]*>[\s\S]*?</style>', ' ', text, flags=_re.IGNORECASE)
|
||
text = _re.sub(r'<[^>]+>', ' ', text)
|
||
text = _re.sub(r'&[a-zA-Z]+;', ' ', text)
|
||
text = _re.sub(r'&#\d+;', ' ', text)
|
||
text = _re.sub(r'\s+', ' ', text).strip()
|
||
return text
|
||
|
||
@app.post("/api/embeddings/upload-url")
|
||
async def embed_upload_url(
|
||
adb_config_id: str = Form(...),
|
||
table_name: str = Form(""),
|
||
url: str = Form(...),
|
||
bg: BackgroundTasks = None,
|
||
u=Depends(require("admin", "user"))
|
||
):
|
||
import requests as req
|
||
url = url.strip()
|
||
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.raise_for_status()
|
||
except Exception as e:
|
||
raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}")
|
||
ct = resp.headers.get("content-type", "")
|
||
if "pdf" in ct:
|
||
content = _extract_pdf_text(resp.content)
|
||
elif "html" in ct or "text" in ct:
|
||
content = _extract_text_from_html(resp.text)
|
||
else:
|
||
content = resp.text
|
||
if not content or not content.strip():
|
||
raise HTTPException(400, "Nenhum conteúdo extraído da URL")
|
||
documents = _chunk_text_file(content, url)
|
||
if not documents:
|
||
raise HTTPException(400, "Nenhum chunk gerado do conteúdo")
|
||
cfg, gc = _get_adb_and_genai(adb_config_id)
|
||
target_table = table_name.strip() or None
|
||
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"], table_name=target_table)
|
||
_audit(u["id"], u["username"], "embed_url", url, f"{len(documents)} chunks")
|
||
return {"ok": True, "message": f"Embedding de {len(documents)} chunks iniciado", "chunks": len(documents), "url": url}
|
||
|
||
class ConsultQuery(BaseModel):
|
||
query: str
|
||
table_name: str = ""
|
||
top_k: int = 10
|
||
|
||
@app.post("/api/embeddings/consult")
|
||
async def consult_embeddings(req: ConsultQuery, u=Depends(current_user)):
|
||
"""Query embeddings via vector search + GenAI to get a formatted answer."""
|
||
if not req.query.strip():
|
||
raise HTTPException(400, "Query não pode ser vazia")
|
||
adb_configs = _get_active_adb_configs(u["id"])
|
||
if not adb_configs:
|
||
raise HTTPException(400, "Nenhuma conexão ADB ativa configurada")
|
||
# Collect results from all active ADB configs + tables
|
||
all_docs = []
|
||
for adb_cfg in adb_configs:
|
||
log.info(f"Consult: processing ADB {adb_cfg['id']} ({adb_cfg.get('config_name','')})")
|
||
try:
|
||
emb_genai = _resolve_embed_config(oci_config_id=adb_cfg.get("oci_config_id"))
|
||
log.info(f"Consult: resolved embed config, compartment={emb_genai.get('compartment_id','?')[:30]}, region={emb_genai.get('genai_region','?')}")
|
||
except Exception as e:
|
||
log.warning(f"Consult: resolve config failed for {adb_cfg['id']}: {e}")
|
||
continue
|
||
emb_model = adb_cfg.get("embedding_model_id", "")
|
||
if not emb_model:
|
||
log.warning(f"Consult: no embedding_model_id for {adb_cfg['id']}")
|
||
continue
|
||
try:
|
||
query_embedding = _embed_text(req.query, emb_genai, emb_model)
|
||
log.info(f"Consult: embedded query, dims={len(query_embedding)}")
|
||
except Exception as e:
|
||
log.warning(f"Consult: embed failed for {adb_cfg['id']}: {e}")
|
||
continue
|
||
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||
log.info(f"Consult: {len(tables)} active tables, filter='{req.table_name}'")
|
||
if req.table_name:
|
||
tables = [t for t in tables if t["table_name"] == req.table_name]
|
||
for tbl in tables:
|
||
try:
|
||
docs = _vector_search(adb_cfg, query_embedding, top_k=req.top_k, table_name=tbl["table_name"])
|
||
for d in docs:
|
||
d["source"] = f"{tbl['table_name']}"
|
||
all_docs.extend(docs)
|
||
except Exception as e:
|
||
log.warning(f"Consult: search failed on {tbl['table_name']}: {type(e).__name__}: {e}")
|
||
if not all_docs:
|
||
return {"answer": "Nenhum resultado encontrado nas bases vetoriais.", "documents": [], "total": 0}
|
||
# Sort by distance and take top results
|
||
all_docs.sort(key=lambda d: d.get("distance", 999))
|
||
top_docs = all_docs[:req.top_k]
|
||
# Build context and call GenAI
|
||
rag_context = _build_rag_context(top_docs)
|
||
augmented = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=req.query)
|
||
# Get GenAI config for answering
|
||
with db() as c:
|
||
gc_row = c.execute("SELECT * FROM genai_configs WHERE is_default=1 ORDER BY created_at DESC").fetchone()
|
||
if not gc_row:
|
||
gc_row = c.execute("SELECT * FROM genai_configs ORDER BY created_at DESC").fetchone()
|
||
if not gc_row:
|
||
# No GenAI config — return raw documents formatted as answer
|
||
parts = []
|
||
for i, d in enumerate(top_docs, 1):
|
||
content = d.get("content", "")
|
||
if len(content) > 800: content = content[:800] + "..."
|
||
parts.append(f"**Documento {i}** — `{d.get('source', '?')}` (distância: {d.get('distance', 0):.4f})\n\n{content}")
|
||
raw_answer = "**Resultados da busca vetorial** (sem GenAI configurado para sumarizar):\n\n---\n\n" + "\n\n---\n\n".join(parts)
|
||
doc_list = [{"content": d.get("content", "")[:500], "source": d.get("source", ""), "distance": round(d.get("distance", 0), 4), "metadata": d.get("metadata", "")} for d in top_docs]
|
||
return {"answer": raw_answer, "documents": doc_list, "total": len(all_docs)}
|
||
gc = dict(gc_row)
|
||
try:
|
||
answer, _, _ = _call_genai(gc, [{"role": "user", "content": augmented}], system_prompt=RAG_DEFAULT_SYSTEM_PROMPT)
|
||
except Exception as e:
|
||
log.error(f"Consult GenAI error: {e}")
|
||
answer = f"Erro ao consultar GenAI: {str(e)[:300]}"
|
||
doc_list = [{"content": d.get("content", "")[:500], "source": d.get("source", ""), "distance": round(d.get("distance", 0), 4), "metadata": d.get("metadata", "")} for d in top_docs]
|
||
return {"answer": answer, "documents": doc_list, "total": len(all_docs)}
|
||
|
||
@app.get("/api/embeddings/{vid}/list")
|
||
async def list_embeddings(vid: str, table_name: str = Query(""), limit: int = Query(50), offset: int = Query(0), u=Depends(current_user)):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
try:
|
||
conn = _get_adb_connection(dict(cfg))
|
||
cur = conn.cursor()
|
||
table_name = table_name.strip() or cfg["table_name"]
|
||
if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada")
|
||
cur.execute(f'SELECT COUNT(*) FROM "{table_name}"')
|
||
total = cur.fetchone()[0]
|
||
cur.execute(f"""
|
||
SELECT ID, METADATA FROM "{table_name}"
|
||
OFFSET :1 ROWS FETCH NEXT :2 ROWS ONLY
|
||
""", [offset, limit])
|
||
rows = []
|
||
for row in cur:
|
||
rid = row[0].hex() if isinstance(row[0], bytes) else str(row[0])
|
||
meta = row[1]
|
||
if hasattr(meta, 'read'): meta = meta.read()
|
||
rows.append({"id": rid, "metadata": meta})
|
||
cur.close(); conn.close()
|
||
return {"total": total, "offset": offset, "limit": limit, "documents": rows}
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Erro ao listar embeddings: {str(e)[:500]}")
|
||
|
||
@app.delete("/api/embeddings/{vid}/{doc_id}")
|
||
async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u=Depends(require("admin","user"))):
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
try:
|
||
conn = _get_adb_connection(dict(cfg))
|
||
cur = conn.cursor()
|
||
table_name = table_name.strip() or cfg["table_name"]
|
||
if not table_name: raise HTTPException(400, "Nenhuma tabela selecionada")
|
||
cur.execute(f'DELETE FROM "{table_name}" WHERE ID = :1', [doc_id])
|
||
conn.commit()
|
||
cur.close(); conn.close()
|
||
return {"ok": True}
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Erro ao deletar: {str(e)[:500]}")
|
||
|
||
@app.post("/api/embeddings/{vid}/purge")
|
||
async def purge_embeddings(vid: str, req: dict, u=Depends(require("admin","user"))):
|
||
"""Delete old embeddings from a table, optionally filtered by tenancy."""
|
||
table_name = req.get("table_name", "").strip()
|
||
tenancy = req.get("tenancy", "").strip()
|
||
if not table_name: raise HTTPException(400, "table_name é obrigatório")
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM adb_vector_configs WHERE id=?", (vid,)).fetchone()
|
||
if not cfg: raise HTTPException(404)
|
||
try:
|
||
conn = _get_adb_connection(dict(cfg))
|
||
cur = conn.cursor()
|
||
if tenancy:
|
||
cur.execute(f'SELECT COUNT(*) FROM "{table_name}" WHERE METADATA LIKE :1',
|
||
[f'%"tenancy":"{tenancy}"%'])
|
||
count = cur.fetchone()[0]
|
||
cur.execute(f'DELETE FROM "{table_name}" WHERE METADATA LIKE :1',
|
||
[f'%"tenancy":"{tenancy}"%'])
|
||
else:
|
||
cur.execute(f'SELECT COUNT(*) FROM "{table_name}"')
|
||
count = cur.fetchone()[0]
|
||
cur.execute(f'DELETE FROM "{table_name}"')
|
||
conn.commit()
|
||
cur.close(); conn.close()
|
||
_audit(u["id"], u["username"], "purge_embeddings", vid, f"table={table_name}, tenancy={tenancy or 'ALL'}, deleted={count}")
|
||
return {"ok": True, "deleted": count, "table": table_name, "tenancy": tenancy or "ALL"}
|
||
except Exception as e:
|
||
raise HTTPException(500, f"Erro ao limpar: {str(e)[:500]}")
|
||
|
||
# ── Reports ───────────────────────────────────────────────────────────────────
|
||
|
||
def _classify_report_file(fname: str) -> str:
|
||
"""Classify a report file into a category based on its filename."""
|
||
fl = fname.lower()
|
||
if "summary_report" in fl: return "summary"
|
||
if "error_report" in fl or "error" in fl and fl.endswith(".csv"): return "error"
|
||
if fl.startswith("obp_") and "findings" in fl: return "obp_finding"
|
||
if fl.startswith("obp_") and "best_practices" in fl: return "obp_best_practice"
|
||
if fl.startswith("obp_"): return "obp_finding"
|
||
if fl.startswith("raw_data_"): return "raw_data"
|
||
if fl.startswith("cis_"): return "cis_finding"
|
||
if "consolidated_report" in fl: return "consolidated"
|
||
if fl.endswith(".png"): return "diagram"
|
||
return "other"
|
||
|
||
@app.post("/api/reports/run")
|
||
async def run_report(req: RunReportReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||
if req.level not in (1, 2): raise HTTPException(400, "Level must be 1 or 2")
|
||
with db() as c:
|
||
cfg = c.execute("SELECT * FROM oci_configs WHERE id=?",(req.config_id,)).fetchone()
|
||
if not cfg: raise HTTPException(404, "Config não encontrada")
|
||
rid = str(uuid.uuid4())
|
||
c.execute("INSERT INTO reports (id,user_id,tenancy_name,config_id,level,obp_checks,raw_data,redact_output,status) VALUES (?,?,?,?,?,?,?,?,?)",
|
||
(rid, u["id"], cfg["tenancy_name"], req.config_id, req.level, int(req.obp), int(req.raw), int(req.redact_output), "running"))
|
||
bg.add_task(_exec_report, rid, dict(cfg), req.regions, req.level, req.obp, req.raw, req.redact_output)
|
||
_audit(u["id"], u["username"], "run_report", rid)
|
||
return {"report_id": rid, "status": "running"}
|
||
|
||
async def _exec_report(rid, cfg, regions, level=2, obp=False, raw=False, redact_output=False):
|
||
rdir = REPORTS / rid; rdir.mkdir(parents=True, exist_ok=True)
|
||
config_path = str(OCI_DIR / cfg["id"] / "config")
|
||
try:
|
||
cmd = ["python3", "-u", "/app/cis_reports.py",
|
||
"-c", config_path,
|
||
"--report-directory", str(rdir),
|
||
"--level", str(level),
|
||
"--print-to-screen", "True",
|
||
"--report-summary-json"]
|
||
if regions: cmd += ["--regions", ",".join(regions)]
|
||
if obp: cmd.append("--obp")
|
||
if raw: cmd.append("--raw")
|
||
if redact_output: cmd.append("--redact-output")
|
||
proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
|
||
_running_reports[rid] = proc
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET worker_pid=? WHERE id=?", (proc.pid, rid))
|
||
progress_lines = []
|
||
try:
|
||
while True:
|
||
line = await proc.stdout.readline()
|
||
if not line:
|
||
break
|
||
text = line.decode(errors="replace").strip()
|
||
if text:
|
||
progress_lines.append(text)
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET progress=? WHERE id=?",
|
||
("\n".join(progress_lines[-50:]), rid))
|
||
await proc.wait()
|
||
finally:
|
||
_running_reports.pop(rid, None)
|
||
stderr_data = await proc.stderr.read()
|
||
# Check if cancelled
|
||
with db() as c:
|
||
cur_status = c.execute("SELECT status FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if cur_status and cur_status["status"] == "cancelled":
|
||
return
|
||
if proc.returncode == 0:
|
||
# Scan output directory for all generated files
|
||
html_path = None; json_path = None
|
||
with db() as c:
|
||
for fpath in rdir.iterdir():
|
||
if fpath.is_file():
|
||
fname = fpath.name
|
||
ftype = fpath.suffix.lstrip(".")
|
||
category = _classify_report_file(fname)
|
||
fsize = fpath.stat().st_size
|
||
c.execute("INSERT INTO report_files (id,report_id,file_name,file_path,file_type,file_category,file_size) VALUES (?,?,?,?,?,?,?)",
|
||
(str(uuid.uuid4()), rid, fname, str(fpath), ftype, category, fsize))
|
||
if "summary_report" in fname and fname.endswith(".html"):
|
||
html_path = str(fpath)
|
||
elif "summary_report" in fname and fname.endswith(".json"):
|
||
json_path = str(fpath)
|
||
c.execute("UPDATE reports SET status='completed',progress=?,html_path=?,json_path=?,completed_at=datetime('now') WHERE id=?",
|
||
("\n".join(progress_lines), html_path, json_path, rid))
|
||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "success", "report", f"Report completed: {rid}")
|
||
else:
|
||
err = (stderr_data.decode(errors="replace") if stderr_data else "Unknown")[:2000]
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET status='failed',progress=?,error_msg=?,completed_at=datetime('now') WHERE id=?",
|
||
("\n".join(progress_lines), err, rid))
|
||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", err)
|
||
except Exception as e:
|
||
_running_reports.pop(rid, None)
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET status='failed',error_msg=?,completed_at=datetime('now') WHERE id=?", (str(e)[:2000], rid))
|
||
_config_log("oci", cfg["id"], cfg["tenancy_name"], "error", "report", str(e)[:2000])
|
||
|
||
@app.get("/api/reports")
|
||
async def list_reports(u=Depends(current_user)):
|
||
with db() as c:
|
||
q = "SELECT id,user_id,tenancy_name,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
|
||
rows = c.execute(q+" ORDER BY created_at DESC").fetchall() if u["role"]=="admin" \
|
||
else c.execute(q+" WHERE user_id=? ORDER BY created_at DESC",(u["id"],)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.get("/api/reports/{rid}/progress")
|
||
async def get_report_progress(rid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT status,progress,created_at,completed_at,error_msg FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
return dict(r)
|
||
|
||
@app.post("/api/reports/{rid}/cancel")
|
||
async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
|
||
with db() as c:
|
||
r = c.execute("SELECT status, worker_pid FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if r["status"] != "running":
|
||
raise HTTPException(400, "Report is not running")
|
||
proc = _running_reports.get(rid)
|
||
if proc:
|
||
try:
|
||
proc.terminate()
|
||
try:
|
||
await asyncio.wait_for(proc.wait(), timeout=5)
|
||
except asyncio.TimeoutError:
|
||
proc.kill()
|
||
except ProcessLookupError:
|
||
pass
|
||
_running_reports.pop(rid, None)
|
||
elif r["worker_pid"]:
|
||
import signal
|
||
try:
|
||
os.kill(r["worker_pid"], signal.SIGTERM)
|
||
except (ProcessLookupError, OSError):
|
||
pass
|
||
with db() as c:
|
||
c.execute("UPDATE reports SET status='cancelled',error_msg='Cancelled by user',completed_at=datetime('now') WHERE id=?", (rid,))
|
||
_audit(u["id"], u["username"], "cancel_report", rid)
|
||
return {"ok": True, "status": "cancelled"}
|
||
|
||
@app.get("/api/reports/{rid}")
|
||
async def get_report(rid, u=Depends(current_user)):
|
||
with db() as c: r=c.execute("SELECT * FROM reports WHERE id=?",(rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if u["role"]!="admin" and r["user_id"]!=u["id"]: raise HTTPException(403)
|
||
return dict(r)
|
||
|
||
@app.get("/api/reports/{rid}/summary")
|
||
async def report_summary(rid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT user_id,json_path,tenancy_name,level,created_at FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||
jp = r["json_path"]
|
||
if not jp or not Path(jp).exists():
|
||
raise HTTPException(400, "Report JSON not found")
|
||
data = json.loads(Path(jp).read_text())
|
||
total = passed = failed = 0
|
||
sections = {}
|
||
total_findings = 0
|
||
total_compliant = 0
|
||
# Format: list of checks with Compliant=Yes/No, Section, Findings, Compliant Items, Total
|
||
checks = data if isinstance(data, list) else data.get("findings", [])
|
||
if isinstance(checks, dict):
|
||
checks = list(checks.values())
|
||
for check in checks:
|
||
total += 1
|
||
compliant = str(check.get("Compliant", check.get("status", ""))).strip().lower()
|
||
is_pass = compliant in ("yes", "pass")
|
||
if is_pass:
|
||
passed += 1
|
||
else:
|
||
failed += 1
|
||
fi = str(check.get("Findings", "0")).strip()
|
||
ci = str(check.get("Compliant Items", "0")).strip()
|
||
total_findings += int(fi) if fi.isdigit() else 0
|
||
total_compliant += int(ci) if ci.isdigit() else 0
|
||
sec = check.get("Section", check.get("section", "Other"))
|
||
s = sections.setdefault(sec, {"passed": 0, "failed": 0, "total": 0})
|
||
s["total"] += 1
|
||
if is_pass:
|
||
s["passed"] += 1
|
||
else:
|
||
s["failed"] += 1
|
||
score = round((passed / total * 100), 1) if total else 0
|
||
return {
|
||
"tenancy": data.get("tenancy", r["tenancy_name"]) if isinstance(data, dict) else r["tenancy_name"],
|
||
"level": r["level"] or 2,
|
||
"regions": data.get("regions", []) if isinstance(data, dict) else [],
|
||
"generated_at": r["created_at"],
|
||
"total": total, "passed": passed, "failed": failed,
|
||
"total_findings": total_findings, "total_compliant": total_compliant,
|
||
"score": score,
|
||
"sections": sections
|
||
}
|
||
|
||
@app.get("/api/reports/{rid}/files")
|
||
async def list_report_files(rid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||
files = c.execute(
|
||
"SELECT id,file_name,file_type,file_category,file_size FROM report_files WHERE report_id=? ORDER BY file_category,file_name",
|
||
(rid,)
|
||
).fetchall()
|
||
return [dict(f) for f in files]
|
||
|
||
@app.get("/api/reports/{rid}/files/{fid}/download")
|
||
async def download_report_file(rid: str, fid: 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 user_id FROM reports WHERE id=?", (rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
if u["role"] != "admin" and r["user_id"] != u["id"]: raise HTTPException(403)
|
||
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
||
if not f: raise HTTPException(404)
|
||
p = Path(f["file_path"])
|
||
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
||
return FileResponse(p, filename=f["file_name"])
|
||
|
||
@app.get("/api/reports/{rid}/html")
|
||
async def report_html(rid):
|
||
with db() as c: r=c.execute("SELECT html_path FROM reports WHERE id=?",(rid,)).fetchone()
|
||
if not r or not r["html_path"] or not Path(r["html_path"]).exists(): raise HTTPException(404)
|
||
return FileResponse(r["html_path"], media_type="text/html")
|
||
|
||
@app.get("/api/reports/{rid}/download")
|
||
async def report_dl(rid, fmt: str = Query("json"), 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 * FROM reports WHERE id=?",(rid,)).fetchone()
|
||
if not r: raise HTTPException(404)
|
||
p = r["json_path"] if fmt=="json" else r["html_path"]
|
||
if not p or not Path(p).exists(): raise HTTPException(404)
|
||
return FileResponse(p, filename=f"cis_{r['tenancy_name']}_{rid[:8]}.{fmt}")
|
||
|
||
# ── Chat Agent ────────────────────────────────────────────────────────────────
|
||
# (endpoints defined below _chat_core, after _agent_respond)
|
||
|
||
def _chat_start(msg: ChatMsg, u, attachments: list = None, agent_type: str = "chat"):
|
||
"""Start a chat: save user msg, resolve config, return (sid, mid, genai_cfg) or immediate response.
|
||
If genai_cfg is None, returns immediate fallback response in mid field as dict."""
|
||
is_new = not msg.session_id
|
||
sid = msg.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", msg.message, None, "done"))
|
||
if is_new:
|
||
title = (msg.message or "Nova conversa")[:80].strip()
|
||
c.execute("INSERT OR IGNORE INTO chat_sessions (id,user_id,agent_type,title) VALUES (?,?,?,?)",
|
||
(sid, u["id"], agent_type, title))
|
||
else:
|
||
c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,))
|
||
|
||
genai_cfg = None
|
||
if msg.genai_config_id:
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM genai_configs WHERE id=?", (msg.genai_config_id,)).fetchone()
|
||
if row:
|
||
genai_cfg = dict(row)
|
||
if msg.temperature is not None: genai_cfg["temperature"] = msg.temperature
|
||
if msg.max_tokens is not None: genai_cfg["max_tokens"] = msg.max_tokens
|
||
if msg.top_p is not None: genai_cfg["top_p"] = msg.top_p
|
||
if msg.top_k is not None: genai_cfg["top_k"] = msg.top_k
|
||
if msg.frequency_penalty is not None: genai_cfg["frequency_penalty"] = msg.frequency_penalty
|
||
if msg.presence_penalty is not None: genai_cfg["presence_penalty"] = msg.presence_penalty
|
||
elif msg.model_id and msg.oci_config_id:
|
||
with db() as c:
|
||
oci_row = c.execute("SELECT * FROM oci_configs WHERE id=?", (msg.oci_config_id,)).fetchone()
|
||
if not oci_row:
|
||
raise HTTPException(400, "OCI config not found")
|
||
region = msg.genai_region or oci_row["region"]
|
||
compartment = _safe_dec(oci_row["compartment_id"]) if oci_row["compartment_id"] else ""
|
||
if not compartment:
|
||
raise HTTPException(400, "compartment_id required")
|
||
genai_cfg = {
|
||
"oci_config_id": msg.oci_config_id,
|
||
"model_id": msg.model_id,
|
||
"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": msg.temperature if msg.temperature is not None else 1.0,
|
||
"max_tokens": msg.max_tokens if msg.max_tokens is not None else 6000,
|
||
"top_p": msg.top_p if msg.top_p is not None else 0.95,
|
||
"top_k": msg.top_k if msg.top_k is not None else 1,
|
||
"frequency_penalty": msg.frequency_penalty if msg.frequency_penalty is not None else 0.0,
|
||
"presence_penalty": msg.presence_penalty if msg.presence_penalty is not None else 0.0,
|
||
}
|
||
|
||
if not genai_cfg:
|
||
# No GenAI config — return immediate fallback
|
||
resp = _agent_respond(msg.message, u)
|
||
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", resp, None, "done"))
|
||
return sid, {"session_id": sid, "response": resp, "model_id": None, "status": "done"}, None
|
||
|
||
# Create placeholder assistant message for background processing
|
||
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", "", genai_cfg.get("model_id"), "processing"))
|
||
return sid, mid, genai_cfg
|
||
|
||
|
||
async def _chat_background(mid: str, sid: str, msg: ChatMsg, user: dict, genai_cfg: dict, attachments: list = None, agent_type: str = "chat"):
|
||
"""Background worker — processes GenAI chat, updates DB when done."""
|
||
log.info(f"Chat background started: mid={mid}, sid={sid}")
|
||
try:
|
||
history = []
|
||
with db() as c:
|
||
prev = c.execute("SELECT role,content FROM chat_messages WHERE session_id=? AND role IN ('user','assistant') AND status='done' ORDER BY created_at ASC", (sid,)).fetchall()
|
||
history = [{"role":r["role"],"content":r["content"]} for r in prev]
|
||
|
||
# ── RAG: augment with vector context from ALL active ADB configs ──
|
||
# Resolve active tenancy for filtered vector search
|
||
rag_tenancy = None
|
||
active_oci_for_rag = genai_cfg.get("oci_config_id") or (msg.oci_config_id if hasattr(msg, 'oci_config_id') and msg.oci_config_id else None)
|
||
if active_oci_for_rag:
|
||
with db() as c:
|
||
oci_for_rag = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (active_oci_for_rag,)).fetchone()
|
||
if oci_for_rag:
|
||
rag_tenancy = oci_for_rag["tenancy_name"]
|
||
log.info(f"RAG: filtering by tenancy '{rag_tenancy}'")
|
||
|
||
rag_context = ""
|
||
adb_cfgs = _get_active_adb_configs(user["id"])
|
||
if adb_cfgs:
|
||
all_documents = []
|
||
for adb_cfg in adb_cfgs:
|
||
try:
|
||
genai_linked = None
|
||
if adb_cfg.get("genai_config_id"):
|
||
with db() as c:
|
||
row = c.execute("SELECT * FROM genai_configs WHERE id=?", (adb_cfg["genai_config_id"],)).fetchone()
|
||
if row: genai_linked = dict(row)
|
||
emb_genai = _resolve_embed_config(oci_config_id=active_oci_for_rag, genai_cfg=genai_linked)
|
||
if emb_genai:
|
||
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||
query_embedding = _embed_text(msg.message, emb_genai, emb_model)
|
||
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||
if not tables:
|
||
tables = [{"table_name": adb_cfg.get("table_name", "")}]
|
||
for tbl in tables:
|
||
try:
|
||
documents = _vector_search(adb_cfg, query_embedding, top_k=5, table_name=tbl["table_name"], tenancy=rag_tenancy)
|
||
if documents:
|
||
for doc in documents:
|
||
doc["source"] = f"{doc.get('source', 'unknown')} [{tbl['table_name']}]"
|
||
all_documents.extend(documents)
|
||
log.info(f"RAG: Retrieved {len(documents)} docs from {tbl['table_name']}")
|
||
except Exception as te:
|
||
log.warning(f"RAG search failed for table {tbl['table_name']}: {te}")
|
||
_chat_log(sid, mid, user["id"], "warning", "rag", "search_failed", f"{tbl['table_name']}: {te}")
|
||
except Exception as e:
|
||
log.warning(f"RAG retrieval failed for {adb_cfg.get('config_name','?')} (non-fatal): {e}")
|
||
_chat_log(sid, mid, user["id"], "warning", "rag", "retrieval_failed", f"{adb_cfg.get('config_name','?')}: {e}")
|
||
if all_documents:
|
||
all_documents.sort(key=lambda d: d["distance"])
|
||
rag_context = _build_rag_context(all_documents[:10])
|
||
|
||
cfg_dict = dict(genai_cfg)
|
||
with db() as c:
|
||
sp_row = c.execute("SELECT content FROM system_prompts WHERE agent=? AND is_active=1 LIMIT 1", (agent_type,)).fetchone()
|
||
global_prompt = sp_row["content"] if sp_row and sp_row["content"] else ""
|
||
if rag_context:
|
||
augmented_message = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=msg.message)
|
||
cfg_dict["system_prompt"] = global_prompt or RAG_DEFAULT_SYSTEM_PROMPT
|
||
else:
|
||
augmented_message = msg.message
|
||
if global_prompt:
|
||
cfg_dict["system_prompt"] = global_prompt
|
||
|
||
# ── Inject all config context into system prompt so model auto-resolves IDs ──
|
||
ctx_parts = []
|
||
active_oci_id = cfg_dict.get("oci_config_id") or (msg.oci_config_id if msg.oci_config_id else None)
|
||
with db() as c:
|
||
oci_cfgs = c.execute("SELECT id,tenancy_name,region,compartment_id FROM oci_configs WHERE user_id=?", (user["id"],)).fetchall()
|
||
genai_cfgs = c.execute("SELECT id,name,model_id,genai_region,compartment_id,oci_config_id FROM genai_configs WHERE user_id=?", (user["id"],)).fetchall()
|
||
adb_cfgs = c.execute("SELECT id,config_name,dsn,table_name,is_active,genai_config_id,embedding_model_id FROM adb_vector_configs WHERE user_id=? AND is_active=1", (user["id"],)).fetchall()
|
||
mcp_srvs = c.execute("SELECT id,name,description,is_active FROM mcp_servers WHERE is_active=1 AND (user_id=? OR EXISTS (SELECT 1 FROM users WHERE id=? AND role='admin'))", (user["id"], user["id"])).fetchall()
|
||
# ── Active OCI config (selected by user in this session) ──
|
||
if active_oci_id:
|
||
for oc in oci_cfgs:
|
||
if oc["id"] == active_oci_id:
|
||
comp = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else "N/A"
|
||
ctx_parts.append(
|
||
f"⚡ CONFIG OCI ATIVA (usar como config_id em TODAS as tools que precisarem): "
|
||
f"config_id=\"{oc['id']}\" tenancy=\"{oc['tenancy_name']}\" region=\"{oc['region']}\" compartment_id=\"{comp}\""
|
||
)
|
||
break
|
||
if oci_cfgs:
|
||
ctx_parts.append("\nTodas as configurações OCI disponíveis (use 'id' como config_id):")
|
||
for oc in oci_cfgs:
|
||
comp = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else "N/A"
|
||
active_tag = " ← ATIVA" if oc["id"] == active_oci_id else ""
|
||
ctx_parts.append(f" - id=\"{oc['id']}\" tenancy=\"{oc['tenancy_name']}\" region=\"{oc['region']}\" compartment_id=\"{comp}\"{active_tag}")
|
||
if genai_cfgs:
|
||
ctx_parts.append("\nConfigurações GenAI disponíveis (use 'id' como genai_config_id):")
|
||
for gc in genai_cfgs:
|
||
comp = _safe_dec(gc["compartment_id"]) if gc["compartment_id"] else "N/A"
|
||
ctx_parts.append(f" - id=\"{gc['id']}\" name=\"{gc['name']}\" model=\"{gc['model_id']}\" region=\"{gc['genai_region']}\" compartment_id=\"{comp}\"")
|
||
if adb_cfgs:
|
||
ctx_parts.append("\nConfigurações ADB Vector Store disponíveis (use 'id' como adb_config_id):")
|
||
for ac in adb_cfgs:
|
||
emb = ac["embedding_model_id"] if ac["embedding_model_id"] else "N/A"
|
||
ctx_parts.append(f" - id=\"{ac['id']}\" name=\"{ac['config_name']}\" table=\"{ac['table_name']}\" embedding_model=\"{emb}\"")
|
||
if mcp_srvs:
|
||
ctx_parts.append("\nMCP Servers ativos (tools disponíveis para uso):")
|
||
for ms in mcp_srvs:
|
||
desc = ms["description"] or ""
|
||
ctx_parts.append(f" - id=\"{ms['id']}\" name=\"{ms['name']}\" desc=\"{desc}\"")
|
||
if ctx_parts:
|
||
ctx_parts.append("\nIMPORTANTE: Use automaticamente a config OCI ATIVA como config_id em todas as tools. NUNCA peça config_id, tenancy ou IDs ao usuário — já estão definidos acima.")
|
||
config_hint = "\n".join(ctx_parts)
|
||
base_prompt = cfg_dict.get("system_prompt", "")
|
||
cfg_dict["system_prompt"] = f"{base_prompt}\n\n{config_hint}" if base_prompt else config_hint
|
||
|
||
# ── Terraform agent: boost max_tokens for complex infra code ──
|
||
if agent_type == "terraform":
|
||
cfg_dict["max_tokens"] = max(int(cfg_dict.get("max_tokens", 6000)), 100000)
|
||
|
||
# ── Inject existing OCI resources for terraform agent ──
|
||
if agent_type == "terraform" and active_oci_id:
|
||
try:
|
||
# Determine compartment: from msg or from OCI config
|
||
tf_compartment = getattr(msg, 'compartment_id', None) or None
|
||
if not tf_compartment:
|
||
for oc in oci_cfgs:
|
||
if oc["id"] == active_oci_id:
|
||
tf_compartment = _safe_dec(oc["compartment_id"]) if oc["compartment_id"] else None
|
||
break
|
||
if tf_compartment:
|
||
tf_region = None
|
||
for oc in oci_cfgs:
|
||
if oc["id"] == active_oci_id:
|
||
tf_region = oc["region"]
|
||
break
|
||
loop = asyncio.get_event_loop()
|
||
resources = await loop.run_in_executor(
|
||
_chat_executor, partial(_fetch_compartment_resources, active_oci_id, tf_compartment, tf_region))
|
||
resource_ctx = _build_resource_context(resources)
|
||
cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + "\n\n" + resource_ctx
|
||
log.info(f"Terraform: injected resource context for compartment {tf_compartment[:20]}...")
|
||
except Exception as e:
|
||
log.warning(f"Failed to inject terraform resource context: {e}")
|
||
|
||
# ── Inject OCI Terraform resource reference for correct resource names ──
|
||
if agent_type == "terraform":
|
||
tf_ref = _load_tf_resource_reference()
|
||
if tf_ref:
|
||
# Extract only the categorized sections (not the full 900+ resource list)
|
||
# to keep prompt size manageable
|
||
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:
|
||
cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + \
|
||
"\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
|
||
log.info(f"Terraform: injected resource reference ({len(ref_compact)} chars)")
|
||
|
||
# ── Inject official Terraform resource docs (Example Usage + Arguments) ──
|
||
if agent_type == "terraform":
|
||
try:
|
||
# Detect resource types from user message + recent conversation history
|
||
detect_text = msg.message if hasattr(msg, 'message') else ""
|
||
if history:
|
||
# Include last 3 messages for context
|
||
for h in history[-3:]:
|
||
detect_text += "\n" + (h.get("content", "") or "")
|
||
resource_types = _detect_tf_resource_types(detect_text)
|
||
if resource_types:
|
||
loop = asyncio.get_event_loop()
|
||
docs_ctx = await loop.run_in_executor(
|
||
_chat_executor, partial(_get_tf_docs_for_resources, resource_types))
|
||
if docs_ctx:
|
||
cfg_dict["system_prompt"] = cfg_dict.get("system_prompt", "") + "\n\n" + docs_ctx
|
||
log.info(f"Terraform: injected {len(resource_types)} resource docs ({len(docs_ctx)} chars)")
|
||
except Exception as e:
|
||
log.warning(f"Failed to inject terraform resource docs: {e}")
|
||
|
||
# Log total prompt sizes for debugging
|
||
if agent_type == "terraform":
|
||
sp_len = len(cfg_dict.get("system_prompt", ""))
|
||
msg_len = len(msg.message) if hasattr(msg, 'message') else 0
|
||
log.info(f"Terraform prompt sizes: system_prompt={sp_len} chars (~{sp_len//4} tokens), user_msg={msg_len} chars (~{msg_len//4} tokens), max_tokens={cfg_dict.get('max_tokens')}")
|
||
|
||
mcp_tools = []
|
||
tool_defs = None
|
||
if msg.use_tools:
|
||
mcp_tools = _get_active_mcp_tools(user["id"])
|
||
if mcp_tools:
|
||
tool_defs = [t["tool"] for t in mcp_tools]
|
||
log.info(f"Chat with {len(tool_defs)} MCP tools available")
|
||
|
||
if history and _should_compact(history):
|
||
log.info(f"Compaction triggered: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens")
|
||
history = _compact_history(sid, user["id"], cfg_dict, history)
|
||
log.info(f"Post-compaction: {len(history)} msgs, ~{_estimate_history_tokens(history)} est tokens")
|
||
|
||
hist = history[:-1] if len(history) > 1 else None
|
||
loop = asyncio.get_event_loop()
|
||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||
tool_defs, None, None, attachments))
|
||
|
||
all_tool_results = []
|
||
accumulated_msgs = []
|
||
iterations = 0
|
||
api_format = GENAI_MODELS.get(genai_cfg.get("model_id", ""), {}).get("api_format", "GENERIC")
|
||
while tool_calls and iterations < 5:
|
||
iterations += 1
|
||
log.info(f"Tool use iteration {iterations}: {len(tool_calls)} tool call(s)")
|
||
iteration_results = []
|
||
for tc in tool_calls:
|
||
mcp_match = next((m for m in mcp_tools if m["tool"]["name"] == tc["name"]), None)
|
||
if mcp_match:
|
||
try:
|
||
result = await _execute_mcp_tool(mcp_match["server"], tc["name"], tc["arguments"])
|
||
log.info(f"Tool {tc['name']} executed successfully ({len(result)} chars)")
|
||
_chat_log(sid, mid, user["id"], "info", "tool", "tool_success", f"{tc['name']} ({len(result)} chars)")
|
||
except Exception as te:
|
||
result = f"Erro ao executar tool {tc['name']}: {str(te)[:300]}"
|
||
log.warning(f"Tool {tc['name']} failed: {te}")
|
||
_chat_log(sid, mid, user["id"], "error", "tool", "tool_error", f"{tc['name']}: {te}")
|
||
else:
|
||
result = f"Tool {tc['name']} não encontrada nos MCP servers ativos"
|
||
_chat_log(sid, mid, user["id"], "error", "tool", "tool_not_found", tc["name"])
|
||
iteration_results.append({"tool_call_id": tc["id"], "name": tc["name"], "content": result})
|
||
all_tool_results.extend(iteration_results)
|
||
|
||
if api_format == "COHERE":
|
||
import oci
|
||
cohere_results = []
|
||
for tr in iteration_results:
|
||
tc_obj = oci.generative_ai_inference.models.CohereToolCall()
|
||
tc_obj.name = tr["name"]
|
||
tc_obj.parameters = {}
|
||
tr_obj = oci.generative_ai_inference.models.CohereToolResult()
|
||
tr_obj.call = tc_obj
|
||
tr_obj.outputs = [{"result": tr["content"]}]
|
||
cohere_results.append(tr_obj)
|
||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||
tool_defs, cohere_results))
|
||
else:
|
||
import oci
|
||
assistant_msg = oci.generative_ai_inference.models.AssistantMessage()
|
||
assistant_msg.tool_calls = tool_calls_raw
|
||
accumulated_msgs.append(assistant_msg)
|
||
for tr in iteration_results:
|
||
tool_msg = oci.generative_ai_inference.models.ToolMessage()
|
||
tool_msg.tool_call_id = tr["tool_call_id"]
|
||
tool_content = oci.generative_ai_inference.models.TextContent()
|
||
tool_content.text = tr["content"]
|
||
tool_msg.content = [tool_content]
|
||
accumulated_msgs.append(tool_msg)
|
||
resp_text, tool_calls, tool_calls_raw = await loop.run_in_executor(
|
||
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||
tool_defs, None, accumulated_msgs))
|
||
|
||
resp = resp_text
|
||
if all_tool_results:
|
||
tools_info = '\n\n🔧 **Tools utilizadas:** ' + ', '.join(tr["name"] for tr in all_tool_results)
|
||
resp += tools_info
|
||
|
||
if not resp or not resp.strip():
|
||
model_id = genai_cfg.get("model_id", "unknown")
|
||
resp = f"⚠️ O modelo **{model_id}** retornou uma resposta vazia. Isso pode ocorrer com alguns modelos. Tente novamente ou selecione outro modelo (ex: Gemini, Llama)."
|
||
log.warning(f"Chat {mid}: empty response from model {model_id}, returning fallback message")
|
||
|
||
# Validate terraform resource types against DB
|
||
if agent_type == "terraform" and resp and '```' in resp:
|
||
try:
|
||
tf_errors = _validate_tf_resource_types(resp)
|
||
if tf_errors:
|
||
warning_lines = ["\n\n⚠️ **Validação automática — Resource types inválidos detectados:**"]
|
||
for err in tf_errors:
|
||
line = f"- `{err['type']}` — NÃO EXISTE no provider oracle/oci."
|
||
if err['suggestions']:
|
||
line += f" Sugestões: {', '.join('`' + s + '`' for s in err['suggestions'])}"
|
||
warning_lines.append(line)
|
||
warning_lines.append("\n**Clique em 'Re-plan' após o modelo corrigir, ou peça correção no chat.**")
|
||
resp += '\n'.join(warning_lines)
|
||
log.warning(f"Chat {mid}: {len(tf_errors)} invalid TF resource types detected: {[e['type'] for e in tf_errors]}")
|
||
except Exception as e:
|
||
log.warning(f"TF resource type validation failed: {e}")
|
||
|
||
with db() as c:
|
||
c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (resp, mid))
|
||
log.info(f"Chat {mid} completed successfully")
|
||
except Exception as e:
|
||
log.error(f"Chat {mid} failed: {e}")
|
||
_chat_log(sid, mid, user["id"], "error", "genai", "genai_failed", str(e))
|
||
with db() as c:
|
||
c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?",
|
||
(f"❌ Erro GenAI: {str(e)[:400]}", mid))
|
||
|
||
MAX_UPLOAD_FILES = 5
|
||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10MB
|
||
IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp"}
|
||
DOC_MIMES = {"application/pdf"}
|
||
|
||
|
||
@app.post("/api/chat/upload")
|
||
async def chat_with_files(
|
||
bg: BackgroundTasks,
|
||
message: str = Form(""),
|
||
session_id: Optional[str] = Form(None),
|
||
genai_config_id: Optional[str] = Form(None),
|
||
model_id: Optional[str] = Form(None),
|
||
oci_config_id: Optional[str] = Form(None),
|
||
genai_region: Optional[str] = Form(None),
|
||
compartment_id: Optional[str] = Form(None),
|
||
use_tools: Optional[bool] = Form(True),
|
||
temperature: Optional[float] = Form(None),
|
||
max_tokens: Optional[int] = Form(None),
|
||
top_p: Optional[float] = Form(None),
|
||
top_k: Optional[int] = Form(None),
|
||
frequency_penalty: Optional[float] = Form(None),
|
||
presence_penalty: Optional[float] = Form(None),
|
||
files: List[UploadFile] = File(default=[]),
|
||
u=Depends(current_user),
|
||
):
|
||
if len(files) > MAX_UPLOAD_FILES:
|
||
raise HTTPException(400, f"Máximo {MAX_UPLOAD_FILES} arquivos por mensagem")
|
||
|
||
attachments = []
|
||
file_names = []
|
||
for f in files:
|
||
data = await f.read()
|
||
if len(data) > MAX_UPLOAD_SIZE:
|
||
raise HTTPException(400, f"Arquivo {f.filename} excede 10MB")
|
||
mime = f.content_type or "application/octet-stream"
|
||
b64 = base64.b64encode(data).decode()
|
||
data_uri = f"data:{mime};base64,{b64}"
|
||
if mime in IMAGE_MIMES:
|
||
attachments.append({"type": "image", "mime": mime, "data_uri": data_uri})
|
||
elif mime in DOC_MIMES:
|
||
attachments.append({"type": "document", "mime": mime, "data_uri": data_uri})
|
||
else:
|
||
try:
|
||
text = data.decode("utf-8", errors="replace")
|
||
except Exception:
|
||
text = data.decode("latin-1", errors="replace")
|
||
message = f"{message}\n\n--- Conteúdo de {f.filename} ---\n{text[:50000]}"
|
||
file_names.append(f.filename)
|
||
|
||
msg = ChatMsg(
|
||
message=message or "Analise os arquivos anexados.",
|
||
session_id=session_id,
|
||
genai_config_id=genai_config_id,
|
||
model_id=model_id,
|
||
oci_config_id=oci_config_id,
|
||
genai_region=genai_region,
|
||
compartment_id=compartment_id,
|
||
use_tools=use_tools if use_tools is not None else True,
|
||
temperature=temperature,
|
||
max_tokens=max_tokens,
|
||
top_p=top_p,
|
||
top_k=top_k,
|
||
frequency_penalty=frequency_penalty,
|
||
presence_penalty=presence_penalty,
|
||
)
|
||
|
||
sid, mid_or_result, genai_cfg = _chat_start(msg, u, attachments=attachments if attachments else None)
|
||
if genai_cfg is None:
|
||
return mid_or_result # immediate fallback response
|
||
|
||
if file_names:
|
||
with db() as c:
|
||
c.execute("UPDATE chat_messages SET content = content || ? WHERE session_id=? AND role='user' AND status='done' ORDER BY created_at DESC LIMIT 1",
|
||
(f"\n[📎 {', '.join(file_names)}]", sid))
|
||
|
||
bg.add_task(_chat_background, mid_or_result, sid, msg, dict(u), genai_cfg, attachments if attachments else None)
|
||
return {"message_id": mid_or_result, "session_id": sid, "status": "processing"}
|
||
|
||
|
||
@app.post("/api/chat")
|
||
async def chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)):
|
||
sid, mid_or_result, genai_cfg = _chat_start(msg, u)
|
||
if genai_cfg is None:
|
||
return mid_or_result # immediate fallback response
|
||
bg.add_task(_chat_background, mid_or_result, sid, msg, dict(u), genai_cfg)
|
||
return {"message_id": mid_or_result, "session_id": sid, "status": "processing"}
|
||
|
||
|
||
@app.get("/api/chat/{mid}/status")
|
||
async def chat_message_status(mid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
r = c.execute("SELECT id, session_id, role, content, model_id, status FROM chat_messages WHERE id=?", (mid,)).fetchone()
|
||
if not r:
|
||
raise HTTPException(404, "Message not found")
|
||
return dict(r)
|
||
|
||
|
||
def _agent_respond(msg, user):
|
||
m = msg.lower().strip()
|
||
if any(k in m for k in ["status","health","como está","saúde"]):
|
||
cli = "✅ Instalado" if shutil.which("oci") else "❌ Não instalado"
|
||
with db() as c:
|
||
nc=c.execute("SELECT COUNT(*) n FROM oci_configs").fetchone()["n"]
|
||
nr=c.execute("SELECT COUNT(*) n FROM reports").fetchone()["n"]
|
||
nu=c.execute("SELECT COUNT(*) n FROM users WHERE is_active=1").fetchone()["n"]
|
||
nm=c.execute("SELECT COUNT(*) n FROM mcp_servers WHERE is_active=1").fetchone()["n"]
|
||
ng=c.execute("SELECT COUNT(*) n FROM genai_configs").fetchone()["n"]
|
||
return (f"📊 **Status do Sistema**\n\n• OCI CLI: {cli}\n• Configs OCI: {nc}\n• GenAI Models: {ng}\n• MCP Servers: {nm}\n• Relatórios: {nr}\n• Usuários ativos: {nu}\n• Servidor: ✅ Online\n• Versão: v{VERSION}")
|
||
if any(k in m for k in ["ajuda","help","comandos"]):
|
||
return ("🤖 **Comandos disponíveis:**\n\n• `status` — Status do sistema\n• `listar configs` — Configurações OCI\n• `verificar cis` — Checks CIS 3.0\n• `modelos` — Modelos GenAI disponíveis\n• `ajuda` — Esta mensagem\n\n💡 Configure um modelo GenAI para chat com IA.")
|
||
if any(k in m for k in ["modelo","modelos","genai"]):
|
||
lines = ["🧠 **Modelos OCI Generative AI disponíveis:**\n"]
|
||
for mid, info in GENAI_MODELS.items():
|
||
lines.append(f"• `{mid}` — {info['name']} ({info['provider']})")
|
||
lines.append("\nConfigure em **GenAI Config** para usar no chat.")
|
||
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"
|
||
"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")
|
||
async def list_chat_sessions(agent_type: str = "chat", limit: int = 50, u=Depends(current_user)):
|
||
with db() as c:
|
||
rows = c.execute(
|
||
"""SELECT cs.id, cs.title, cs.agent_type, cs.created_at, cs.updated_at,
|
||
(SELECT COUNT(*) FROM chat_messages cm WHERE cm.session_id=cs.id) as message_count
|
||
FROM chat_sessions cs WHERE cs.user_id=? AND cs.agent_type=?
|
||
ORDER BY cs.updated_at DESC LIMIT ?""",
|
||
(u["id"], agent_type, limit)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
@app.get("/api/chat/sessions/{sid}/messages")
|
||
async def get_session_messages(sid: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
session = c.execute("SELECT * FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
|
||
if not session:
|
||
raise HTTPException(404, "Session not found")
|
||
msgs = c.execute(
|
||
"SELECT id, role, content, model_id, status, created_at FROM chat_messages "
|
||
"WHERE session_id=? AND status='done' ORDER BY created_at ASC", (sid,)).fetchall()
|
||
return {"session": dict(session), "messages": [dict(m) for m in msgs]}
|
||
|
||
|
||
@app.put("/api/chat/sessions/{sid}/title")
|
||
async def rename_session(sid: str, req: dict = Body(...), u=Depends(current_user)):
|
||
with db() as c:
|
||
c.execute("UPDATE chat_sessions SET title=? WHERE id=? AND user_id=?",
|
||
(req.get("title", "")[:120], sid, u["id"]))
|
||
return {"ok": True}
|
||
|
||
|
||
@app.delete("/api/chat/{sid}")
|
||
async def clear_chat(sid, u=Depends(current_user)):
|
||
with db() as c:
|
||
# Delete associated terraform workspaces and their files
|
||
ws_rows = c.execute("SELECT id FROM terraform_workspaces WHERE session_id=? AND user_id=?", (sid, u["id"])).fetchall()
|
||
for ws in ws_rows:
|
||
wdir = TERRAFORM_DIR / ws["id"]
|
||
if wdir.exists():
|
||
shutil.rmtree(wdir, ignore_errors=True)
|
||
c.execute("DELETE FROM terraform_workspaces WHERE session_id=? AND user_id=?", (sid, u["id"]))
|
||
c.execute("DELETE FROM chat_messages WHERE session_id=? AND user_id=?", (sid, u["id"]))
|
||
c.execute("DELETE FROM chat_sessions WHERE id=? AND user_id=?", (sid, u["id"]))
|
||
return {"ok": True}
|
||
|
||
|
||
# ── Terraform Agent ───────────────────────────────────────────────────────────
|
||
|
||
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)
|
||
|
||
|
||
@app.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")
|
||
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])
|
||
|
||
|
||
@app.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")
|
||
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])
|
||
|
||
|
||
@app.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")
|
||
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])
|
||
|
||
|
||
@app.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}
|
||
|
||
|
||
@app.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."""
|
||
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])
|
||
|
||
|
||
@app.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"}
|
||
|
||
|
||
class TfWorkspaceReq(BaseModel):
|
||
session_id: str
|
||
oci_config_id: str
|
||
compartment_id: Optional[str] = None
|
||
name: str = "workspace"
|
||
tf_code: str
|
||
|
||
|
||
@app.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"}
|
||
|
||
|
||
@app.get("/api/terraform/workspaces")
|
||
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",
|
||
(u["id"],)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
|
||
@app.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)
|
||
|
||
|
||
@app.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}
|
||
|
||
|
||
@app.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"}
|
||
|
||
|
||
@app.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"}
|
||
|
||
|
||
@app.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"}
|
||
|
||
|
||
@app.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)
|
||
|
||
|
||
@app.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")
|
||
from starlette.responses import Response
|
||
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"'})
|
||
|
||
|
||
@app.post("/api/terraform/workspaces/{wid}/cancel")
|
||
async def tf_cancel(wid: str, u=Depends(current_user)):
|
||
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}
|
||
|
||
|
||
@app.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():
|
||
import shutil as _sh
|
||
_sh.rmtree(wdir, ignore_errors=True)
|
||
return {"ok": True}
|
||
|
||
|
||
@app.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()
|
||
result = await loop.run_in_executor(_chat_executor, _regenerate_tf_reference)
|
||
# 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
|
||
|
||
|
||
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)
|
||
|
||
|
||
def _split_tf_monolith(content: str) -> dict:
|
||
"""Split a monolithic HCL string into multiple logical files by resource type.
|
||
Returns dict of {filename: content} or None if splitting not needed."""
|
||
import re as _re
|
||
content = _fix_single_line_blocks(content)
|
||
lines = content.split('\n')
|
||
top_blocks = []
|
||
preamble = []
|
||
accum = []
|
||
in_block = False
|
||
b_depth = 0
|
||
cur_header = ''
|
||
|
||
for line in lines:
|
||
stripped = _re.sub(r'"[^"]*"', '', line)
|
||
opens = stripped.count('{')
|
||
closes = stripped.count('}')
|
||
if not in_block:
|
||
hdr = _re.match(r'^\s*(variable|output|resource|data|provider|module)\s+"', line) or \
|
||
_re.match(r'^\s*(locals|terraform)\s*\{', line)
|
||
if hdr:
|
||
in_block = True
|
||
cur_header = line
|
||
accum = preamble + [line]
|
||
preamble = []
|
||
b_depth = opens - closes
|
||
if b_depth <= 0:
|
||
b_depth = 0
|
||
if b_depth == 0 and opens > 0:
|
||
top_blocks.append((cur_header, '\n'.join(accum)))
|
||
in_block = False
|
||
accum = []
|
||
else:
|
||
preamble.append(line)
|
||
else:
|
||
accum.append(line)
|
||
b_depth += opens - closes
|
||
if b_depth <= 0:
|
||
b_depth = 0
|
||
top_blocks.append((cur_header, '\n'.join(accum)))
|
||
in_block = False
|
||
accum = []
|
||
cur_header = ''
|
||
if accum:
|
||
top_blocks.append((cur_header or '', '\n'.join(accum)))
|
||
|
||
if len(top_blocks) < 3:
|
||
return None
|
||
|
||
cats = {'variables': [], 'outputs': [], 'networking': [], 'compute': [], 'database': [],
|
||
'firewall': [], 'loadbalancer': [], 'storage': [], 'iam': [], 'drg': [],
|
||
'data': [], 'providers': [], 'other': []}
|
||
for header, body in top_blocks:
|
||
h = header.strip()
|
||
if h.startswith('variable '):
|
||
cats['variables'].append(body)
|
||
elif h.startswith('output '):
|
||
cats['outputs'].append(body)
|
||
elif h.startswith('provider '):
|
||
cats['providers'].append(body)
|
||
elif _re.search(r'resource\s+"oci_core_(drg|remote_peering|drg_route|drg_attachment)', h):
|
||
cats['drg'].append(body)
|
||
elif _re.search(r'resource\s+"oci_core_(vcn|subnet|internet_gateway|nat_gateway|service_gateway|route_table|security_list|network_security_group|dhcp_options|local_peering_gateway|virtual_circuit)', h):
|
||
cats['networking'].append(body)
|
||
elif _re.search(r'resource\s+"oci_core_instance', h):
|
||
cats['compute'].append(body)
|
||
elif _re.search(r'resource\s+"oci_(database|nosql|mysql|psql)', h) or _re.search(r'resource\s+"oci_core_autonomous', h):
|
||
cats['database'].append(body)
|
||
elif _re.search(r'resource\s+"oci_network_firewall', h):
|
||
cats['firewall'].append(body)
|
||
elif _re.search(r'resource\s+"oci_(load_balancer|network_load_balancer)', h):
|
||
cats['loadbalancer'].append(body)
|
||
elif _re.search(r'resource\s+"oci_(objectstorage|file_storage)', h):
|
||
cats['storage'].append(body)
|
||
elif _re.search(r'resource\s+"oci_identity', h):
|
||
cats['iam'].append(body)
|
||
elif h.startswith('data '):
|
||
cats['data'].append(body)
|
||
else:
|
||
cats['other'].append(body)
|
||
|
||
result = {}
|
||
mapping = [('variables.tf', 'variables'), ('providers.tf', 'providers'), ('networking.tf', 'networking'),
|
||
('drg.tf', 'drg'), ('compute.tf', 'compute'), ('database.tf', 'database'),
|
||
('firewall.tf', 'firewall'), ('loadbalancer.tf', 'loadbalancer'), ('storage.tf', 'storage'),
|
||
('iam.tf', 'iam'), ('data.tf', 'data'), ('main.tf', 'other'), ('outputs.tf', 'outputs')]
|
||
for fname, key in mapping:
|
||
if cats[key]:
|
||
result[fname] = '\n\n'.join(cats[key])
|
||
return result if len(result) >= 3 else None
|
||
|
||
|
||
def _write_tf_files(wdir: Path, tf_code: str):
|
||
"""Parse tf_code for '// filename: xxx.tf' markers and write separate files.
|
||
If only 1-2 large files, auto-split into multiple files by resource type."""
|
||
import re as _re
|
||
parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', tf_code, flags=_re.MULTILINE)
|
||
|
||
files = {}
|
||
if len(parts) >= 3:
|
||
if parts[0].strip():
|
||
files['main.tf'] = parts[0].strip()
|
||
for i in range(1, len(parts), 2):
|
||
fname = parts[i].strip().replace("/", "_").replace("..", "_")
|
||
content = parts[i + 1].strip() if i + 1 < len(parts) else ""
|
||
if fname and content:
|
||
files[fname] = content
|
||
else:
|
||
files['main.tf'] = tf_code
|
||
|
||
# Auto-split: if 1-2 large files, split by resource type
|
||
if len(files) <= 2:
|
||
all_content = '\n\n'.join(files.values())
|
||
if len(all_content) > 2000:
|
||
split = _split_tf_monolith(all_content)
|
||
if split:
|
||
files = split
|
||
log.info(f"Backend auto-split monolithic TF into {len(files)} files: {list(files.keys())}")
|
||
|
||
# Deduplicate: if a resource appears in multiple files, keep only in the split file
|
||
# This handles edge cases where monolithic + split files both exist
|
||
if len(files) > 2:
|
||
resource_locations = {} # "type.name" -> [(filename, line)]
|
||
dupes_in = set()
|
||
for fname, content in files.items():
|
||
for m in _re.finditer(r'^resource\s+"(\S+)"\s+"(\S+)"', content, _re.MULTILINE):
|
||
key = f'{m.group(1)}.{m.group(2)}'
|
||
resource_locations.setdefault(key, []).append(fname)
|
||
# Find files that contain ONLY duplicate resources (= monolithic leftovers)
|
||
for key, fnames in resource_locations.items():
|
||
if len(fnames) > 1:
|
||
# The largest file is likely the monolithic one
|
||
largest = max(fnames, key=lambda f: len(files.get(f, '')))
|
||
dupes_in.add(largest)
|
||
# Remove monolithic files that are fully duplicated
|
||
for fname in dupes_in:
|
||
if all(
|
||
any(f2 != fname and f2 in [fl for fl in resource_locations.get(key, []) if fl != fname])
|
||
for key, flist in resource_locations.items()
|
||
if fname in flist
|
||
) and len(files) - 1 >= 2:
|
||
log.info(f"Removing duplicate monolithic file: {fname}")
|
||
del files[fname]
|
||
|
||
# Fix single-line blocks in all files
|
||
for fname in files:
|
||
files[fname] = _fix_single_line_blocks(files[fname])
|
||
|
||
# Write files to disk
|
||
for fname, content in files.items():
|
||
(wdir / fname).write_text(content)
|
||
|
||
|
||
async def _terraform_exec(wid: str, action: str, user: dict):
|
||
"""Background: run terraform init + plan/apply/destroy in workspace dir."""
|
||
log.info(f"Terraform {action} started: wid={wid}")
|
||
status_col = f"{action}_output"
|
||
final_ok = {"plan": "planned", "apply": "applied", "destroy": "destroyed"}[action]
|
||
|
||
try:
|
||
with db() as c:
|
||
ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=?", (wid,)).fetchone()
|
||
if not ws:
|
||
return
|
||
|
||
wdir = TERRAFORM_DIR / wid
|
||
wdir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# Clean old .tf files (keep .terraform/, state, lock)
|
||
for old_tf in wdir.glob("*.tf"):
|
||
old_tf.unlink()
|
||
|
||
# Write .tf files — parse // filename: comments to split into separate files
|
||
_write_tf_files(wdir, ws["tf_code"] or "")
|
||
|
||
# Auto-generate provider.tf from OCI config
|
||
# Detect provider aliases declared by the model in generated files
|
||
import re as _re2
|
||
oci_cfg = _get_oci_config(ws["oci_config_id"])
|
||
alias_blocks = [] # list of (alias_name, region_ref)
|
||
|
||
# Scan all .tf files for provider "oci" { alias = "..." ... region = ... }
|
||
provider_block_re = _re2.compile(r'provider\s+"oci"\s*\{', _re2.MULTILINE)
|
||
for tf_file in sorted(wdir.glob("*.tf")):
|
||
if tf_file.name == "provider.tf":
|
||
continue
|
||
content = tf_file.read_text()
|
||
# Find each provider "oci" block and extract alias + region
|
||
blocks_to_remove = []
|
||
for m in provider_block_re.finditer(content):
|
||
start = m.start()
|
||
# Find matching closing brace
|
||
depth = 0
|
||
end = start
|
||
for ci in range(m.end() - 1, len(content)):
|
||
if content[ci] == '{':
|
||
depth += 1
|
||
elif content[ci] == '}':
|
||
depth -= 1
|
||
if depth == 0:
|
||
end = ci + 1
|
||
break
|
||
block = content[start:end]
|
||
alias_m = _re2.search(r'alias\s*=\s*"([^"]+)"', block)
|
||
region_m = _re2.search(r'region\s*=\s*(.+)', block)
|
||
if alias_m:
|
||
alias_name = alias_m.group(1)
|
||
region_ref = region_m.group(1).strip().rstrip("}").strip() if region_m else '"unknown"'
|
||
alias_blocks.append((alias_name, region_ref))
|
||
blocks_to_remove.append((start, end))
|
||
# Remove model-generated provider blocks (we centralize in provider.tf)
|
||
if blocks_to_remove:
|
||
new_content = content
|
||
for s, e in reversed(blocks_to_remove):
|
||
new_content = new_content[:s] + new_content[e:]
|
||
# Also remove leading comments right before removed blocks
|
||
new_content = _re2.sub(r'\n(//[^\n]*\n){1,5}\s*\n', '\n\n', new_content)
|
||
new_content = new_content.strip()
|
||
if new_content:
|
||
tf_file.write_text(new_content + "\n")
|
||
else:
|
||
tf_file.unlink() # Remove empty file
|
||
|
||
# Scan all .tf files for variable declarations with region-like defaults
|
||
# so we can map alias providers to the correct variable reference
|
||
var_region_map = {} # variable_name -> default_value
|
||
var_re = _re2.compile(r'variable\s+"([^"]*region[^"]*)"\s*\{', _re2.IGNORECASE)
|
||
for tf_file in sorted(wdir.glob("*.tf")):
|
||
if tf_file.name in ("provider.tf", "terraform.tfvars"):
|
||
continue
|
||
content = tf_file.read_text()
|
||
for vm in var_re.finditer(content):
|
||
var_region_map[vm.group(1)] = f'var.{vm.group(1)}'
|
||
|
||
# Check if variable "region" is declared — use it for primary provider
|
||
has_region_var = "region" in var_region_map
|
||
|
||
# Also scan for provider refs in resource blocks (provider = oci.xxx)
|
||
for tf_file in sorted(wdir.glob("*.tf")):
|
||
if tf_file.name == "provider.tf":
|
||
continue
|
||
content = tf_file.read_text()
|
||
for ref_m in _re2.finditer(r'provider\s*=\s*oci\.(\w+)', content):
|
||
ref_alias = ref_m.group(1)
|
||
if ref_alias not in [a[0] for a in alias_blocks]:
|
||
# Try to find a matching region variable for this alias
|
||
# e.g. alias "mad_3" might match var.region_secondary
|
||
region_ref = 'var.region' # fallback to primary region var
|
||
for vname, vref in var_region_map.items():
|
||
if vname != "region": # prefer non-primary region vars for aliases
|
||
region_ref = vref
|
||
break
|
||
alias_blocks.append((ref_alias, region_ref))
|
||
|
||
passphrase = oci_cfg.get('pass_phrase', '')
|
||
cred_block = f''' tenancy_ocid = "{oci_cfg.get('tenancy', '')}"
|
||
user_ocid = "{oci_cfg.get('user', '')}"
|
||
fingerprint = "{oci_cfg.get('fingerprint', '')}"
|
||
private_key_path = "{oci_cfg.get('key_file', '')}"'''
|
||
if passphrase:
|
||
cred_block += f'\n private_key_password = "{passphrase}"'
|
||
|
||
# Primary region: use var.region if declared in .tf files, otherwise hardcode from OCI config
|
||
with db() as c:
|
||
oci_row = c.execute("SELECT region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
|
||
primary_region = oci_row["region"] if oci_row else oci_cfg.get("region", "")
|
||
region_value = 'var.region' if has_region_var else f'"{primary_region}"'
|
||
|
||
provider_tf = f'''terraform {{
|
||
required_providers {{
|
||
oci = {{
|
||
source = "oracle/oci"
|
||
}}
|
||
}}
|
||
}}
|
||
|
||
provider "oci" {{
|
||
{cred_block}
|
||
region = {region_value}
|
||
}}
|
||
'''
|
||
# Add alias providers with same credentials
|
||
seen_aliases = set()
|
||
for alias_name, region_ref in alias_blocks:
|
||
if alias_name in seen_aliases:
|
||
continue
|
||
seen_aliases.add(alias_name)
|
||
provider_tf += f'''
|
||
provider "oci" {{
|
||
alias = "{alias_name}"
|
||
{cred_block}
|
||
region = {region_ref}
|
||
}}
|
||
'''
|
||
(wdir / "provider.tf").write_text(provider_tf)
|
||
|
||
# Auto-generate terraform.tfvars — scan declared variables and provide values from OCI config
|
||
with db() as c:
|
||
oci_row = c.execute("SELECT compartment_id, region FROM oci_configs WHERE id=?", (ws["oci_config_id"],)).fetchone()
|
||
comp_id = ws["compartment_id"] if ws["compartment_id"] else (_safe_dec(oci_row["compartment_id"]) if oci_row and oci_row["compartment_id"] else "")
|
||
|
||
# Scan all declared variables in .tf files
|
||
declared_vars = set()
|
||
for tf_file in sorted(wdir.glob("*.tf")):
|
||
if tf_file.name in ("provider.tf", "terraform.tfvars"):
|
||
continue
|
||
content = tf_file.read_text()
|
||
for vm in _re2.finditer(r'variable\s+"([^"]+)"', content):
|
||
declared_vars.add(vm.group(1))
|
||
|
||
# Map known variable names to OCI config values
|
||
var_values = {"compartment_id": comp_id}
|
||
oci_var_map = {
|
||
"tenancy_ocid": oci_cfg.get("tenancy", ""),
|
||
"tenancy_id": oci_cfg.get("tenancy", ""),
|
||
"user_ocid": oci_cfg.get("user", ""),
|
||
"fingerprint": oci_cfg.get("fingerprint", ""),
|
||
"private_key_path": oci_cfg.get("key_file", ""),
|
||
}
|
||
# NOTE: "region" is intentionally NOT included — Terraform uses the default from variable declarations
|
||
for vname, vval in oci_var_map.items():
|
||
if vname in declared_vars and vval:
|
||
var_values[vname] = vval
|
||
|
||
tfvars_lines = [f'{k} = "{v}"' for k, v in var_values.items()]
|
||
(wdir / "terraform.tfvars").write_text("\n".join(tfvars_lines) + "\n")
|
||
|
||
output_lines = []
|
||
|
||
def _update_output(text):
|
||
output_lines.append(text)
|
||
with db() as c:
|
||
c.execute(f"UPDATE terraform_workspaces SET {status_col}=?, updated_at=datetime('now') WHERE id=?",
|
||
("\n".join(output_lines[-200:]), wid))
|
||
|
||
# terraform init
|
||
_update_output("$ terraform init ...")
|
||
proc_init = await asyncio.create_subprocess_exec(
|
||
"terraform", f"-chdir={wdir}", "init", "-no-color", "-input=false",
|
||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
||
while True:
|
||
line = await proc_init.stdout.readline()
|
||
if not line:
|
||
break
|
||
_update_output(line.decode(errors="replace").rstrip())
|
||
await proc_init.wait()
|
||
if proc_init.returncode != 0:
|
||
_update_output(f"\n❌ terraform init failed (exit {proc_init.returncode})")
|
||
with db() as c:
|
||
c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?",
|
||
("terraform init failed", wid))
|
||
return
|
||
|
||
# terraform action
|
||
_update_output(f"\n$ terraform {action} ...")
|
||
cmd = ["terraform", f"-chdir={wdir}", action, "-no-color", "-input=false"]
|
||
if action in ("apply", "destroy"):
|
||
cmd.append("-auto-approve")
|
||
|
||
proc = await asyncio.create_subprocess_exec(
|
||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT)
|
||
_running_terraform[wid] = proc
|
||
|
||
while True:
|
||
line = await proc.stdout.readline()
|
||
if not line:
|
||
break
|
||
_update_output(line.decode(errors="replace").rstrip())
|
||
await proc.wait()
|
||
_running_terraform.pop(wid, None)
|
||
|
||
if proc.returncode == 0:
|
||
output_lines.append(f"\n✅ terraform {action} completed successfully")
|
||
full_output = "\n".join(output_lines)
|
||
with db() as c:
|
||
c.execute(f"UPDATE terraform_workspaces SET {status_col}=?, status=?, updated_at=datetime('now') WHERE id=?",
|
||
(full_output, final_ok, wid))
|
||
_audit(user["id"], user["username"], f"terraform_{action}", wid, f"status={final_ok}")
|
||
else:
|
||
output_lines.append(f"\n❌ terraform {action} failed (exit {proc.returncode})")
|
||
full_output = "\n".join(output_lines)
|
||
with db() as c:
|
||
c.execute(f"UPDATE terraform_workspaces SET {status_col}=?, status='failed', error=?, updated_at=datetime('now') WHERE id=?",
|
||
(full_output, f"terraform {action} failed (exit {proc.returncode})", wid))
|
||
|
||
except Exception as e:
|
||
log.error(f"Terraform exec error: {e}")
|
||
with db() as c:
|
||
c.execute("UPDATE terraform_workspaces SET status='failed', error=?, updated_at=datetime('now') WHERE id=?",
|
||
(str(e)[:500], wid))
|
||
|
||
|
||
# ── Audit ─────────────────────────────────────────────────────────────────────
|
||
@app.get("/api/audit-log")
|
||
async def audit_log(limit:int=Query(100,le=500), u=Depends(require("admin"))):
|
||
with db() as c: rows=c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ?",(limit,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# ── Config Logs ───────────────────────────────────────────────────────────────
|
||
@app.get("/api/config-logs")
|
||
async def get_config_logs(
|
||
config_type: str = Query(None), config_id: str = Query(None),
|
||
severity: str = Query(None), limit: int = Query(50, le=200),
|
||
u=Depends(current_user)
|
||
):
|
||
query = "SELECT * FROM config_logs WHERE 1=1"
|
||
params = []
|
||
if config_type: query += " AND config_type=?"; params.append(config_type)
|
||
if config_id: query += " AND config_id=?"; params.append(config_id)
|
||
if severity: query += " AND severity=?"; params.append(severity)
|
||
if u["role"] != "admin": query += " AND user_id=?"; params.append(u["id"])
|
||
query += " ORDER BY created_at DESC LIMIT ?"
|
||
params.append(limit)
|
||
with db() as c: rows = c.execute(query, params).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# ── Chat Logs ─────────────────────────────────────────────────────────────────
|
||
@app.get("/api/chat-logs")
|
||
async def get_chat_logs(
|
||
severity: str = Query(None), session_id: str = Query(None),
|
||
limit: int = Query(50, le=200), u=Depends(current_user)
|
||
):
|
||
query = "SELECT * FROM chat_logs WHERE 1=1"
|
||
params = []
|
||
if severity: query += " AND severity=?"; params.append(severity)
|
||
if session_id: query += " AND session_id=?"; params.append(session_id)
|
||
if u["role"] != "admin": query += " AND user_id=?"; params.append(u["id"])
|
||
query += " ORDER BY created_at DESC LIMIT ?"
|
||
params.append(limit)
|
||
with db() as c: rows = c.execute(query, params).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
# ── App Settings ──────────────────────────────────────────────────────────────
|
||
@app.get("/api/settings/{key}")
|
||
async def get_setting(key: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||
return {"key": key, "value": row["value"] if row else ""}
|
||
|
||
@app.put("/api/settings/{key}")
|
||
async def put_setting(key: str, body: dict, u=Depends(require("admin"))):
|
||
value = body.get("value", "")
|
||
with db() as c:
|
||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", (key, value))
|
||
return {"key": key, "value": value}
|
||
|
||
# ── System Prompts ────────────────────────────────────────────────────────────
|
||
@app.get("/api/prompts/{agent}")
|
||
async def list_prompts(agent: str, u=Depends(current_user)):
|
||
with db() as c:
|
||
rows = c.execute("SELECT * FROM system_prompts WHERE agent=? ORDER BY is_active DESC, created_at DESC", (agent,)).fetchall()
|
||
return [dict(r) for r in rows]
|
||
|
||
@app.post("/api/prompts")
|
||
async def save_prompt(body: dict, u=Depends(require("admin"))):
|
||
agent = body.get("agent", "chat")
|
||
with db() as c:
|
||
count = c.execute("SELECT COUNT(*) FROM system_prompts WHERE agent=?", (agent,)).fetchone()[0]
|
||
if count >= 10:
|
||
raise HTTPException(400, "Limite de 10 prompts atingido. Exclua um antes de criar outro.")
|
||
pid = str(uuid.uuid4())
|
||
name = body.get("name", "Sem nome")
|
||
content = body.get("content", "")
|
||
is_active = body.get("is_active", False)
|
||
with db() as c:
|
||
if is_active:
|
||
c.execute("UPDATE system_prompts SET is_active=0 WHERE agent=?", (agent,))
|
||
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active) VALUES (?,?,?,?,?)",
|
||
(pid, name, agent, content, int(is_active)))
|
||
return {"id": pid}
|
||
|
||
@app.put("/api/prompts/{pid}")
|
||
async def update_prompt(pid: str, body: dict, u=Depends(require("admin"))):
|
||
with db() as c:
|
||
existing = c.execute("SELECT * FROM system_prompts WHERE id=?", (pid,)).fetchone()
|
||
if not existing:
|
||
raise HTTPException(404)
|
||
name = body.get("name", existing["name"])
|
||
content = body.get("content", existing["content"])
|
||
is_active = body.get("is_active", existing["is_active"])
|
||
with db() as c:
|
||
if is_active:
|
||
c.execute("UPDATE system_prompts SET is_active=0 WHERE agent=?", (existing["agent"],))
|
||
c.execute("UPDATE system_prompts SET name=?,content=?,is_active=? WHERE id=?",
|
||
(name, content, int(is_active), pid))
|
||
return {"id": pid}
|
||
|
||
@app.delete("/api/prompts/{pid}")
|
||
async def delete_prompt(pid: str, u=Depends(require("admin"))):
|
||
with db() as c:
|
||
c.execute("DELETE FROM system_prompts WHERE id=?", (pid,))
|
||
return {"ok": True}
|
||
|
||
# ── Export / Import Configuration ─────────────────────────────────────────────
|
||
@app.get("/api/config/export")
|
||
async def export_config(u=Depends(require("admin"))):
|
||
"""Export all configurations as JSON (OCI configs with keys, GenAI, MCP, prompts, settings)."""
|
||
data = {"version": VERSION, "exported_at": datetime.now().isoformat()}
|
||
with db() as c:
|
||
# OCI configs
|
||
oci_rows = c.execute("SELECT * FROM oci_configs").fetchall()
|
||
oci_cfgs = []
|
||
for r in oci_rows:
|
||
cfg = dict(r)
|
||
# Include private key content (base64)
|
||
kp = Path(cfg.get("key_file_path", ""))
|
||
if kp.exists():
|
||
cfg["_private_key_b64"] = base64.b64encode(kp.read_bytes()).decode()
|
||
# Include passphrase from config file if exists
|
||
cfg_file = kp.parent / "config" if kp.parent.exists() else None
|
||
if cfg_file and cfg_file.exists():
|
||
for line in cfg_file.read_text().splitlines():
|
||
if line.startswith("pass_phrase="):
|
||
cfg["_key_passphrase"] = line.split("=", 1)[1]
|
||
oci_cfgs.append(cfg)
|
||
data["oci_configs"] = oci_cfgs
|
||
# GenAI configs
|
||
data["genai_configs"] = [dict(r) for r in c.execute("SELECT * FROM genai_configs").fetchall()]
|
||
# MCP servers (exclude system-auto-registered if tools are empty)
|
||
data["mcp_servers"] = [dict(r) for r in c.execute("SELECT * FROM mcp_servers").fetchall()]
|
||
# System prompts
|
||
data["system_prompts"] = [dict(r) for r in c.execute("SELECT * FROM system_prompts").fetchall()]
|
||
# App settings
|
||
data["app_settings"] = [dict(r) for r in c.execute("SELECT * FROM app_settings").fetchall()]
|
||
# ADB vector configs
|
||
adb_rows = c.execute("SELECT * FROM adb_vector_configs").fetchall()
|
||
data["adb_vector_configs"] = [dict(r) for r in adb_rows]
|
||
# ADB vector tables
|
||
data["adb_vector_tables"] = [dict(r) for r in c.execute("SELECT * FROM adb_vector_tables").fetchall()]
|
||
return StreamingResponse(
|
||
iter([json.dumps(data, indent=2, ensure_ascii=False)]),
|
||
media_type="application/json",
|
||
headers={"Content-Disposition": f'attachment; filename="oci-cis-agent-config-{datetime.now().strftime("%Y%m%d-%H%M%S")}.json"'}
|
||
)
|
||
|
||
|
||
@app.post("/api/config/import")
|
||
async def import_config(file: UploadFile = File(...), u=Depends(require("admin"))):
|
||
"""Import configurations from exported JSON. Merges by ID (skip existing)."""
|
||
content = await file.read()
|
||
try:
|
||
data = json.loads(content)
|
||
except json.JSONDecodeError:
|
||
raise HTTPException(400, "Arquivo JSON inválido")
|
||
counts = {"oci_configs": 0, "genai_configs": 0, "mcp_servers": 0, "system_prompts": 0, "app_settings": 0, "adb_vector_configs": 0, "adb_vector_tables": 0, "skipped": 0}
|
||
with db() as c:
|
||
# OCI configs
|
||
for cfg in data.get("oci_configs", []):
|
||
if c.execute("SELECT 1 FROM oci_configs WHERE id=?", (cfg["id"],)).fetchone():
|
||
counts["skipped"] += 1; continue
|
||
# Restore private key file
|
||
cdir = OCI_DIR / cfg["id"]; cdir.mkdir(parents=True, exist_ok=True)
|
||
kp = cdir / "oci_api_key.pem"
|
||
if cfg.get("_private_key_b64"):
|
||
kp.write_bytes(base64.b64decode(cfg["_private_key_b64"])); kp.chmod(0o600)
|
||
# Write config file
|
||
cfg_file = cdir / "config"
|
||
cfg_content = (f"[DEFAULT]\nuser={_dec(cfg['user_ocid']) if cfg.get('user_ocid') else ''}\n"
|
||
f"fingerprint={_dec(cfg['fingerprint']) if cfg.get('fingerprint') else ''}\n"
|
||
f"tenancy={_dec(cfg['tenancy_ocid']) if cfg.get('tenancy_ocid') else ''}\n"
|
||
f"region={cfg.get('region','')}\nkey_file={kp}\n")
|
||
if cfg.get("_key_passphrase"):
|
||
cfg_content += f"pass_phrase={cfg['_key_passphrase']}\n"
|
||
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||
(cfg["id"], cfg.get("user_id", u["id"]), cfg.get("tenancy_name",""), cfg.get("tenancy_ocid",""), cfg.get("user_ocid",""),
|
||
cfg.get("fingerprint",""), cfg.get("region",""), str(kp), cfg.get("compartment_id"), cfg.get("created_at")))
|
||
counts["oci_configs"] += 1
|
||
# GenAI configs
|
||
for cfg in data.get("genai_configs", []):
|
||
if c.execute("SELECT 1 FROM genai_configs WHERE id=?", (cfg["id"],)).fetchone():
|
||
counts["skipped"] += 1; continue
|
||
c.execute("INSERT INTO genai_configs (id,user_id,name,oci_config_id,model_id,model_ocid,compartment_id,genai_region,endpoint,serving_type,dedicated_endpoint_id,temperature,max_tokens,top_p,top_k,frequency_penalty,presence_penalty,is_default,system_prompt,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(cfg["id"], cfg.get("user_id", u["id"]), cfg.get("name",""), cfg.get("oci_config_id",""), cfg.get("model_id",""),
|
||
cfg.get("model_ocid"), cfg.get("compartment_id",""), cfg.get("genai_region",""), cfg.get("endpoint",""),
|
||
cfg.get("serving_type","ON_DEMAND"), cfg.get("dedicated_endpoint_id"), cfg.get("temperature",1),
|
||
cfg.get("max_tokens",6000), cfg.get("top_p",0.95), cfg.get("top_k",1), cfg.get("frequency_penalty",0),
|
||
cfg.get("presence_penalty",0), cfg.get("is_default",0), cfg.get("system_prompt",""), cfg.get("created_at")))
|
||
counts["genai_configs"] += 1
|
||
# MCP servers
|
||
for srv in data.get("mcp_servers", []):
|
||
if c.execute("SELECT 1 FROM mcp_servers WHERE id=?", (srv["id"],)).fetchone():
|
||
counts["skipped"] += 1; continue
|
||
c.execute("INSERT INTO mcp_servers (id,user_id,name,description,server_type,command,args,env_vars,url,module_path,tools,linked_adb_id,is_active,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(srv["id"], srv.get("user_id", u["id"]), srv.get("name",""), srv.get("description"), srv.get("server_type","stdio"),
|
||
srv.get("command"), srv.get("args"), srv.get("env_vars"), srv.get("url"), srv.get("module_path"),
|
||
srv.get("tools"), srv.get("linked_adb_id"), srv.get("is_active",1), srv.get("created_at")))
|
||
counts["mcp_servers"] += 1
|
||
# System prompts
|
||
for p in data.get("system_prompts", []):
|
||
if c.execute("SELECT 1 FROM system_prompts WHERE id=?", (p["id"],)).fetchone():
|
||
counts["skipped"] += 1; continue
|
||
c.execute("INSERT INTO system_prompts (id,name,agent,content,is_active,created_at) VALUES (?,?,?,?,?,?)",
|
||
(p["id"], p.get("name",""), p.get("agent","chat"), p.get("content",""), p.get("is_active",0), p.get("created_at")))
|
||
counts["system_prompts"] += 1
|
||
# App settings
|
||
for s in data.get("app_settings", []):
|
||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES (?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||
(s["key"], s.get("value",""), s.get("updated_at")))
|
||
counts["app_settings"] += 1
|
||
# ADB vector configs
|
||
for cfg in data.get("adb_vector_configs", []):
|
||
if c.execute("SELECT 1 FROM adb_vector_configs WHERE id=?", (cfg["id"],)).fetchone():
|
||
counts["skipped"] += 1; continue
|
||
c.execute("INSERT INTO adb_vector_configs (id,user_id,config_name,dsn,username,password_enc,wallet_dir,wallet_password_enc,table_name,use_mtls,is_active,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||
(cfg["id"], cfg.get("user_id", u["id"]), cfg.get("config_name",""), cfg.get("dsn",""), cfg.get("username",""),
|
||
cfg.get("password_enc",""), cfg.get("wallet_dir"), cfg.get("wallet_password_enc"), cfg.get("table_name","CIS_EMBEDDINGS"),
|
||
cfg.get("use_mtls",1), cfg.get("is_active",1), cfg.get("created_at")))
|
||
counts["adb_vector_configs"] += 1
|
||
# ADB vector tables
|
||
for t in data.get("adb_vector_tables", []):
|
||
if c.execute("SELECT 1 FROM adb_vector_tables WHERE id=?", (t["id"],)).fetchone():
|
||
counts["skipped"] += 1; continue
|
||
c.execute("INSERT INTO adb_vector_tables (id,adb_config_id,table_name,description,is_active,created_at) VALUES (?,?,?,?,?,?)",
|
||
(t["id"], t.get("adb_config_id",""), t.get("table_name",""), t.get("description",""), t.get("is_active",1), t.get("created_at")))
|
||
counts["adb_vector_tables"] += 1
|
||
_audit(u["id"], u["username"], "import_config", "bulk", json.dumps(counts))
|
||
return counts
|
||
|
||
|
||
# ── CIS Engine Version Management ─────────────────────────────────────────────
|
||
CIS_REPORTS_PATH = Path("/app/cis_reports.py")
|
||
CIS_GITHUB_RAW = "https://raw.githubusercontent.com/oci-landing-zones/oci-cis-landingzone-quickstart/main/scripts/cis_reports.py"
|
||
CIS_PATCHES = [
|
||
{"name": "region_none_check_1",
|
||
"original": "elif region.region_name in self.__regions_to_run_in or self.__run_in_all_regions:",
|
||
"patched": "elif self.__run_in_all_regions or (self.__regions_to_run_in and region.region_name in self.__regions_to_run_in):"},
|
||
{"name": "region_none_check_2",
|
||
"original": "if self.__home_region not in self.__regions_to_run_in:",
|
||
"patched": "if not self.__run_in_all_regions and self.__regions_to_run_in and self.__home_region not in self.__regions_to_run_in:"},
|
||
]
|
||
|
||
def _read_cis_version(content: str = None) -> dict:
|
||
if content is None:
|
||
if not CIS_REPORTS_PATH.exists(): return {"version": "unknown", "date": "unknown"}
|
||
content = CIS_REPORTS_PATH.read_text(encoding="utf-8", errors="replace")
|
||
ver = re.search(r'RELEASE_VERSION\s*=\s*["\']([^"\']+)["\']', content)
|
||
dt = re.search(r'UPDATED_DATE\s*=\s*["\']([^"\']+)["\']', content)
|
||
return {"version": ver.group(1) if ver else "unknown", "date": dt.group(1) if dt else "unknown"}
|
||
|
||
@app.get("/api/cis-engine/version")
|
||
async def cis_engine_version(u=Depends(current_user)):
|
||
info = _read_cis_version()
|
||
return {"version": info["version"], "updated_date": info["date"], "file_path": str(CIS_REPORTS_PATH)}
|
||
|
||
@app.get("/api/cis-engine/check-update")
|
||
async def cis_engine_check_update(u=Depends(require("admin"))):
|
||
import requests as req
|
||
local = _read_cis_version()
|
||
try:
|
||
resp = req.get(CIS_GITHUB_RAW, timeout=30)
|
||
resp.raise_for_status()
|
||
remote = _read_cis_version(resp.text)
|
||
except Exception as e:
|
||
raise HTTPException(502, f"Failed to check GitHub: {str(e)[:300]}")
|
||
return {
|
||
"local_version": local["version"], "local_date": local["date"],
|
||
"remote_version": remote["version"], "remote_date": remote["date"],
|
||
"update_available": remote["version"] != local["version"]
|
||
}
|
||
|
||
@app.post("/api/cis-engine/update")
|
||
async def cis_engine_update(u=Depends(require("admin"))):
|
||
import requests as req
|
||
old = _read_cis_version()
|
||
try:
|
||
resp = req.get(CIS_GITHUB_RAW, timeout=60)
|
||
resp.raise_for_status()
|
||
content = resp.text
|
||
except Exception as e:
|
||
raise HTTPException(502, f"Failed to download from GitHub: {str(e)[:300]}")
|
||
new = _read_cis_version(content)
|
||
# Apply patches
|
||
patches_applied = []
|
||
for p in CIS_PATCHES:
|
||
if p["original"] in content:
|
||
content = content.replace(p["original"], p["patched"])
|
||
patches_applied.append(p["name"])
|
||
CIS_REPORTS_PATH.write_text(content, encoding="utf-8")
|
||
# Save version info
|
||
with db() as c:
|
||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES ('cis_engine_version',?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||
(new["version"],))
|
||
log.info(f"CIS engine updated: {old['version']} → {new['version']}, patches: {patches_applied}")
|
||
_audit(u["id"], u["username"], "cis_engine_update", None, f"{old['version']} → {new['version']}")
|
||
return {"ok": True, "old_version": old["version"], "new_version": new["version"], "patches_applied": patches_applied}
|
||
|
||
@app.get("/api/health")
|
||
async def health():
|
||
return {"status":"ok","ts":datetime.utcnow().isoformat(),"version":VERSION}
|
||
|
||
@app.on_event("startup")
|
||
async def startup():
|
||
init_db()
|
||
# Mark orphaned "running" reports as failed (e.g. after container restart)
|
||
with db() as c:
|
||
orphaned = c.execute("UPDATE reports SET status='failed', error_msg='Interrompido: container reiniciado' WHERE status='running'").rowcount
|
||
if orphaned:
|
||
log.warning(f"Marked {orphaned} orphaned running report(s) as failed")
|
||
# Auto-register CIS Compliance Scanner MCP server if not present (deduplicate on startup)
|
||
with db() as c:
|
||
dupes = c.execute("SELECT id FROM mcp_servers WHERE name='CIS Compliance Scanner' ORDER BY created_at ASC").fetchall()
|
||
if len(dupes) > 1:
|
||
keep = dupes[0]["id"]
|
||
for d in dupes[1:]:
|
||
c.execute("DELETE FROM mcp_servers WHERE id=?", (d["id"],))
|
||
log.info(f"Removed {len(dupes)-1} duplicate CIS Compliance Scanner entries, kept {keep}")
|
||
if not dupes:
|
||
mid = str(uuid.uuid4())
|
||
c.execute(
|
||
"INSERT INTO mcp_servers (id, user_id, name, description, server_type, command, args) VALUES (?,?,?,?,?,?,?)",
|
||
(mid, "system", "CIS Compliance Scanner",
|
||
"OCI CIS Foundations Benchmark 3.0 - 48 CIS checks + 11 OBP checks via MCP",
|
||
"stdio", "python3", json.dumps(["/app/mcp_cis_server.py"]))
|
||
)
|
||
log.info(f"Auto-registered CIS Compliance Scanner MCP server (id={mid})")
|
||
|
||
# Save CIS engine version to app_settings
|
||
try:
|
||
vi = _read_cis_version()
|
||
with db() as c:
|
||
c.execute("INSERT INTO app_settings (key,value,updated_at) VALUES ('cis_engine_version',?,datetime('now')) ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
|
||
(vi["version"],))
|
||
log.info(f"CIS engine version: {vi['version']} ({vi['date']})")
|
||
except Exception as e:
|
||
log.warning(f"Could not read CIS engine version: {e}")
|
||
|
||
# Generate TF resource reference if not present (volume mount may hide build-time file)
|
||
ref_path = Path(_TF_RESOURCE_REF_PATH)
|
||
if not ref_path.exists():
|
||
try:
|
||
result = subprocess.run(["python", "/app/gen_tf_reference.py"], capture_output=True, text=True, timeout=300)
|
||
if result.returncode == 0 and ref_path.exists():
|
||
log.info(f"Generated TF resource reference at startup: {ref_path.stat().st_size} bytes")
|
||
else:
|
||
log.warning(f"TF reference generation failed at startup: {result.stderr[:200] if result.stderr else 'unknown'}")
|
||
except Exception as e:
|
||
log.warning(f"Could not generate TF reference at startup: {e}")
|
||
# Populate tf_valid_types table from reference file
|
||
if ref_path.exists():
|
||
try:
|
||
count = _populate_tf_valid_types()
|
||
if count:
|
||
log.info(f"TF valid types DB populated: {count} entries")
|
||
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")
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
uvicorn.run(app, host="0.0.0.0", port=8000)
|