refactor: decompose monolith — app.py 10,600→143 lines, 30+ modules
Fase 0 complete: extract all business logic, auth, database, and 176 endpoints from monolithic app.py into dedicated modules. Structure: - app.py: FastAPI hub (CORS, routers, startup/shutdown) - models.py: 13 Pydantic request models - utils.py: shared utilities (embed status, upload validation, process registries) - config.py: constants, env vars, model catalogs - auth/: crypto, jwt_auth, oidc, rate_limit - database/: db(), init_db(), schema DDL - services/: genai, compliance, chat, terraform, embeddings, cis_reports, mcp - routes/: 13 APIRouter modules (auth, users, oci_config, oci_explorer, genai, mcp, adb, embeddings, reports, chat, terraform, settings, cis_engine) Also: README updated with OCIR auth instructions for manual docker run. 86 tests passing.
This commit is contained in:
39
README.md
39
README.md
@@ -309,7 +309,44 @@ bash distribution/setup.sh
|
|||||||
|
|
||||||
One command: authenticates to OCIR, pulls the image, starts the container. Access `http://localhost:8080` — default credentials `admin` / `admin123` (forced password change on first login).
|
One command: authenticates to OCIR, pulls the image, starts the container. Access `http://localhost:8080` — default credentials `admin` / `admin123` (forced password change on first login).
|
||||||
|
|
||||||
### Option B — Development (Docker Compose)
|
### Option B — Manual Docker Run (from OCIR image)
|
||||||
|
|
||||||
|
> **Important**: The container image is hosted on a **private OCIR registry**. You must authenticate before pulling.
|
||||||
|
|
||||||
|
**Step 1 — Authenticate to OCIR** (one-time):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python infra/scripts/ocir-login.py --passphrase ateam-security-2026
|
||||||
|
```
|
||||||
|
|
||||||
|
This passphrase is **read-only** — it only grants permission to pull the container image. It cannot be used to push images, access OCI resources, or modify any configuration.
|
||||||
|
|
||||||
|
To authenticate and pull the image in one step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python infra/scripts/ocir-login.py --passphrase ateam-security-2026 --pull
|
||||||
|
```
|
||||||
|
|
||||||
|
> The script uses Fernet AES-128 encryption (PBKDF2 600K iterations) to securely store the registry auth token. The passphrase only decrypts the OCIR pull credential.
|
||||||
|
|
||||||
|
**Step 2 — Run the container**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name oci-cis-agent \
|
||||||
|
-p 8080:8080 \
|
||||||
|
-v agent-data:/data \
|
||||||
|
-e APP_SECRET=$(openssl rand -hex 64) \
|
||||||
|
-e TZ=America/Sao_Paulo \
|
||||||
|
--restart unless-stopped \
|
||||||
|
us-ashburn-1.ocir.io/idi1o0a010nx/oci-cis-agent:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Access `http://localhost:8080` — default credentials `admin` / `admin123` (forced password change on first login).
|
||||||
|
|
||||||
|
> **Note**: If you get `403 Forbidden`, the OCIR login expired. Run `ocir-login.py` again.
|
||||||
|
|
||||||
|
### Option C — Development (Docker Compose)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
|
|||||||
10552
backend/app.py
10552
backend/app.py
File diff suppressed because it is too large
Load Diff
0
backend/auth/__init__.py
Normal file
0
backend/auth/__init__.py
Normal file
91
backend/auth/crypto.py
Normal file
91
backend/auth/crypto.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""
|
||||||
|
Cryptographic helpers — password hashing, Fernet encryption, TOTP, JWT tokens.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
from cryptography.fernet import Fernet as _Fernet
|
||||||
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC as _PBKDF2HMAC
|
||||||
|
from cryptography.hazmat.primitives import hashes as _hashes
|
||||||
|
|
||||||
|
from config import APP_SECRET, JWT_ALG, JWT_EXP_H
|
||||||
|
|
||||||
|
# ── Password Hashing (PBKDF2-SHA256, 100k iterations) ───────────────────────
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── TOTP (RFC 6238, Google Authenticator compatible) ─────────────────────────
|
||||||
|
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/AI-Agent:{user}?secret={secret}&issuer=AI-Agent"
|
||||||
|
|
||||||
|
# ── JWT Token ────────────────────────────────────────────────────────────────
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Fernet Encryption (AES-128-CBC + HMAC-SHA256) ───────────────────────────
|
||||||
|
_FERNET_PREFIX = "fernet:"
|
||||||
|
|
||||||
|
def _derive_fernet_key(secret: str) -> bytes:
|
||||||
|
kdf = _PBKDF2HMAC(
|
||||||
|
algorithm=_hashes.SHA256(), length=32,
|
||||||
|
salt=b"oci-cis-agent-fernet-v1", iterations=100_000
|
||||||
|
)
|
||||||
|
return base64.urlsafe_b64encode(kdf.derive(secret.encode()))
|
||||||
|
|
||||||
|
_fernet = _Fernet(_derive_fernet_key(APP_SECRET))
|
||||||
|
|
||||||
|
def _enc(v):
|
||||||
|
if not v: return v
|
||||||
|
return _FERNET_PREFIX + _fernet.encrypt(v.encode()).decode()
|
||||||
|
|
||||||
|
def _dec(v):
|
||||||
|
if not v: return v
|
||||||
|
if v.startswith(_FERNET_PREFIX):
|
||||||
|
return _fernet.decrypt(v[len(_FERNET_PREFIX):].encode()).decode()
|
||||||
|
return base64.b64decode(v.encode()).decode()
|
||||||
|
|
||||||
|
def _mask(v, show=6):
|
||||||
|
if not v or len(v) <= show:
|
||||||
|
return "\u2022" * 8
|
||||||
|
return "\u2022" * min(len(v) - show, 20) + v[-show:]
|
||||||
|
|
||||||
|
def _safe_dec(v):
|
||||||
|
if not v: return v
|
||||||
|
try:
|
||||||
|
return _dec(v)
|
||||||
|
except Exception:
|
||||||
|
return v
|
||||||
105
backend/auth/jwt_auth.py
Normal file
105
backend/auth/jwt_auth.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""JWT authentication — current_user dependency, require roles, audit/log helpers."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
from fastapi import HTTPException, Depends
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from config import APP_SECRET, JWT_ALG
|
||||||
|
from database import db
|
||||||
|
|
||||||
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
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]))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Access Control ───────────────────────────────────────────────────────────
|
||||||
|
_CONFIG_TABLES = {
|
||||||
|
"oci": "oci_configs",
|
||||||
|
"genai": "genai_configs",
|
||||||
|
"adb": "adb_vector_configs",
|
||||||
|
"mcp": "mcp_servers",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_config_access(config_type: str, config_id: str, user: dict, *, require_owner: bool = False) -> dict:
|
||||||
|
table = _CONFIG_TABLES.get(config_type)
|
||||||
|
if not table:
|
||||||
|
raise HTTPException(400, f"Tipo de config inválido: {config_type}")
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute(f"SELECT * FROM {table} WHERE id=?", (config_id,)).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404)
|
||||||
|
row = dict(row)
|
||||||
|
if user["role"] == "admin":
|
||||||
|
return row
|
||||||
|
is_owner = row.get("user_id") == user["id"]
|
||||||
|
is_global = row.get("is_global", 0)
|
||||||
|
if not is_owner and not is_global:
|
||||||
|
raise HTTPException(404)
|
||||||
|
if require_owner and not is_owner:
|
||||||
|
raise HTTPException(403, "Apenas o dono pode modificar este recurso")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_report_access(report_id: str, user: dict) -> dict:
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT * FROM reports WHERE id=? AND user_id=?", (report_id, user["id"])).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404)
|
||||||
|
return dict(row)
|
||||||
93
backend/auth/oidc.py
Normal file
93
backend/auth/oidc.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""OIDC helpers — discovery, JWKS, state management, token validation."""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth.crypto import _safe_dec
|
||||||
|
|
||||||
|
# ── Caches ───────────────────────────────────────────────────────────────────
|
||||||
|
_oidc_discovery_cache: dict = {}
|
||||||
|
_oidc_jwks_cache: dict = {}
|
||||||
|
_OIDC_CACHE_TTL = 3600
|
||||||
|
|
||||||
|
_oidc_pending: dict = {}
|
||||||
|
_oidc_pending_lock = threading.Lock()
|
||||||
|
_OIDC_STATE_TTL = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _get_oidc_config() -> dict:
|
||||||
|
keys = ("auth_mode", "oidc_issuer", "oidc_client_id", "oidc_client_secret",
|
||||||
|
"oidc_redirect_uri", "oidc_group_admin", "oidc_group_user", "oidc_group_viewer")
|
||||||
|
cfg = {}
|
||||||
|
with db() as c:
|
||||||
|
for k in keys:
|
||||||
|
row = c.execute("SELECT value FROM app_settings WHERE key=?", (k,)).fetchone()
|
||||||
|
cfg[k] = row["value"] if row else ""
|
||||||
|
if cfg.get("oidc_client_secret"):
|
||||||
|
cfg["oidc_client_secret"] = _safe_dec(cfg["oidc_client_secret"])
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_discover(issuer: str) -> dict:
|
||||||
|
import requests as req
|
||||||
|
now = time.time()
|
||||||
|
cached = _oidc_discovery_cache.get(issuer)
|
||||||
|
if cached and now - cached[1] < _OIDC_CACHE_TTL:
|
||||||
|
return cached[0]
|
||||||
|
url = issuer.rstrip("/") + "/.well-known/openid-configuration"
|
||||||
|
resp = req.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
_oidc_discovery_cache[issuer] = (data, now)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_get_jwks(jwks_uri: str) -> list:
|
||||||
|
import requests as req
|
||||||
|
now = time.time()
|
||||||
|
cached = _oidc_jwks_cache.get(jwks_uri)
|
||||||
|
if cached and now - cached[1] < _OIDC_CACHE_TTL:
|
||||||
|
return cached[0]
|
||||||
|
resp = req.get(jwks_uri, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
keys = resp.json().get("keys", [])
|
||||||
|
_oidc_jwks_cache[jwks_uri] = (keys, now)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_store_state(state: str, nonce: str):
|
||||||
|
with _oidc_pending_lock:
|
||||||
|
now = time.time()
|
||||||
|
expired = [k for k, v in _oidc_pending.items() if now - v["created"] > _OIDC_STATE_TTL]
|
||||||
|
for k in expired:
|
||||||
|
del _oidc_pending[k]
|
||||||
|
_oidc_pending[state] = {"nonce": nonce, "created": now}
|
||||||
|
|
||||||
|
|
||||||
|
def _oidc_pop_state(state: str) -> dict | None:
|
||||||
|
with _oidc_pending_lock:
|
||||||
|
return _oidc_pending.pop(state, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_id_token(id_token: str, issuer: str, client_id: str, nonce: str) -> dict:
|
||||||
|
from jwt.algorithms import RSAAlgorithm
|
||||||
|
disco = _oidc_discover(issuer)
|
||||||
|
jwks = _oidc_get_jwks(disco["jwks_uri"])
|
||||||
|
header = pyjwt.get_unverified_header(id_token)
|
||||||
|
kid = header.get("kid")
|
||||||
|
key_data = None
|
||||||
|
for k in jwks:
|
||||||
|
if k.get("kid") == kid:
|
||||||
|
key_data = k
|
||||||
|
break
|
||||||
|
if not key_data:
|
||||||
|
raise HTTPException(401, "OIDC: chave não encontrada no JWKS")
|
||||||
|
public_key = RSAAlgorithm.from_jwk(json.dumps(key_data))
|
||||||
|
claims = pyjwt.decode(id_token, public_key, algorithms=["RS256"], audience=client_id, issuer=issuer)
|
||||||
|
if claims.get("nonce") != nonce:
|
||||||
|
raise HTTPException(401, "OIDC: nonce inválido")
|
||||||
|
return claims
|
||||||
20
backend/auth/rate_limit.py
Normal file
20
backend/auth/rate_limit.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
"""Rate limiting for login brute-force protection."""
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
_login_attempts: dict = {}
|
||||||
|
_login_attempts_lock = threading.Lock()
|
||||||
|
_LOGIN_WINDOW = 300 # 5 minutes
|
||||||
|
_LOGIN_MAX = 10 # max attempts per window
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(ip: str):
|
||||||
|
now = time.time()
|
||||||
|
with _login_attempts_lock:
|
||||||
|
attempts = _login_attempts.get(ip, [])
|
||||||
|
attempts = [t for t in attempts if now - t < _LOGIN_WINDOW]
|
||||||
|
if len(attempts) >= _LOGIN_MAX:
|
||||||
|
raise HTTPException(429, "Muitas tentativas de login. Aguarde 5 minutos.")
|
||||||
|
attempts.append(now)
|
||||||
|
_login_attempts[ip] = attempts
|
||||||
123
backend/config.py
Normal file
123
backend/config.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Application configuration — constants, paths, environment variables, model catalogs.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import concurrent.futures
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Secrets & Auth ───────────────────────────────────────────────────────────
|
||||||
|
APP_SECRET = os.environ.get("APP_SECRET") or secrets.token_hex(32)
|
||||||
|
if not os.environ.get("APP_SECRET"):
|
||||||
|
import warnings
|
||||||
|
warnings.warn("APP_SECRET not set — using random key (tokens will not survive restarts). Set APP_SECRET in .env for production.")
|
||||||
|
JWT_ALG = "HS256"
|
||||||
|
JWT_EXP_H = int(os.environ.get("JWT_EXPIRY_HOURS", "12"))
|
||||||
|
|
||||||
|
# ── Paths ────────────────────────────────────────────────────────────────────
|
||||||
|
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"
|
||||||
|
TERRAFORM_DIR = DATA / "terraform"
|
||||||
|
_EMBED_STATUS_DIR = DATA / ".embed_status"
|
||||||
|
|
||||||
|
for d in [DATA, OCI_DIR, REPORTS, MCP_DIR, WALLET_DIR, TERRAFORM_DIR, _EMBED_STATUS_DIR]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# ── App Settings ─────────────────────────────────────────────────────────────
|
||||||
|
VERSION = "1.1"
|
||||||
|
_START_TIME = time.time()
|
||||||
|
_MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # 50MB
|
||||||
|
|
||||||
|
# ── Chat Memory Compaction ───────────────────────────────────────────────────
|
||||||
|
COMPACT_TOKEN_THRESHOLD = 6000
|
||||||
|
COMPACT_KEEP_RECENT = 20
|
||||||
|
COMPACT_SUMMARY_MAX_TOKENS = 1500
|
||||||
|
COMPACT_MIN_MESSAGES = 8
|
||||||
|
|
||||||
|
# ── Concurrency ──────────────────────────────────────────────────────────────
|
||||||
|
_chat_executor = concurrent.futures.ThreadPoolExecutor(max_workers=10, thread_name_prefix="chat")
|
||||||
|
|
||||||
|
# ── Logging ──────────────────────────────────────────────────────────────────
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
log = logging.getLogger("agent")
|
||||||
|
|
||||||
|
# ── OCI GenAI Models Catalog ─────────────────────────────────────────────────
|
||||||
|
GENAI_MODELS = {
|
||||||
|
"meta.llama-4-maverick-17b-128e-instruct-fp8": {"provider":"meta","name":"Meta Llama 4 Maverick","api_format":"GENERIC","max_tokens":8192,
|
||||||
|
"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","max_tokens":8192,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaw23hfc7mtvv5wef3gwvvaguyzqmhb5lx4r5s3y2xzc4a"}},
|
||||||
|
"google.gemini-2.5-pro": {"provider":"google","name":"Google Gemini 2.5 Pro","api_format":"GENERIC","max_tokens":65536,
|
||||||
|
"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","max_tokens":8192,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaeo4ehrn25guuats5s45hnvswlhxo6riop275l2bkr2vq"}},
|
||||||
|
"openai.gpt-5.2": {"provider":"openai","name":"OpenAI GPT-5.2","api_format":"GENERIC","max_tokens":32768,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya4fw3p5fddnexbfcurnz7spgkqb4mq4a6y5ubyv7777sa"}},
|
||||||
|
"openai.gpt-5.1": {"provider":"openai","name":"OpenAI GPT-5.1","api_format":"GENERIC","max_tokens":32768,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3darth2ozqcfssb2bats5jitpgigllccajasdyqljnkq"}},
|
||||||
|
"openai.gpt-5-mini": {"provider":"openai","name":"OpenAI GPT-5 Mini","api_format":"GENERIC","max_tokens":16384,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya3eain4n6v3edm4ryjvze5hnjouujd4vralxntfalwjaq"}},
|
||||||
|
"openai.gpt-4.1": {"provider":"openai","name":"OpenAI GPT-4.1","api_format":"GENERIC","max_tokens":32768,"penalties":True,
|
||||||
|
"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","max_tokens":16384,"penalties":True,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya6pk3sxishpiexm2rb5sf4ytb5tsbz4to2g3g23smidaa"}},
|
||||||
|
"openai.gpt-4o": {"provider":"openai","name":"OpenAI GPT-4o","api_format":"GENERIC","max_tokens":16384,"penalties":True,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyah7slrtboxdbfdy5cdspsfts62yumoclpdgwydopse7za"}},
|
||||||
|
"openai.o4-mini": {"provider":"openai","name":"OpenAI o4-mini","api_format":"GENERIC","reasoning":True,"max_tokens":100000,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceya5ivfqp4fxlajoeg2cahlqtuuswagiv6a7dggpigy23bq"}},
|
||||||
|
"openai.o3": {"provider":"openai","name":"OpenAI o3","api_format":"GENERIC","reasoning":True,"max_tokens":100000,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyalgnrukpjk6wm5zsf4jzkoneahgswhrk7kukkoagwnzma"}},
|
||||||
|
"xai.grok-4": {"provider":"xai","name":"xAI Grok 4","api_format":"GENERIC","max_tokens":131072,
|
||||||
|
"ocids":{"us-ashburn-1":"ocid1.generativeaimodel.oc1.iad.amaaaaaask7dceyaldmhg25is4nouena4oa2pj4nvwgfeempo4syiaazukia"}},
|
||||||
|
"xai.grok-3": {"provider":"xai","name":"xAI Grok 3","api_format":"GENERIC","max_tokens":131072,
|
||||||
|
"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","max_tokens":131072,
|
||||||
|
"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",
|
||||||
|
],
|
||||||
|
}
|
||||||
376
backend/database/__init__.py
Normal file
376
backend/database/__init__.py
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
"""Database layer — SQLite context manager and schema initialization."""
|
||||||
|
import sqlite3
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
from config import DB_PATH, TERRAFORM_DIR, log
|
||||||
|
from auth.crypto import _hash_pw
|
||||||
|
|
||||||
|
# ── 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,
|
||||||
|
timezone TEXT DEFAULT 'America/Sao_Paulo',
|
||||||
|
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,
|
||||||
|
reasoning_effort TEXT,
|
||||||
|
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,
|
||||||
|
is_global INTEGER DEFAULT 0,
|
||||||
|
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,
|
||||||
|
is_global INTEGER DEFAULT 0,
|
||||||
|
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,
|
||||||
|
status TEXT DEFAULT 'done',
|
||||||
|
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 terminal_history (
|
||||||
|
id TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
||||||
|
oci_config_id TEXT NOT NULL, command TEXT NOT NULL,
|
||||||
|
exit_code INTEGER, output TEXT, error 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 explorer_counts_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
oci_config_id TEXT NOT NULL,
|
||||||
|
compartment_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
regions TEXT DEFAULT '',
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
UNIQUE(oci_config_id, compartment_id, resource_type, regions)
|
||||||
|
);
|
||||||
|
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')")
|
||||||
|
c.execute("DELETE FROM audit_log WHERE created_at < datetime('now', '-30 days')")
|
||||||
|
# ── Indexes for performance ──
|
||||||
|
for idx in [
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chat_msg_session ON chat_messages(session_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chat_msg_user ON chat_messages(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chat_msg_status ON chat_messages(status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_chat_sess_user ON chat_sessions(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_reports_user ON reports(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_reports_status ON reports(status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_report_files_report ON report_files(report_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_oci_cfg_user ON oci_configs(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_genai_cfg_user ON genai_configs(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_mcp_srv_user ON mcp_servers(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_adb_cfg_user ON adb_vector_configs(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_adb_tables_cfg ON adb_vector_tables(adb_config_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tf_ws_user ON terraform_workspaces(user_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_tf_ws_session ON terraform_workspaces(session_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id)",
|
||||||
|
]:
|
||||||
|
c.execute(idx)
|
||||||
|
# ── Migrations ──
|
||||||
|
for col in ["system_prompt TEXT DEFAULT ''", "reasoning_effort TEXT"]:
|
||||||
|
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'", "is_global INTEGER DEFAULT 0"]:
|
||||||
|
try:
|
||||||
|
c.execute(f"ALTER TABLE adb_vector_configs ADD COLUMN {col}")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
for col in ["is_global INTEGER DEFAULT 0"]:
|
||||||
|
try:
|
||||||
|
c.execute(f"ALTER TABLE mcp_servers 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
|
||||||
|
for col in ["ssh_public_key TEXT", "auth_type TEXT DEFAULT 'api_key'"]:
|
||||||
|
try:
|
||||||
|
c.execute(f"ALTER TABLE oci_configs ADD COLUMN {col}")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass
|
||||||
|
for col in ["timezone TEXT DEFAULT 'America/Sao_Paulo'", "auth_provider TEXT DEFAULT 'local'", "oidc_subject TEXT", "must_change_password INTEGER DEFAULT 0"]:
|
||||||
|
try:
|
||||||
|
c.execute(f"ALTER TABLE users 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 as e:
|
||||||
|
log.warning(f"DB migration: adb_vector_tables backfill failed: {e}")
|
||||||
|
# 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 as e:
|
||||||
|
log.warning(f"DB migration: chat_sessions backfill failed: {e}")
|
||||||
|
# Migrate legacy base64 fields to Fernet encryption
|
||||||
|
_enc_migrations = [
|
||||||
|
("oci_configs", ["tenancy_ocid", "user_ocid", "fingerprint", "compartment_id"]),
|
||||||
|
("adb_vector_configs", ["password_enc", "wallet_password_enc"]),
|
||||||
|
]
|
||||||
|
for table, cols in _enc_migrations:
|
||||||
|
for col in cols:
|
||||||
|
try:
|
||||||
|
rows = c.execute(f"SELECT id, {col} FROM {table} WHERE {col} IS NOT NULL AND {col} != ''").fetchall()
|
||||||
|
for row in rows:
|
||||||
|
val = row[col]
|
||||||
|
if val and not val.startswith(_FERNET_PREFIX):
|
||||||
|
try:
|
||||||
|
plaintext = base64.b64decode(val.encode()).decode()
|
||||||
|
c.execute(f"UPDATE {table} SET {col}=? WHERE id=?", (_enc(plaintext), row["id"]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"DB migration: encrypt {table}.{col} failed: {e}")
|
||||||
|
# Seed default system prompts (late import to avoid circular dependency)
|
||||||
|
from services.genai import RAG_DEFAULT_SYSTEM_PROMPT, TF_DEFAULT_SYSTEM_PROMPT
|
||||||
|
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:
|
||||||
|
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))
|
||||||
|
# Recover stuck terraform workspaces (interrupted by container restart)
|
||||||
|
stuck = c.execute("SELECT id, status FROM terraform_workspaces WHERE status IN ('planning','applying','destroying')").fetchall()
|
||||||
|
for ws in stuck:
|
||||||
|
prev = 'applied' if ws["status"] == 'destroying' else 'failed'
|
||||||
|
c.execute("UPDATE terraform_workspaces SET status=?, error='Interrompido por restart do container', updated_at=datetime('now') WHERE id=?", (prev, ws["id"]))
|
||||||
|
# Remove stale lock files
|
||||||
|
lock_file = TERRAFORM_DIR / ws["id"] / ".terraform.tfstate.lock.info"
|
||||||
|
if lock_file.exists():
|
||||||
|
lock_file.unlink()
|
||||||
|
log.info(f"Recovered stuck workspace {ws['id']}: {ws['status']} -> {prev}")
|
||||||
|
adm = c.execute("SELECT id FROM users WHERE username='admin'").fetchone()
|
||||||
|
if not adm:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,must_change_password) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(str(uuid.uuid4()), "Admin", "Sistema", "admin", "admin@local", _hash_pw("admin123"), "admin", 1)
|
||||||
|
)
|
||||||
|
log.info("Default admin created — username: admin / password: admin123")
|
||||||
|
|
||||||
74
backend/models.py
Normal file
74
backend/models.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""Pydantic request models for API endpoints."""
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
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; timezone: Optional[str] = None
|
||||||
|
|
||||||
|
class ChatMsg(BaseModel):
|
||||||
|
message: str; session_id: Optional[str] = None
|
||||||
|
genai_config_id: Optional[str] = None
|
||||||
|
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
|
||||||
|
reasoning_effort: Optional[str] = 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
|
||||||
|
reasoning_effort: Optional[str] = None
|
||||||
|
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; is_global: bool = False
|
||||||
|
|
||||||
|
class ConsultQuery(BaseModel):
|
||||||
|
query: str
|
||||||
|
table_name: str = ""
|
||||||
|
top_k: int = 10
|
||||||
|
oci_config_id: str = ""
|
||||||
|
|
||||||
|
class TfPromptReq(BaseModel):
|
||||||
|
message: str
|
||||||
|
genai_config: Optional[dict] = None
|
||||||
|
history: Optional[list] = None
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
|
||||||
|
class TfWorkspaceReq(BaseModel):
|
||||||
|
session_id: str
|
||||||
|
oci_config_id: str
|
||||||
|
compartment_id: Optional[str] = None
|
||||||
|
name: str = "workspace"
|
||||||
|
tf_code: str
|
||||||
0
backend/routes/__init__.py
Normal file
0
backend/routes/__init__.py
Normal file
321
backend/routes/adb.py
Normal file
321
backend/routes/adb.py
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
"""ADB (Autonomous Database) routes — parse-wallet, config CRUD, wallet upload, test, tables, ingest."""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from typing import Optional
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form, BackgroundTasks
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access
|
||||||
|
from auth.crypto import _enc
|
||||||
|
from config import WALLET_DIR, log
|
||||||
|
from models import IngestDocReq
|
||||||
|
from utils import validate_upload
|
||||||
|
from services.genai import (
|
||||||
|
_get_adb_connection,
|
||||||
|
_get_tables_for_config,
|
||||||
|
)
|
||||||
|
from services.embeddings import _ingest_documents_task
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_TABLE_NAME_RE = re.compile(r'^[A-Za-z][A-Za-z0-9_]{0,127}$')
|
||||||
|
|
||||||
|
|
||||||
|
@router.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)."""
|
||||||
|
await validate_upload(wallet, {".zip"})
|
||||||
|
wallet.file.seek(0)
|
||||||
|
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 nao contem 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 nao e um ZIP valido")
|
||||||
|
|
||||||
|
@router.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"),
|
||||||
|
is_global: str = Form("false"),
|
||||||
|
wallet: Optional[UploadFile] = File(None),
|
||||||
|
u=Depends(require("admin","user"))
|
||||||
|
):
|
||||||
|
if wallet and wallet.filename:
|
||||||
|
await validate_upload(wallet, {".zip"})
|
||||||
|
wallet.file.seek(0)
|
||||||
|
vid = str(uuid.uuid4())
|
||||||
|
use_mtls_bool = use_mtls.lower() in ("true", "1", "yes")
|
||||||
|
is_global_val = 1 if (is_global.lower() in ("true", "1", "yes") and u["role"] == "admin") else 0
|
||||||
|
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,is_global) 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, is_global_val))
|
||||||
|
# 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 conexao", u["id"], u["username"])
|
||||||
|
_audit(u["id"], u["username"], "save_adb_config", vid, config_name)
|
||||||
|
_config_log("adb", vid, config_name, "success", "save", f"Conexao salva: {config_name} ({dsn})", u["id"], u["username"])
|
||||||
|
return {"id": vid, "config_name": config_name}
|
||||||
|
|
||||||
|
@router.post("/api/adb/{vid}/upload-wallet")
|
||||||
|
async def upload_wallet(vid: str, wallet: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u, require_owner=True)
|
||||||
|
await validate_upload(wallet, {".zip"})
|
||||||
|
wallet.file.seek(0)
|
||||||
|
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])
|
||||||
|
|
||||||
|
@router.get("/api/adb/configs")
|
||||||
|
async def list_adb(u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
cols = "id,user_id,config_name,dsn,username,table_name,use_mtls,is_active,is_global,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=? OR is_global=1",(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
|
||||||
|
|
||||||
|
@router.post("/api/adb/test/{vid}")
|
||||||
|
async def test_adb(vid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u)
|
||||||
|
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", "Conexao Autonomous DB OK!", u["id"], u["username"])
|
||||||
|
return {"status":"success","message":"Conexao Autonomous DB OK!"}
|
||||||
|
except ImportError:
|
||||||
|
_config_log("adb", vid, cname, "error", "test", "python-oracledb nao disponivel no container.", u["id"], u["username"])
|
||||||
|
return {"status":"error","message":"python-oracledb nao disponivel 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}
|
||||||
|
|
||||||
|
@router.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."""
|
||||||
|
_verify_config_access("adb", vid, u)
|
||||||
|
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"Nao 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 conexao: {str(e)[:500]}"}
|
||||||
|
|
||||||
|
@router.delete("/api/adb/configs/{vid}")
|
||||||
|
async def del_adb(vid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u, require_owner=True)
|
||||||
|
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}
|
||||||
|
|
||||||
|
@router.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"),
|
||||||
|
is_global: str = Form(""),
|
||||||
|
wallet: Optional[UploadFile] = File(None),
|
||||||
|
u=Depends(require("admin","user"))
|
||||||
|
):
|
||||||
|
if wallet and wallet.filename:
|
||||||
|
await validate_upload(wallet, {".zip"})
|
||||||
|
wallet.file.seek(0)
|
||||||
|
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)
|
||||||
|
if u["role"] != "admin" and existing.get("is_global"): raise HTTPException(403, "Nao e possivel editar configuracao global")
|
||||||
|
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 u["role"] == "admin" and is_global:
|
||||||
|
is_global_val = 1 if is_global.lower() in ("true", "1", "yes") else 0
|
||||||
|
sets += ",is_global=?"
|
||||||
|
vals.append(is_global_val)
|
||||||
|
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"Conexao atualizada: {config_name} ({dsn})", u["id"], u["username"])
|
||||||
|
return {"id": vid, "config_name": config_name}
|
||||||
|
|
||||||
|
@router.get("/api/adb/{vid}/tables")
|
||||||
|
async def list_adb_tables(vid: str, u=Depends(current_user)):
|
||||||
|
_verify_config_access("adb", vid, u)
|
||||||
|
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)
|
||||||
|
|
||||||
|
@router.post("/api/adb/{vid}/tables")
|
||||||
|
async def add_adb_table(vid: str, req: dict, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u, require_owner=True)
|
||||||
|
table_name = req.get("table_name", "").strip()
|
||||||
|
description = req.get("description", "").strip()
|
||||||
|
if not table_name: raise HTTPException(400, "table_name e obrigatorio")
|
||||||
|
if not _TABLE_NAME_RE.match(table_name): raise HTTPException(400, "Nome da tabela invalido. Use letras maiusculas, numeros 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}' ja registrada nesta conexao")
|
||||||
|
_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}
|
||||||
|
|
||||||
|
@router.delete("/api/adb/{vid}/tables/{tid}")
|
||||||
|
async def remove_adb_table(vid: str, tid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u, require_owner=True)
|
||||||
|
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}
|
||||||
|
|
||||||
|
@router.put("/api/adb/{vid}/tables/{tid}")
|
||||||
|
async def update_adb_table(vid: str, tid: str, req: dict, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u, require_owner=True)
|
||||||
|
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 e obrigatorio")
|
||||||
|
if not _TABLE_NAME_RE.match(tn): raise HTTPException(400, "Nome da tabela invalido")
|
||||||
|
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 ja existe nesta conexao")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.post("/api/adb/{vid}/ingest")
|
||||||
|
async def ingest_documents(vid: str, req: IngestDocReq, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u)
|
||||||
|
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"Ingestao iniciada para {len(req.documents)} documentos", "config_id": vid}
|
||||||
227
backend/routes/auth.py
Normal file
227
backend/routes/auth.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
"""Auth routes — login, logout, register, change-password, OIDC, MFA."""
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
|
from config import JWT_EXP_H
|
||||||
|
from database import db
|
||||||
|
from auth.crypto import _hash_pw, _verify_pw, _totp_secret, _totp_verify, _totp_uri, _make_token
|
||||||
|
from auth.oidc import _get_oidc_config, _oidc_discover, _oidc_store_state, _oidc_pop_state, _validate_id_token
|
||||||
|
from auth.jwt_auth import current_user, require, _audit
|
||||||
|
from auth.rate_limit import _check_rate_limit
|
||||||
|
from models import LoginReq, RegisterReq, TOTPVerify, ChangePwReq
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
||||||
|
@router.post("/api/auth/login")
|
||||||
|
async def login(req: LoginReq, request: Request):
|
||||||
|
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||||
|
with db() as c:
|
||||||
|
mode_row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
|
||||||
|
if mode_row and mode_row["value"] == "oidc":
|
||||||
|
raise HTTPException(400, "Login local desabilitado. Use Oracle IAM.")
|
||||||
|
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"]),"timezone":u.get("timezone","America/Sao_Paulo")},
|
||||||
|
"must_change_password": bool(u.get("must_change_password", 0))}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.post("/api/auth/change-password")
|
||||||
|
async def change_pw(req: ChangePwReq, u=Depends(current_user)):
|
||||||
|
if u.get("auth_provider") == "oidc":
|
||||||
|
raise HTTPException(400, "Usuários OIDC não podem alterar senha localmente")
|
||||||
|
if not _verify_pw(req.current_password, u["password_hash"]): raise HTTPException(400, "Senha atual incorreta")
|
||||||
|
pw = req.new_password
|
||||||
|
if len(pw) < 8: raise HTTPException(400, "Senha deve ter no mínimo 8 caracteres")
|
||||||
|
if not re.search(r'[A-Z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra maiúscula")
|
||||||
|
if not re.search(r'[a-z]', pw): raise HTTPException(400, "Senha deve conter ao menos uma letra minúscula")
|
||||||
|
if not re.search(r'\d', pw): raise HTTPException(400, "Senha deve conter ao menos um número")
|
||||||
|
if not re.search(r'[^A-Za-z0-9]', pw): raise HTTPException(400, "Senha deve conter ao menos um caractere especial")
|
||||||
|
if pw == req.current_password: raise HTTPException(400, "A nova senha deve ser diferente da atual")
|
||||||
|
with db() as c: c.execute("UPDATE users SET password_hash=?, must_change_password=0 WHERE id=?", (_hash_pw(pw), u["id"]))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ── OIDC endpoints ───────────────────────────────────────────────────────────
|
||||||
|
@router.get("/api/auth/oidc/config")
|
||||||
|
async def oidc_public_config():
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT value FROM app_settings WHERE key='auth_mode'").fetchone()
|
||||||
|
return {"auth_mode": row["value"] if row else "local"}
|
||||||
|
|
||||||
|
@router.get("/api/auth/oidc/login")
|
||||||
|
async def oidc_login(request: Request):
|
||||||
|
_check_rate_limit(request.client.host if request.client else "unknown")
|
||||||
|
cfg = _get_oidc_config()
|
||||||
|
if cfg.get("auth_mode") not in ("oidc", "both"):
|
||||||
|
raise HTTPException(400, "OIDC login não está habilitado")
|
||||||
|
if not cfg.get("oidc_issuer") or not cfg.get("oidc_client_id"):
|
||||||
|
raise HTTPException(500, "OIDC não configurado (issuer/client_id ausente)")
|
||||||
|
disco = _oidc_discover(cfg["oidc_issuer"])
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
nonce = secrets.token_urlsafe(32)
|
||||||
|
_oidc_store_state(state, nonce)
|
||||||
|
params = {"response_type": "code", "client_id": cfg["oidc_client_id"],
|
||||||
|
"redirect_uri": cfg["oidc_redirect_uri"], "scope": "openid profile email groups",
|
||||||
|
"state": state, "nonce": nonce}
|
||||||
|
auth_url = disco["authorization_endpoint"] + "?" + urlencode(params)
|
||||||
|
return RedirectResponse(url=auth_url, status_code=302)
|
||||||
|
|
||||||
|
@router.get("/api/auth/oidc/callback")
|
||||||
|
async def oidc_callback(request: Request, code: str = Query(...), state: str = Query(...)):
|
||||||
|
ip = request.client.host if request.client else "unknown"
|
||||||
|
_check_rate_limit(ip)
|
||||||
|
pending = _oidc_pop_state(state)
|
||||||
|
if not pending:
|
||||||
|
raise HTTPException(400, "OIDC: state inválido ou expirado")
|
||||||
|
nonce = pending["nonce"]
|
||||||
|
cfg = _get_oidc_config()
|
||||||
|
disco = _oidc_discover(cfg["oidc_issuer"])
|
||||||
|
import requests as req
|
||||||
|
token_resp = req.post(disco["token_endpoint"], data={
|
||||||
|
"grant_type": "authorization_code", "code": code,
|
||||||
|
"redirect_uri": cfg["oidc_redirect_uri"],
|
||||||
|
"client_id": cfg["oidc_client_id"], "client_secret": cfg["oidc_client_secret"],
|
||||||
|
}, timeout=15)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
from config import log
|
||||||
|
log.error(f"OIDC token exchange failed: {token_resp.status_code} {token_resp.text[:500]}")
|
||||||
|
raise HTTPException(502, "OIDC: falha ao trocar código por token")
|
||||||
|
tokens = token_resp.json()
|
||||||
|
id_token_raw = tokens.get("id_token")
|
||||||
|
if not id_token_raw:
|
||||||
|
raise HTTPException(502, "OIDC: id_token ausente na resposta")
|
||||||
|
claims = _validate_id_token(id_token_raw, cfg["oidc_issuer"], cfg["oidc_client_id"], nonce)
|
||||||
|
sub = claims.get("sub")
|
||||||
|
email = claims.get("email", "")
|
||||||
|
given_name = claims.get("given_name") or claims.get("name", "").split(" ")[0] or "OIDC"
|
||||||
|
family_name = claims.get("family_name") or " ".join(claims.get("name", "User").split(" ")[1:]) or "User"
|
||||||
|
groups = claims.get("groups", [])
|
||||||
|
if not groups:
|
||||||
|
groups = claims.get("http://www.oracle.com/groups", [])
|
||||||
|
role = "viewer"
|
||||||
|
if cfg.get("oidc_group_admin") and cfg["oidc_group_admin"] in groups:
|
||||||
|
role = "admin"
|
||||||
|
elif cfg.get("oidc_group_user") and cfg["oidc_group_user"] in groups:
|
||||||
|
role = "user"
|
||||||
|
with db() as c:
|
||||||
|
u = c.execute("SELECT * FROM users WHERE oidc_subject=? AND is_active=1", (sub,)).fetchone()
|
||||||
|
if u:
|
||||||
|
u = dict(u)
|
||||||
|
c.execute("UPDATE users SET role=?, last_login=datetime('now'), email=? WHERE id=?",
|
||||||
|
(role, email or u.get("email"), u["id"]))
|
||||||
|
u["role"] = role
|
||||||
|
_audit(u["id"], u["username"], "oidc_login", details=f"sub={sub}", ip=ip)
|
||||||
|
else:
|
||||||
|
uid = str(uuid.uuid4())
|
||||||
|
username = email.split("@")[0] if email else f"oidc_{sub[:8]}"
|
||||||
|
base_username = username
|
||||||
|
counter = 1
|
||||||
|
while c.execute("SELECT 1 FROM users WHERE username=?", (username,)).fetchone():
|
||||||
|
username = f"{base_username}_{counter}"
|
||||||
|
counter += 1
|
||||||
|
fake_hash = _hash_pw(secrets.token_hex(32))
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO users (id,first_name,last_name,username,email,password_hash,role,auth_provider,oidc_subject) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
||||||
|
(uid, given_name, family_name, username, email, fake_hash, role, "oidc", sub))
|
||||||
|
u = {"id": uid, "first_name": given_name, "last_name": family_name,
|
||||||
|
"username": username, "email": email, "role": role}
|
||||||
|
_audit(uid, username, "oidc_jit_provision", details=f"sub={sub} email={email} role={role}", ip=ip)
|
||||||
|
c.execute("UPDATE users SET last_login=datetime('now') WHERE id=?", (uid,))
|
||||||
|
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))
|
||||||
|
token = _make_token(u["id"], u["role"], sid)
|
||||||
|
html = f"""<!DOCTYPE html><html><head><title>OIDC Login</title></head><body>
|
||||||
|
<script>localStorage.setItem('t',{json.dumps(token)});window.location.href='/chat';</script>
|
||||||
|
<noscript><p>Login OK. <a href="/chat">Continuar</a></p></noscript></body></html>"""
|
||||||
|
return HTMLResponse(content=html)
|
||||||
|
|
||||||
|
@router.post("/api/auth/oidc/logout")
|
||||||
|
async def oidc_logout(u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE sessions SET is_active=0 WHERE user_id=?", (u["id"],))
|
||||||
|
_audit(u["id"], u["username"], "oidc_logout")
|
||||||
|
idp_logout_url = None
|
||||||
|
try:
|
||||||
|
cfg = _get_oidc_config()
|
||||||
|
if cfg.get("oidc_issuer"):
|
||||||
|
disco = _oidc_discover(cfg["oidc_issuer"])
|
||||||
|
idp_logout_url = disco.get("end_session_endpoint")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True, "idp_logout_url": idp_logout_url}
|
||||||
|
|
||||||
|
@router.post("/api/settings/oidc/test")
|
||||||
|
async def test_oidc(u=Depends(require("admin"))):
|
||||||
|
cfg = _get_oidc_config()
|
||||||
|
if not cfg.get("oidc_issuer"):
|
||||||
|
raise HTTPException(400, "Issuer URL não configurado")
|
||||||
|
import requests as req
|
||||||
|
try:
|
||||||
|
url = cfg["oidc_issuer"].rstrip("/") + "/.well-known/openid-configuration"
|
||||||
|
resp = req.get(url, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
disco = resp.json()
|
||||||
|
has_auth = bool(disco.get("authorization_endpoint"))
|
||||||
|
has_token = bool(disco.get("token_endpoint"))
|
||||||
|
has_jwks = bool(disco.get("jwks_uri"))
|
||||||
|
return {"ok": has_auth and has_token and has_jwks, "issuer": disco.get("issuer"),
|
||||||
|
"authorization_endpoint": has_auth, "token_endpoint": has_token,
|
||||||
|
"jwks_uri": has_jwks, "end_session_endpoint": bool(disco.get("end_session_endpoint"))}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(502, f"Falha ao conectar ao discovery: {str(e)}")
|
||||||
|
|
||||||
|
# ── MFA ───────────────────────────────────────────────────────────────────────
|
||||||
|
@router.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"])}
|
||||||
|
|
||||||
|
@router.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"}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
200
backend/routes/chat.py
Normal file
200
backend/routes/chat.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""Chat routes — chat upload, chat JSON, message status, sessions CRUD."""
|
||||||
|
import base64
|
||||||
|
import shutil
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter, HTTPException, Depends, Query, BackgroundTasks,
|
||||||
|
UploadFile, File, Form, Body,
|
||||||
|
)
|
||||||
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
|
from config import VERSION, GENAI_MODELS, TERRAFORM_DIR, log
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, _audit
|
||||||
|
from models import ChatMsg
|
||||||
|
from services.chat_background import _chat_start, _chat_background
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ── Upload constants ──────────────────────────────────────────────────────────
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chat endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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),
|
||||||
|
reasoning_effort: Optional[str] = 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 as e:
|
||||||
|
log.warning(f"UTF-8 decode failed for file attachment, falling back to latin-1: {e}")
|
||||||
|
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,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
)
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.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"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Local fallback agent (no GenAI configured) ───────────────────────────────
|
||||||
|
|
||||||
|
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 **AI Agent v" + VERSION + "** — Infrastructure & Security Engineer. Sem modelo GenAI configurado, uso respostas locais.\n\n"
|
||||||
|
"Para chat com IA:\n1. Configure **OCI Credentials**\n2. Configure **GenAI** com modelo e região\n3. Selecione o modelo no chat\n\nDigite **ajuda** para ver os comandos.")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chat sessions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/chat/sessions/{sid}/messages")
|
||||||
|
async def get_session_messages(sid: str, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), 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")
|
||||||
|
total = c.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM chat_messages "
|
||||||
|
"WHERE session_id=? AND status='done'", (sid,)).fetchone()["cnt"]
|
||||||
|
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 LIMIT ? OFFSET ?", (sid, limit, skip)).fetchall()
|
||||||
|
return {"session": dict(session), "messages": [dict(m) for m in msgs], "total": total}
|
||||||
|
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
|
||||||
|
@router.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}
|
||||||
105
backend/routes/cis_engine.py
Normal file
105
backend/routes/cis_engine.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""CIS Engine routes — version management, check-update, update, health check."""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
|
from config import VERSION, _START_TIME, log
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, require, _audit
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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:"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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)}
|
||||||
|
|
||||||
|
@router.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"]
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
db_ok = False
|
||||||
|
try:
|
||||||
|
with db() as c:
|
||||||
|
c.execute("SELECT 1")
|
||||||
|
db_ok = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"version": VERSION,
|
||||||
|
"uptime_seconds": int(time.time() - _START_TIME),
|
||||||
|
"db_ok": db_ok,
|
||||||
|
"ts": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
732
backend/routes/embeddings.py
Normal file
732
backend/routes/embeddings.py
Normal file
@@ -0,0 +1,732 @@
|
|||||||
|
"""Embedding routes — preview, embed report, status, section, file, upload, upload-url, consult, list, delete, purge."""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Form, Query, BackgroundTasks
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access, _verify_report_access
|
||||||
|
from config import REPORTS, log, _chat_executor
|
||||||
|
from models import ConsultQuery
|
||||||
|
from utils import validate_upload, set_embed_status, get_embed_status, update_embed_status
|
||||||
|
from services.genai import (
|
||||||
|
_call_genai,
|
||||||
|
_get_adb_connection,
|
||||||
|
_resolve_embed_config,
|
||||||
|
_embed_text,
|
||||||
|
_DIM_TO_MODEL,
|
||||||
|
_get_table_embedding_dim,
|
||||||
|
_vector_search_multi,
|
||||||
|
_relevant_tables,
|
||||||
|
_build_rag_context,
|
||||||
|
_get_active_adb_configs,
|
||||||
|
_get_tables_for_config,
|
||||||
|
RAG_CONTEXT_TEMPLATE,
|
||||||
|
CONSULT_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
from services.embeddings import (
|
||||||
|
_build_metadata_json,
|
||||||
|
_auto_register_table,
|
||||||
|
_ingest_documents_task,
|
||||||
|
_chunk_report_by_section,
|
||||||
|
_chunk_cis_pdf,
|
||||||
|
_chunk_text_file,
|
||||||
|
_get_adb_and_genai,
|
||||||
|
_chunk_summary_csv,
|
||||||
|
_purge_table_by_tenancy,
|
||||||
|
_resolve_table_for_csv,
|
||||||
|
_chunk_findings_csv,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-local helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _extract_pdf_text(file_bytes: bytes) -> str:
|
||||||
|
"""Extract text from a PDF file using PyPDF2 or pdfplumber."""
|
||||||
|
import io
|
||||||
|
try:
|
||||||
|
import PyPDF2
|
||||||
|
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
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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."""
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
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}
|
||||||
|
|
||||||
|
@router.post("/api/embeddings/report/{rid}")
|
||||||
|
async def embed_report(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||||
|
"""Auto-embed all CIS report CSVs into their mapped ADB vector tables."""
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
vid = req.get("adb_config_id")
|
||||||
|
if not vid: raise HTTPException(400, "adb_config_id is required")
|
||||||
|
_verify_config_access("adb", vid, u)
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT 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")
|
||||||
|
|
||||||
|
rdir = REPORTS / rid
|
||||||
|
tenancy = r["tenancy_name"] or "unknown"
|
||||||
|
|
||||||
|
# Read extract_date from summary CSV
|
||||||
|
import csv as csvmod
|
||||||
|
summary_csv = rdir / "cis_summary_report.csv"
|
||||||
|
extract_date = ""
|
||||||
|
if summary_csv.exists():
|
||||||
|
with open(summary_csv, "r", encoding="utf-8") as f:
|
||||||
|
first = next(csvmod.DictReader(f), {})
|
||||||
|
extract_date = first.get("extract_date", "")
|
||||||
|
|
||||||
|
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r["config_id"] if r["config_id"] else None, user_id=u["id"])
|
||||||
|
|
||||||
|
# Load registered tables for validation
|
||||||
|
with db() as c:
|
||||||
|
registered = {t["table_name"].lower() for t in
|
||||||
|
c.execute("SELECT table_name FROM adb_vector_tables WHERE adb_config_id=? AND is_active=1", (vid,)).fetchall()}
|
||||||
|
|
||||||
|
# Scan all CSVs and map to tables
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
table_docs: dict[str, list] = {}
|
||||||
|
missing_tables: list[str] = []
|
||||||
|
skipped_tables: list[str] = []
|
||||||
|
|
||||||
|
for csv_file in sorted(rdir.glob("cis_*.csv")):
|
||||||
|
table = _resolve_table_for_csv(csv_file.name)
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
if table.lower() not in registered:
|
||||||
|
if table not in skipped_tables:
|
||||||
|
skipped_tables.append(table)
|
||||||
|
continue
|
||||||
|
if csv_file.name == "cis_summary_report.csv":
|
||||||
|
docs = _chunk_summary_csv(str(csv_file), tenancy, extract_date)
|
||||||
|
else:
|
||||||
|
docs = _chunk_findings_csv(str(csv_file), tenancy, extract_date)
|
||||||
|
if docs:
|
||||||
|
table_docs.setdefault(table, []).extend(docs)
|
||||||
|
|
||||||
|
if not table_docs:
|
||||||
|
if skipped_tables:
|
||||||
|
raise HTTPException(400, f"Tabela(s) nao registrada(s) no ADB: {', '.join(skipped_tables)}. Registre em Configuracoes > ADB Vector.")
|
||||||
|
raise HTTPException(400, "No CSV files found to embed")
|
||||||
|
|
||||||
|
# Calculate totals for status tracking
|
||||||
|
total_docs = sum(len(d) for d in table_docs.values())
|
||||||
|
tables_used = list(table_docs.keys())
|
||||||
|
set_embed_status(task_id, {
|
||||||
|
"status": "running", "table": ", ".join(tables_used), "tenancy": tenancy,
|
||||||
|
"inserted": 0, "total": total_docs, "user_id": u["id"],
|
||||||
|
"message": f"Embedding {total_docs} documentos em {len(tables_used)} tabelas..."
|
||||||
|
})
|
||||||
|
|
||||||
|
# Build queue info: [(table, doc_count), ...]
|
||||||
|
table_queue = [(tbl, len(docs)) for tbl, docs in table_docs.items()]
|
||||||
|
|
||||||
|
def _bg_embed_all():
|
||||||
|
"""Background: embed documents into their respective tables sequentially."""
|
||||||
|
import array
|
||||||
|
default_model = cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||||||
|
inserted_total = 0
|
||||||
|
skipped_total = 0
|
||||||
|
processed_total = 0
|
||||||
|
errors = []
|
||||||
|
for tbl_idx, (tbl, docs) in enumerate(table_docs.items()):
|
||||||
|
remaining = table_queue[tbl_idx + 1:] if tbl_idx + 1 < len(table_queue) else []
|
||||||
|
_auto_register_table(cfg["id"], tbl)
|
||||||
|
purged = _purge_table_by_tenancy(cfg, tbl, tenancy, extract_date)
|
||||||
|
if purged:
|
||||||
|
update_embed_status(task_id, {"message": f"Purged {purged} old docs from {tbl}...",
|
||||||
|
"current_table": tbl, "current_inserted": 0, "current_total": len(docs),
|
||||||
|
"queue": [{"table": t, "docs": n} for t, n in remaining]})
|
||||||
|
emb_model = default_model
|
||||||
|
try:
|
||||||
|
actual_dim = _get_table_embedding_dim(cfg, tbl)
|
||||||
|
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
||||||
|
emb_model = _DIM_TO_MODEL[actual_dim]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
current_inserted = 0
|
||||||
|
current_skipped = 0
|
||||||
|
try:
|
||||||
|
conn = _get_adb_connection(cfg)
|
||||||
|
cur = conn.cursor()
|
||||||
|
for doc in docs:
|
||||||
|
try:
|
||||||
|
content = doc.get("content", "")
|
||||||
|
if not content:
|
||||||
|
skipped_total += 1
|
||||||
|
current_skipped += 1
|
||||||
|
processed_total += 1
|
||||||
|
continue
|
||||||
|
embedding = _embed_text(content, gc, emb_model)
|
||||||
|
vec = array.array('f', [float(x) for x in embedding])
|
||||||
|
metadata = _build_metadata_json(
|
||||||
|
tenancy=doc.get("tenancy", tenancy),
|
||||||
|
section=doc.get("section", ""),
|
||||||
|
report_date=extract_date,
|
||||||
|
user_id=u["id"],
|
||||||
|
)
|
||||||
|
cur.execute(f'INSERT INTO "{tbl}" (ID, TEXT, EMBEDDING, METADATA) VALUES (HEXTORAW(:1), :2, :3, :4)',
|
||||||
|
[uuid.uuid4().hex.upper(), content, vec, metadata])
|
||||||
|
inserted_total += 1
|
||||||
|
current_inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
err_str = str(e)
|
||||||
|
if "message" in err_str:
|
||||||
|
import re as _re
|
||||||
|
m = _re.search(r'"message"\s*:\s*"(.*?)"', err_str)
|
||||||
|
log.warning(f"Embed skip in {tbl}: {m.group(1)[:200] if m else err_str[:200]}")
|
||||||
|
else:
|
||||||
|
log.warning(f"Embed skip in {tbl}: {err_str[:200]}")
|
||||||
|
skipped_total += 1
|
||||||
|
current_skipped += 1
|
||||||
|
processed_total += 1
|
||||||
|
update_embed_status(task_id, {
|
||||||
|
"inserted": inserted_total, "skipped": skipped_total, "processed": processed_total,
|
||||||
|
"current_table": tbl, "current_inserted": current_inserted, "current_skipped": current_skipped,
|
||||||
|
"current_total": len(docs),
|
||||||
|
"queue": [{"table": t, "docs": n} for t, n in remaining],
|
||||||
|
"message": f"{tbl}: {current_inserted}/{len(docs)} — global: {inserted_total}/{total_docs}"
|
||||||
|
})
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
log.info(f"Embedded {current_inserted}/{len(docs)} docs into {tbl} (tenancy={tenancy})")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to connect/embed to {tbl}: {e}")
|
||||||
|
errors.append(f"{tbl}: {str(e)[:100]}")
|
||||||
|
|
||||||
|
msg = f"{inserted_total}/{total_docs} documentos em {len(tables_used)} tabelas ({', '.join(tables_used)})"
|
||||||
|
if tenancy: msg += f" — tenancy: {tenancy}"
|
||||||
|
if errors: msg += f" | Erros: {'; '.join(errors)}"
|
||||||
|
update_embed_status(task_id, {
|
||||||
|
"status": "done" if not errors else "done",
|
||||||
|
"inserted": inserted_total, "message": msg
|
||||||
|
})
|
||||||
|
_audit(u["id"], u["username"], "embed_report_auto", rid, msg)
|
||||||
|
_config_log("adb", cfg["id"], cfg.get("config_name"),
|
||||||
|
"success" if not errors else "error", "ingest", msg, u["id"], u["username"])
|
||||||
|
|
||||||
|
_chat_executor.submit(_bg_embed_all)
|
||||||
|
msg = f"Embedding iniciado — {total_docs} documentos em {len(tables_used)} tabelas ({', '.join(tables_used)})"
|
||||||
|
if skipped_tables:
|
||||||
|
msg += f". Ignoradas (nao registradas): {', '.join(skipped_tables)}"
|
||||||
|
return {
|
||||||
|
"ok": True, "task_id": task_id, "message": msg,
|
||||||
|
"tables": tables_used, "skipped": skipped_tables, "total_documents": total_docs, "tenancy": tenancy
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/embeddings/status/{task_id}")
|
||||||
|
async def embedding_status(task_id: str, u=Depends(current_user)):
|
||||||
|
"""Check embedding task progress."""
|
||||||
|
st = get_embed_status(task_id)
|
||||||
|
if not st:
|
||||||
|
return {"status": "unknown", "message": "Task not found"}
|
||||||
|
if st.get("user_id") and st["user_id"] != u["id"] and u["role"] != "admin":
|
||||||
|
return {"status": "unknown", "message": "Task not found"}
|
||||||
|
return {k: v for k, v in st.items() if k != "user_id"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/embeddings/report/{rid}/section")
|
||||||
|
async def embed_report_section(rid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||||
|
"""Embed all CSV files from a specific report section into the mapped ADB table."""
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
import csv as csvmod
|
||||||
|
vid = req.get("adb_config_id")
|
||||||
|
file_names: list = req.get("file_names", [])
|
||||||
|
if not vid:
|
||||||
|
# Auto-detect first active ADB
|
||||||
|
with db() as c:
|
||||||
|
adb = c.execute("SELECT id FROM adb_vector_configs WHERE is_active=1 LIMIT 1").fetchone()
|
||||||
|
if not adb: raise HTTPException(400, "No active ADB config found")
|
||||||
|
vid = adb["id"]
|
||||||
|
if not file_names: raise HTTPException(400, "file_names is required")
|
||||||
|
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT tenancy_name, config_id FROM reports WHERE id=? AND status='completed'", (rid,)).fetchone()
|
||||||
|
if not r: raise HTTPException(404)
|
||||||
|
rdir = REPORTS / rid
|
||||||
|
tenancy = r["tenancy_name"] or "unknown"
|
||||||
|
|
||||||
|
# Read extract_date
|
||||||
|
summary = rdir / "cis_summary_report.csv"
|
||||||
|
extract_date = ""
|
||||||
|
if summary.exists():
|
||||||
|
with open(summary, "r", encoding="utf-8") as f:
|
||||||
|
first = next(csvmod.DictReader(f), {})
|
||||||
|
extract_date = first.get("extract_date", "")
|
||||||
|
|
||||||
|
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r["config_id"] if r["config_id"] else None, user_id=u["id"])
|
||||||
|
|
||||||
|
# Load registered tables for validation
|
||||||
|
with db() as c:
|
||||||
|
registered = {t["table_name"].lower() for t in
|
||||||
|
c.execute("SELECT table_name FROM adb_vector_tables WHERE adb_config_id=? AND is_active=1", (vid,)).fetchall()}
|
||||||
|
|
||||||
|
# Group files by target table and validate
|
||||||
|
import array
|
||||||
|
table_docs: dict[str, list] = {}
|
||||||
|
missing_tables: list[str] = []
|
||||||
|
for fname in file_names:
|
||||||
|
csv_path = rdir / fname
|
||||||
|
if not csv_path.exists(): continue
|
||||||
|
table = _resolve_table_for_csv(fname)
|
||||||
|
if not table: continue
|
||||||
|
if table.lower() not in registered:
|
||||||
|
if table not in missing_tables:
|
||||||
|
missing_tables.append(table)
|
||||||
|
continue
|
||||||
|
if fname == "cis_summary_report.csv":
|
||||||
|
docs = _chunk_summary_csv(str(csv_path), tenancy, extract_date)
|
||||||
|
else:
|
||||||
|
docs = _chunk_findings_csv(str(csv_path), tenancy, extract_date)
|
||||||
|
if docs:
|
||||||
|
table_docs.setdefault(table, []).extend(docs)
|
||||||
|
|
||||||
|
if missing_tables and not table_docs:
|
||||||
|
raise HTTPException(400, f"Tabela(s) nao registrada(s) no ADB: {', '.join(missing_tables)}. Registre em Configuracoes > ADB Vector.")
|
||||||
|
if not table_docs:
|
||||||
|
raise HTTPException(400, "No embeddable content found in the selected files")
|
||||||
|
|
||||||
|
total_docs = sum(len(d) for d in table_docs.values())
|
||||||
|
tables_used = list(table_docs.keys())
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
set_embed_status(task_id, {
|
||||||
|
"status": "running", "table": ", ".join(tables_used), "tenancy": tenancy,
|
||||||
|
"inserted": 0, "total": total_docs, "user_id": u["id"], "message": f"Embedding {total_docs} docs em {', '.join(tables_used)}..."
|
||||||
|
})
|
||||||
|
|
||||||
|
def _bg():
|
||||||
|
default_model = cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
processed = 0
|
||||||
|
for tbl, docs in table_docs.items():
|
||||||
|
purged = _purge_table_by_tenancy(cfg, tbl, tenancy, extract_date)
|
||||||
|
if purged:
|
||||||
|
update_embed_status(task_id, {"message": f"Purged {purged} old docs from {tbl}..."})
|
||||||
|
_auto_register_table(cfg["id"], tbl)
|
||||||
|
emb_model = default_model
|
||||||
|
try:
|
||||||
|
actual_dim = _get_table_embedding_dim(cfg, tbl)
|
||||||
|
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
||||||
|
emb_model = _DIM_TO_MODEL[actual_dim]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
conn = _get_adb_connection(cfg)
|
||||||
|
cur = conn.cursor()
|
||||||
|
for doc in docs:
|
||||||
|
try:
|
||||||
|
content = doc.get("content", "")
|
||||||
|
if not content:
|
||||||
|
skipped += 1
|
||||||
|
processed += 1
|
||||||
|
continue
|
||||||
|
embedding = _embed_text(content, gc, emb_model)
|
||||||
|
vec = array.array('f', [float(x) for x in embedding])
|
||||||
|
metadata = _build_metadata_json(tenancy=doc.get("tenancy", tenancy), section=doc.get("section", ""), report_date=extract_date, user_id=u["id"])
|
||||||
|
cur.execute(f'INSERT INTO "{tbl}" (ID, TEXT, EMBEDDING, METADATA) VALUES (HEXTORAW(:1), :2, :3, :4)',
|
||||||
|
[uuid.uuid4().hex.upper(), content, vec, metadata])
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
skipped += 1
|
||||||
|
err_str = str(e)
|
||||||
|
if "message" in err_str:
|
||||||
|
import re as _re
|
||||||
|
m = _re.search(r'"message"\s*:\s*"(.*?)"', err_str)
|
||||||
|
log.warning(f"Embed skip in {tbl}: {m.group(1)[:200] if m else err_str[:200]}")
|
||||||
|
else:
|
||||||
|
log.warning(f"Embed skip in {tbl}: {err_str[:200]}")
|
||||||
|
processed += 1
|
||||||
|
update_embed_status(task_id, {
|
||||||
|
"inserted": inserted, "skipped": skipped, "processed": processed, "total": total_docs,
|
||||||
|
"message": f"{tbl}: {inserted} OK, {skipped} falhas ({processed}/{total_docs})"
|
||||||
|
})
|
||||||
|
conn.commit(); cur.close(); conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Embed connection error {tbl}: {e}")
|
||||||
|
msg = f"{inserted}/{total_docs} embeddings OK"
|
||||||
|
if skipped: msg += f", {skipped} falhas"
|
||||||
|
msg += f" em {', '.join(tables_used)} (tenancy: {tenancy})"
|
||||||
|
update_embed_status(task_id, {"status": "done", "inserted": inserted, "skipped": skipped, "processed": processed, "message": msg})
|
||||||
|
_audit(u["id"], u["username"], "embed_section", rid, msg)
|
||||||
|
|
||||||
|
_chat_executor.submit(_bg)
|
||||||
|
return {"ok": True, "task_id": task_id, "tables": tables_used, "total": total_docs,
|
||||||
|
"message": f"Embedding de {total_docs} docs iniciado ({', '.join(tables_used)})"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/embeddings/report/{rid}/file/{fid}")
|
||||||
|
async def embed_report_file(rid: str, fid: str, req: dict, bg: BackgroundTasks, u=Depends(require("admin","user"))):
|
||||||
|
"""Embed a specific report file (CSV, JSON, TXT, etc.) into ADB vector store."""
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
vid = req.get("adb_config_id")
|
||||||
|
if not vid: raise HTTPException(400, "adb_config_id is required")
|
||||||
|
_verify_config_access("adb", vid, u)
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT 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")
|
||||||
|
f = c.execute("SELECT * FROM report_files WHERE id=? AND report_id=?", (fid, rid)).fetchone()
|
||||||
|
if not f: raise HTTPException(404, "File not found")
|
||||||
|
p = Path(f["file_path"])
|
||||||
|
if not p.exists(): raise HTTPException(404, "File not found on disk")
|
||||||
|
fname = f["file_name"].lower()
|
||||||
|
allowed = ('.txt', '.csv', '.json', '.md', '.pdf')
|
||||||
|
if not any(fname.endswith(ext) for ext in allowed):
|
||||||
|
raise HTTPException(400, f"Formatos aceitos para embedding: {', '.join(allowed)}")
|
||||||
|
raw = p.read_bytes()
|
||||||
|
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, f["file_name"])
|
||||||
|
if not documents: raise HTTPException(400, "No content chunks found")
|
||||||
|
cfg, gc = _get_adb_and_genai(vid, oci_config_id=r["config_id"] if r["config_id"] else None, user_id=u["id"])
|
||||||
|
target_table = req.get("table_name") or None
|
||||||
|
tenancy = r["tenancy_name"] or "unknown"
|
||||||
|
task_id = str(uuid.uuid4())
|
||||||
|
bg.add_task(_ingest_documents_task, cfg, gc, documents, u["id"], u["username"],
|
||||||
|
table_name=target_table, tenancy=tenancy, task_id=task_id)
|
||||||
|
_audit(u["id"], u["username"], "embed_report_file", f"{rid}/{fid}", f"{f['file_name']}, {len(documents)} chunks, tenancy={tenancy}")
|
||||||
|
return {"ok": True, "task_id": task_id, "message": f"Embedding de {f['file_name']} iniciado ({len(documents)} chunks)", "chunks": len(documents)}
|
||||||
|
|
||||||
|
@router.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"))):
|
||||||
|
_verify_config_access("adb", adb_config_id, u)
|
||||||
|
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)}")
|
||||||
|
await validate_upload(file, allowed)
|
||||||
|
file.file.seek(0)
|
||||||
|
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")
|
||||||
|
target_table = table_name.strip() or None
|
||||||
|
# Use CIS-specific chunking for cisrecom table (segments by recommendation number with overlap)
|
||||||
|
if target_table and 'cisrecom' in target_table.lower():
|
||||||
|
documents = _chunk_cis_pdf(content, file.filename)
|
||||||
|
if not documents:
|
||||||
|
documents = _chunk_text_file(content, file.filename) # fallback
|
||||||
|
else:
|
||||||
|
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, user_id=u["id"])
|
||||||
|
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}
|
||||||
|
|
||||||
|
@router.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"))
|
||||||
|
):
|
||||||
|
_verify_config_access("adb", adb_config_id, u)
|
||||||
|
import requests as req
|
||||||
|
url = url.strip()
|
||||||
|
if not url.startswith(("http://", "https://")):
|
||||||
|
raise HTTPException(400, "URL invalida — deve comecar com http:// ou https://")
|
||||||
|
try:
|
||||||
|
resp = req.get(url, timeout=30, headers={"User-Agent": "Mozilla/5.0 AI-Agent/1.0"})
|
||||||
|
resp.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(400, f"Erro ao acessar URL: {str(e)[:300]}")
|
||||||
|
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 conteudo extraido da URL")
|
||||||
|
documents = _chunk_text_file(content, url)
|
||||||
|
if not documents:
|
||||||
|
raise HTTPException(400, "Nenhum chunk gerado do conteudo")
|
||||||
|
cfg, gc = _get_adb_and_genai(adb_config_id, user_id=u["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}
|
||||||
|
|
||||||
|
@router.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 nao pode ser vazia")
|
||||||
|
adb_configs = _get_active_adb_configs(u["id"])
|
||||||
|
if not adb_configs:
|
||||||
|
raise HTTPException(400, "Nenhuma conexao ADB ativa configurada")
|
||||||
|
# Resolve tenancy for filtered search
|
||||||
|
rag_tenancy = None
|
||||||
|
if req.oci_config_id:
|
||||||
|
with db() as c:
|
||||||
|
oci_row = c.execute("SELECT tenancy_name FROM oci_configs WHERE id=?", (req.oci_config_id,)).fetchone()
|
||||||
|
if oci_row:
|
||||||
|
rag_tenancy = oci_row["tenancy_name"]
|
||||||
|
log.info(f"Consult: filtering by tenancy '{rag_tenancy}'")
|
||||||
|
|
||||||
|
# Detect CIS recommendation number in query for exact text filtering
|
||||||
|
import re as _re
|
||||||
|
cis_match = _re.search(r'(?:cis|recommendation)\s*(\d+\.\d+)', req.query, _re.IGNORECASE)
|
||||||
|
cis_text_filter = f"CIS Recommendation: {cis_match.group(1)}" if cis_match else None
|
||||||
|
if cis_text_filter:
|
||||||
|
log.info(f"Consult: detected CIS filter '{cis_text_filter}'")
|
||||||
|
|
||||||
|
# Collect results from all active ADB configs + tables
|
||||||
|
all_docs = []
|
||||||
|
rag_errors = []
|
||||||
|
for adb_cfg in adb_configs:
|
||||||
|
try:
|
||||||
|
emb_genai = _resolve_embed_config(oci_config_id=adb_cfg.get("oci_config_id"), user_id=u["id"])
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Consult: resolve config failed for {adb_cfg['id']}: {e}")
|
||||||
|
continue
|
||||||
|
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||||||
|
if req.table_name:
|
||||||
|
tables = [t for t in tables if t["table_name"] == req.table_name]
|
||||||
|
all_table_names = [t["table_name"] for t in tables if t["table_name"]]
|
||||||
|
# Smart skip
|
||||||
|
relevant = _relevant_tables(req.query, all_table_names) if not req.table_name else all_table_names
|
||||||
|
skipped = set(all_table_names) - set(relevant)
|
||||||
|
if skipped:
|
||||||
|
log.info(f"Consult: skipped {', '.join(skipped)}")
|
||||||
|
# Auto-detect model
|
||||||
|
emb_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||||||
|
for tbl_name in relevant:
|
||||||
|
try:
|
||||||
|
dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
||||||
|
if dim and dim in _DIM_TO_MODEL:
|
||||||
|
emb_model = _DIM_TO_MODEL[dim]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
query_embedding = _embed_text(req.query, emb_genai, emb_model)
|
||||||
|
tbl_top_k = 10 if cis_text_filter else 3
|
||||||
|
docs = _vector_search_multi(adb_cfg, query_embedding, relevant,
|
||||||
|
top_k_per_table=tbl_top_k, tenancy=rag_tenancy,
|
||||||
|
text_filter=cis_text_filter, user_id=u["id"])
|
||||||
|
all_docs.extend(docs)
|
||||||
|
if docs:
|
||||||
|
sources = {}
|
||||||
|
for d in docs:
|
||||||
|
sources[d["source"]] = sources.get(d["source"], 0) + 1
|
||||||
|
log.info(f"Consult: {len(docs)} docs — {', '.join(f'{k}:{v}' for k,v in sources.items())}")
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e)[:150]
|
||||||
|
log.warning(f"Consult: search failed: {err}")
|
||||||
|
if "DPY-6001" in str(e) or "DPY-6005" in str(e) or "timeout" in str(e).lower():
|
||||||
|
rag_errors.append(f"ADB offline ou timeout ({adb_cfg.get('config_name','?')})")
|
||||||
|
if not all_docs:
|
||||||
|
if rag_errors:
|
||||||
|
return {"answer": "\u26a0\ufe0f " + "; ".join(set(rag_errors)) + ". A base de conhecimento nao esta disponivel no momento.", "documents": [], "total": 0}
|
||||||
|
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_limit = 15 if cis_text_filter else 8
|
||||||
|
top_docs = all_docs[:top_limit]
|
||||||
|
# Build context with dates and sources
|
||||||
|
rag_context = _build_rag_context(top_docs, max_total_chars=16000 if cis_text_filter else 12000)
|
||||||
|
if rag_errors:
|
||||||
|
rag_context += "\n\n\u26a0\ufe0f Algumas bases nao puderam ser consultadas: " + "; ".join(set(rag_errors))
|
||||||
|
augmented = RAG_CONTEXT_TEMPLATE.format(context=rag_context, question=req.query)
|
||||||
|
# Get GenAI config for answering — try saved config first, then auto-resolve from OCI
|
||||||
|
gc = None
|
||||||
|
with db() as c:
|
||||||
|
gc_row = c.execute("SELECT * FROM genai_configs WHERE user_id=? AND is_default=1 ORDER BY created_at DESC", (u["id"],)).fetchone()
|
||||||
|
if not gc_row:
|
||||||
|
gc_row = c.execute("SELECT * FROM genai_configs WHERE user_id=? ORDER BY created_at DESC", (u["id"],)).fetchone()
|
||||||
|
if gc_row:
|
||||||
|
gc = dict(gc_row)
|
||||||
|
else:
|
||||||
|
# Auto-resolve: build GenAI config from OCI credentials + default model
|
||||||
|
try:
|
||||||
|
resolved = _resolve_embed_config(user_id=u["id"])
|
||||||
|
gc = {
|
||||||
|
"oci_config_id": resolved["oci_config_id"],
|
||||||
|
"endpoint": resolved.get("endpoint", f"https://inference.generativeai.{resolved.get('genai_region','us-ashburn-1')}.oci.oraclecloud.com"),
|
||||||
|
"compartment_id": resolved.get("compartment_id", ""),
|
||||||
|
"genai_region": resolved.get("genai_region", "us-ashburn-1"),
|
||||||
|
"model_id": "openai.gpt-5.2",
|
||||||
|
"model_ocid": "",
|
||||||
|
"serving_type": "ON_DEMAND",
|
||||||
|
"temperature": 0.3,
|
||||||
|
"max_tokens": 8000,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"top_k": 1,
|
||||||
|
"frequency_penalty": 0,
|
||||||
|
"presence_penalty": 0,
|
||||||
|
}
|
||||||
|
log.info(f"Consult: auto-resolved GenAI config from OCI, model=openai.gpt-4.1")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Consult: no GenAI config available: {e}")
|
||||||
|
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]
|
||||||
|
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', '?')}` (distancia: {d.get('distance', 0):.4f})\n\n{content}")
|
||||||
|
return {"answer": "\n\n---\n\n".join(parts), "documents": doc_list, "total": len(all_docs)}
|
||||||
|
try:
|
||||||
|
gc["system_prompt"] = CONSULT_SYSTEM_PROMPT
|
||||||
|
answer, _, _ = _call_genai(gc, augmented)
|
||||||
|
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)}
|
||||||
|
|
||||||
|
@router.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)):
|
||||||
|
_verify_config_access("adb", vid, u)
|
||||||
|
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]}")
|
||||||
|
|
||||||
|
@router.delete("/api/embeddings/{vid}/{doc_id}")
|
||||||
|
async def delete_embedding(vid: str, doc_id: str, table_name: str = Query(""), u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("adb", vid, u, require_owner=True)
|
||||||
|
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]}")
|
||||||
|
|
||||||
|
@router.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."""
|
||||||
|
_verify_config_access("adb", vid, u, require_owner=True)
|
||||||
|
table_name = req.get("table_name", "").strip()
|
||||||
|
tenancy = req.get("tenancy", "").strip()
|
||||||
|
if not table_name: raise HTTPException(400, "table_name e obrigatorio")
|
||||||
|
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]}")
|
||||||
91
backend/routes/genai.py
Normal file
91
backend/routes/genai.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""GenAI configuration routes — model catalog, CRUD, test."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access
|
||||||
|
from config import GENAI_MODELS, GENAI_REGIONS, EMBEDDING_MODELS, OCI_REGIONS
|
||||||
|
from models import GenAIConfigReq
|
||||||
|
from services.genai import _call_genai
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/genai/models")
|
||||||
|
async def list_genai_models(u=Depends(current_user)):
|
||||||
|
return JSONResponse(
|
||||||
|
content={"models": GENAI_MODELS, "regions": GENAI_REGIONS, "embedding_models": EMBEDDING_MODELS, "oci_regions": OCI_REGIONS},
|
||||||
|
headers={"Cache-Control": "max-age=3600"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.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,reasoning_effort,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, req.reasoning_effort, 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}
|
||||||
|
|
||||||
|
@router.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]
|
||||||
|
|
||||||
|
@router.delete("/api/genai/configs/{gid}")
|
||||||
|
async def del_genai(gid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("genai", gid, u, require_owner=True)
|
||||||
|
with db() as c: c.execute("DELETE FROM genai_configs WHERE id=?", (gid,))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.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=?,reasoning_effort=?,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, req.reasoning_effort, 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}
|
||||||
|
|
||||||
|
@router.post("/api/genai/test/{gid}")
|
||||||
|
async def test_genai(gid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("genai", gid, u)
|
||||||
|
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}
|
||||||
139
backend/routes/mcp.py
Normal file
139
backend/routes/mcp.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
"""MCP server routes — CRUD, toggle, upload, link-adb, discover-tools, update tools."""
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Query
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access
|
||||||
|
from config import MCP_DIR, log
|
||||||
|
from models import MCPServerReq
|
||||||
|
from utils import validate_upload
|
||||||
|
from services.mcp_client import _discover_mcp_tools
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/mcp/servers")
|
||||||
|
async def register_mcp(req: MCPServerReq, u=Depends(require("admin","user"))):
|
||||||
|
mid = str(uuid.uuid4())
|
||||||
|
is_global_val = 1 if (req.is_global and u["role"] == "admin") else 0
|
||||||
|
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,is_global) 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, is_global_val))
|
||||||
|
_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}
|
||||||
|
|
||||||
|
@router.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=? OR is_global=1 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 Exception as e: log.warning(f"Failed to parse MCP server field '{k}': {e}")
|
||||||
|
res.append(d)
|
||||||
|
return res
|
||||||
|
|
||||||
|
@router.delete("/api/mcp/servers/{mid}")
|
||||||
|
async def del_mcp(mid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("mcp", mid, u, require_owner=True)
|
||||||
|
with db() as c: c.execute("DELETE FROM mcp_servers WHERE id=?", (mid,))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.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)
|
||||||
|
is_global_val = 1 if (req.is_global and u["role"] == "admin") else (existing["is_global"] if u["role"] != "admin" else (1 if req.is_global else 0))
|
||||||
|
with db() as c:
|
||||||
|
c.execute(
|
||||||
|
"UPDATE mcp_servers SET name=?,description=?,server_type=?,command=?,args=?,env_vars=?,url=?,tools=?,linked_adb_id=?,is_global=? 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, is_global_val, 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}
|
||||||
|
|
||||||
|
@router.put("/api/mcp/servers/{mid}/toggle")
|
||||||
|
async def toggle_mcp(mid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("mcp", mid, u, require_owner=True)
|
||||||
|
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"]}
|
||||||
|
|
||||||
|
@router.post("/api/mcp/servers/{mid}/upload")
|
||||||
|
async def upload_mcp_file(mid: str, file: UploadFile = File(...), u=Depends(require("admin","user"))):
|
||||||
|
await validate_upload(file, {".py", ".js", ".ts", ".json", ".yaml", ".yml", ".toml", ".sh"})
|
||||||
|
file.file.seek(0)
|
||||||
|
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)}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.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 Exception as e: log.warning(f"Failed to parse existing MCP tools JSON: {e}")
|
||||||
|
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}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
503
backend/routes/oci_config.py
Normal file
503
backend/routes/oci_config.py
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
"""OCI config routes — CRUD, test, OCI CLI terminal (execute, history, completions)."""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter, HTTPException, Depends, UploadFile, File, Form, Query,
|
||||||
|
)
|
||||||
|
|
||||||
|
from config import OCI_DIR, _chat_executor, log
|
||||||
|
from database import db
|
||||||
|
from auth.crypto import _enc, _dec, _mask, _safe_dec
|
||||||
|
from auth.jwt_auth import current_user, require, _audit, _config_log, _verify_config_access
|
||||||
|
from utils import validate_upload
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ── OCI Config ────────────────────────────────────────────────────────────────
|
||||||
|
@router.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(""),
|
||||||
|
ssh_public_key: str = Form(""),
|
||||||
|
auth_type: str = Form("api_key"),
|
||||||
|
security_token: str = Form(""),
|
||||||
|
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
||||||
|
u = Depends(require("admin","user"))
|
||||||
|
):
|
||||||
|
if auth_type not in ("api_key", "session_token"):
|
||||||
|
raise HTTPException(400, "auth_type deve ser 'api_key' ou 'session_token'")
|
||||||
|
_PEM_EXTS = {".pem", ".key"}
|
||||||
|
if private_key and private_key.filename:
|
||||||
|
await validate_upload(private_key, _PEM_EXTS)
|
||||||
|
private_key.file.seek(0)
|
||||||
|
if public_key and public_key.filename:
|
||||||
|
await validate_upload(public_key, _PEM_EXTS)
|
||||||
|
public_key.file.seek(0)
|
||||||
|
cid = str(uuid.uuid4()); cdir = OCI_DIR / cid; cdir.mkdir(parents=True)
|
||||||
|
kp = cdir / "oci_api_key.pem"
|
||||||
|
|
||||||
|
if auth_type == "session_token":
|
||||||
|
# Session token auth: requires tenancy_ocid, region, token, and session key
|
||||||
|
if not security_token.strip():
|
||||||
|
shutil.rmtree(cdir, ignore_errors=True)
|
||||||
|
raise HTTPException(400, "Session Token é obrigatório para autenticação por token.")
|
||||||
|
if not private_key or not private_key.filename:
|
||||||
|
shutil.rmtree(cdir, ignore_errors=True)
|
||||||
|
raise HTTPException(400, "A chave de sessão (.pem) é obrigatória para autenticação por token.")
|
||||||
|
key_bytes = await private_key.read()
|
||||||
|
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||||||
|
# Save security token file
|
||||||
|
token_file = cdir / "token"
|
||||||
|
token_file.write_text(security_token.strip())
|
||||||
|
token_file.chmod(0o600)
|
||||||
|
# Generate config
|
||||||
|
cfg_content = (f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\n"
|
||||||
|
f"key_file={kp}\nsecurity_token_file={token_file}\n")
|
||||||
|
if user_ocid:
|
||||||
|
cfg_content = f"[DEFAULT]\nuser={user_ocid}\n" + cfg_content.replace("[DEFAULT]\n", "")
|
||||||
|
cfg_file = cdir / "config"
|
||||||
|
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||||||
|
# For session_token, fingerprint/user may not be provided — use placeholders
|
||||||
|
user_ocid = user_ocid or "session_token_user"
|
||||||
|
fingerprint = fingerprint or "session_token"
|
||||||
|
else:
|
||||||
|
# API Key auth (original flow)
|
||||||
|
if not user_ocid or not fingerprint:
|
||||||
|
shutil.rmtree(cdir, ignore_errors=True)
|
||||||
|
raise HTTPException(400, "OCID User e Fingerprint são obrigatórios para API Key.")
|
||||||
|
if not private_key or not private_key.filename:
|
||||||
|
shutil.rmtree(cdir, ignore_errors=True)
|
||||||
|
raise HTTPException(400, "Selecione a chave privada (.pem).")
|
||||||
|
key_bytes = await private_key.read()
|
||||||
|
kp.write_bytes(key_bytes); kp.chmod(0o600)
|
||||||
|
key_text = key_bytes.decode("utf-8", errors="ignore")
|
||||||
|
key_is_encrypted = "ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text
|
||||||
|
if key_is_encrypted and not key_passphrase:
|
||||||
|
shutil.rmtree(cdir, ignore_errors=True)
|
||||||
|
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
||||||
|
if public_key and public_key.filename:
|
||||||
|
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
||||||
|
cfg_file = cdir / "config"
|
||||||
|
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||||||
|
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||||||
|
if key_passphrase:
|
||||||
|
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||||||
|
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||||||
|
|
||||||
|
with db() as c:
|
||||||
|
c.execute("INSERT INTO oci_configs (id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,key_file_path,compartment_id,ssh_public_key,auth_type) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(cid, u["id"], tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, str(kp), _enc(compartment_id) if compartment_id else None, ssh_public_key.strip() if ssh_public_key.strip() else None, auth_type))
|
||||||
|
_audit(u["id"], u["username"], "save_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
|
||||||
|
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial salva: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
|
||||||
|
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
||||||
|
|
||||||
|
@router.get("/api/oci/configs")
|
||||||
|
async def list_oci(u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
cols = "id,user_id,tenancy_name,tenancy_ocid,user_ocid,fingerprint,region,compartment_id,ssh_public_key,auth_type,created_at"
|
||||||
|
if u["role"]=="admin": rows=c.execute(f"SELECT {cols} FROM oci_configs").fetchall()
|
||||||
|
else: rows=c.execute(f"SELECT {cols} FROM oci_configs WHERE user_id=?",(u["id"],)).fetchall()
|
||||||
|
result = []
|
||||||
|
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"] = "\u2022" * 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"])
|
||||||
|
if d.get("ssh_public_key"):
|
||||||
|
# Show just type + first/last few chars for identification
|
||||||
|
parts = d["ssh_public_key"].split()
|
||||||
|
d["ssh_public_key_preview"] = f"{parts[0]} ...{parts[1][-12:]}" if len(parts) >= 2 else "ssh-rsa ..."
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.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(""),
|
||||||
|
ssh_public_key: str = Form(""),
|
||||||
|
auth_type: str = Form(""),
|
||||||
|
security_token: str = Form(""),
|
||||||
|
private_key: Optional[UploadFile] = File(None), public_key: Optional[UploadFile] = File(None),
|
||||||
|
u = Depends(require("admin","user"))
|
||||||
|
):
|
||||||
|
_PEM_EXTS = {".pem", ".key"}
|
||||||
|
if private_key and private_key.filename:
|
||||||
|
await validate_upload(private_key, _PEM_EXTS)
|
||||||
|
private_key.file.seek(0)
|
||||||
|
if public_key and public_key.filename:
|
||||||
|
await validate_upload(public_key, _PEM_EXTS)
|
||||||
|
public_key.file.seek(0)
|
||||||
|
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 ""
|
||||||
|
try:
|
||||||
|
existing_auth = existing["auth_type"] or "api_key"
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
existing_auth = "api_key"
|
||||||
|
auth_type = auth_type or existing_auth
|
||||||
|
try:
|
||||||
|
existing_ssh = existing["ssh_public_key"] or ""
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
existing_ssh = ""
|
||||||
|
ssh_public_key = ssh_public_key.strip() if ssh_public_key.strip() else existing_ssh
|
||||||
|
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 auth_type == "api_key" and ("ENCRYPTED" in key_text or "Proc-Type: 4,ENCRYPTED" in key_text) and not key_passphrase:
|
||||||
|
raise HTTPException(400, "A chave privada está criptografada (ENCRYPTED). Informe a Key Passphrase.")
|
||||||
|
if public_key and public_key.filename:
|
||||||
|
(cdir / "oci_api_key_public.pem").write_bytes(await public_key.read())
|
||||||
|
# Update session token file if provided
|
||||||
|
if security_token.strip():
|
||||||
|
token_file = cdir / "token"
|
||||||
|
token_file.write_text(security_token.strip()); token_file.chmod(0o600)
|
||||||
|
cfg_file = cdir / "config"
|
||||||
|
if auth_type == "session_token":
|
||||||
|
token_path = cdir / "token"
|
||||||
|
cfg_content = f"[DEFAULT]\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
|
||||||
|
if user_ocid and user_ocid != "session_token_user":
|
||||||
|
cfg_content = f"[DEFAULT]\nuser={user_ocid}\ntenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n"
|
||||||
|
cfg_content += f"security_token_file={token_path}\n"
|
||||||
|
else:
|
||||||
|
cfg_content = (f"[DEFAULT]\nuser={user_ocid}\nfingerprint={fingerprint}\n"
|
||||||
|
f"tenancy={tenancy_ocid}\nregion={region}\nkey_file={kp}\n")
|
||||||
|
if key_passphrase:
|
||||||
|
cfg_content += f"pass_phrase={key_passphrase}\n"
|
||||||
|
cfg_file.write_text(cfg_content); cfg_file.chmod(0o600)
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE oci_configs SET tenancy_name=?,tenancy_ocid=?,user_ocid=?,fingerprint=?,region=?,compartment_id=?,ssh_public_key=?,auth_type=? WHERE id=?",
|
||||||
|
(tenancy_name, _enc(tenancy_ocid), _enc(user_ocid), _enc(fingerprint), region, _enc(compartment_id) if compartment_id else None, ssh_public_key or None, auth_type, cid))
|
||||||
|
_audit(u["id"], u["username"], "update_oci_config", cid, f"tenancy={tenancy_name} auth={auth_type}")
|
||||||
|
_config_log("oci", cid, tenancy_name, "success", "save", f"Credencial atualizada: {tenancy_name} ({region}) [{auth_type}]", u["id"], u["username"])
|
||||||
|
return {"id": cid, "tenancy_name": tenancy_name, "region": region}
|
||||||
|
|
||||||
|
@router.post("/api/oci/test/{cid}")
|
||||||
|
async def test_oci(cid: str, u=Depends(require("admin","user"))):
|
||||||
|
_verify_config_access("oci", cid, u)
|
||||||
|
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:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
r = await loop.run_in_executor(None, partial(
|
||||||
|
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 CLI Terminal ─────────────────────────────────────────────────────────
|
||||||
|
# OCID resource type → OCI CLI get command mapping
|
||||||
|
_OCID_CMD_MAP = {
|
||||||
|
"instance": "oci compute instance get --instance-id",
|
||||||
|
"image": "oci compute image get --image-id",
|
||||||
|
"vcn": "oci network vcn get --vcn-id",
|
||||||
|
"subnet": "oci network subnet get --subnet-id",
|
||||||
|
"securitylist": "oci network security-list get --security-list-id",
|
||||||
|
"routetable": "oci network route-table get --rt-id",
|
||||||
|
"internetgateway": "oci network internet-gateway get --ig-id",
|
||||||
|
"natgateway": "oci network nat-gateway get --nat-gateway-id",
|
||||||
|
"servicegateway": "oci network service-gateway get --service-gateway-id",
|
||||||
|
"drg": "oci network drg get --drg-id",
|
||||||
|
"drgattachment": "oci network drg-attachment get --drg-attachment-id",
|
||||||
|
"localpeeringgateway": "oci network local-peering-gateway get --local-peering-gateway-id",
|
||||||
|
"networksecuritygroup": "oci network nsg get --nsg-id",
|
||||||
|
"vnic": "oci network vnic get --vnic-id",
|
||||||
|
"privateip": "oci network private-ip get --private-ip-id",
|
||||||
|
"publicip": "oci network public-ip get --public-ip-id",
|
||||||
|
"dhcpoptions": "oci network dhcp-options get --dhcp-id",
|
||||||
|
"cpe": "oci network cpe get --cpe-id",
|
||||||
|
"ipsecconnection": "oci network ip-sec-connection get --ipsc-id",
|
||||||
|
"volume": "oci bv volume get --volume-id",
|
||||||
|
"bootvolume": "oci bv boot-volume get --boot-volume-id",
|
||||||
|
"volumebackup": "oci bv backup get --volume-backup-id",
|
||||||
|
"bootvolumereplica": "oci bv boot-volume-replica get --boot-volume-replica-id",
|
||||||
|
"volumegroup": "oci bv volume-group get --volume-group-id",
|
||||||
|
"bucket": "oci os bucket get --bucket-name",
|
||||||
|
"compartment": "oci iam compartment get --compartment-id",
|
||||||
|
"tenancy": "oci iam tenancy get --tenancy-id",
|
||||||
|
"user": "oci iam user get --user-id",
|
||||||
|
"group": "oci iam group get --group-id",
|
||||||
|
"policy": "oci iam policy get --policy-id",
|
||||||
|
"dynamicgroup": "oci iam dynamic-group get --dynamic-group-id",
|
||||||
|
"identityprovider": "oci iam identity-provider get --identity-provider-id",
|
||||||
|
"autonomousdatabase": "oci db autonomous-database get --autonomous-database-id",
|
||||||
|
"dbsystem": "oci db system get --db-system-id",
|
||||||
|
"database": "oci db database get --database-id",
|
||||||
|
"dbhome": "oci db db-home get --db-home-id",
|
||||||
|
"dbnode": "oci db node get --db-node-id",
|
||||||
|
"mysqldbsystem": "oci mysql db-system get --db-system-id",
|
||||||
|
"loadbalancer": "oci lb load-balancer get --load-balancer-id",
|
||||||
|
"networkloadbalancer": "oci nlb network-load-balancer get --network-load-balancer-id",
|
||||||
|
"certificate": "oci certs-mgmt certificate get --certificate-id",
|
||||||
|
"vault": "oci kms management vault get --vault-id",
|
||||||
|
"key": "oci kms management key get --key-id",
|
||||||
|
"secret": "oci vault secret get --secret-id",
|
||||||
|
"fnapp": "oci fn application get --application-id",
|
||||||
|
"fnfunc": "oci fn function get --function-id",
|
||||||
|
"apigateway": "oci api-gateway gateway get --gateway-id",
|
||||||
|
"containerinstance": "oci container-instances container-instance get --container-instance-id",
|
||||||
|
"cluster": "oci ce cluster get --cluster-id",
|
||||||
|
"nodepool": "oci ce node-pool get --node-pool-id",
|
||||||
|
"filesystem": "oci fs file-system get --file-system-id",
|
||||||
|
"mounttarget": "oci fs mount-target get --mount-target-id",
|
||||||
|
"alarm": "oci monitoring alarm get --alarm-id",
|
||||||
|
"loggroup": "oci logging log-group get --log-group-id",
|
||||||
|
"log": "oci logging log get --log-id",
|
||||||
|
"topic": "oci ons topic get --topic-id",
|
||||||
|
"subscription": "oci ons subscription get --subscription-id",
|
||||||
|
"stream": "oci streaming stream get --stream-id",
|
||||||
|
"streampool": "oci streaming stream-pool get --stream-pool-id",
|
||||||
|
"dnszone": "oci dns zone get --zone-name-or-id",
|
||||||
|
"networkfirewall": "oci network-firewall network-firewall get --network-firewall-id",
|
||||||
|
"networkfirewallpolicy": "oci network-firewall network-firewall-policy get --network-firewall-policy-id",
|
||||||
|
"waaspolicy": "oci waas waas-policy get --waas-policy-id",
|
||||||
|
"instancepool": "oci compute-management instance-pool get --instance-pool-id",
|
||||||
|
"instanceconfiguration": "oci compute-management instance-configuration get --instance-configuration-id",
|
||||||
|
"capacityreservation": "oci compute capacity-reservation get --capacity-reservation-id",
|
||||||
|
"dedicatedvmhost": "oci compute dedicated-vm-host get --dedicated-vm-host-id",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _resolve_ocid_command(ocid: str) -> str | None:
|
||||||
|
"""Parse an OCID and return the corresponding OCI CLI get command."""
|
||||||
|
parts = ocid.strip().split(".")
|
||||||
|
if len(parts) < 4 or parts[0] != "ocid1":
|
||||||
|
return None
|
||||||
|
resource_type = parts[1].lower()
|
||||||
|
# Extract region from OCID if present (4th part, non-empty for regional resources)
|
||||||
|
region = parts[3] if len(parts) > 3 and parts[3] and parts[3] != parts[2] else None
|
||||||
|
cmd_template = _OCID_CMD_MAP.get(resource_type)
|
||||||
|
if not cmd_template:
|
||||||
|
return None
|
||||||
|
cmd = f"{cmd_template} {ocid}"
|
||||||
|
if region:
|
||||||
|
cmd += f" --region {region}"
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
_TERMINAL_BLOCKED_PATTERNS = re.compile(
|
||||||
|
r'(rm\s|mv\s|cp\s|chmod\s|chown\s|sudo\s|bash\s|sh\s|curl\s|wget\s|python|pip\s|apt\s|yum\s|>\s|>>\s|\|)',
|
||||||
|
re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/api/terminal/execute")
|
||||||
|
async def terminal_execute(req: dict, u=Depends(current_user)):
|
||||||
|
"""Execute an OCI CLI command using the selected OCI config. Only 'oci' commands allowed."""
|
||||||
|
oci_config_id = req.get("oci_config_id", "")
|
||||||
|
command = (req.get("command", "") or "").strip()
|
||||||
|
if not oci_config_id:
|
||||||
|
raise HTTPException(400, "oci_config_id obrigatório")
|
||||||
|
if not command:
|
||||||
|
raise HTTPException(400, "Comando vazio")
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
# Auto-lookup: OCID pasted directly → resolve to oci get command
|
||||||
|
resolved_cmd = None
|
||||||
|
if command.startswith("ocid1."):
|
||||||
|
resolved_cmd = _resolve_ocid_command(command)
|
||||||
|
if resolved_cmd:
|
||||||
|
command = resolved_cmd
|
||||||
|
else:
|
||||||
|
raise HTTPException(400, f"Tipo de recurso não reconhecido no OCID: {command.split('.')[1]}")
|
||||||
|
# find <query> → search resources by display name using OCI Search service
|
||||||
|
elif command.startswith("find "):
|
||||||
|
query = command[5:].strip()
|
||||||
|
if not query:
|
||||||
|
raise HTTPException(400, "Uso: find <nome-do-recurso> | find %partial% | find 10.0.1.5")
|
||||||
|
# Detect IP address → search by IP
|
||||||
|
if re.match(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$', query):
|
||||||
|
search_query = f"query privateip resources where ipAddress = '{query}'"
|
||||||
|
elif '%' in query:
|
||||||
|
# Partial match with LIKE
|
||||||
|
search_query = f"query all resources where displayName =~ '{query}'"
|
||||||
|
else:
|
||||||
|
search_query = f"query all resources where displayName = '{query}'"
|
||||||
|
resolved_cmd = f"oci search resource structured-search --query-text \"{search_query}\" --limit 20"
|
||||||
|
command = resolved_cmd
|
||||||
|
# Security: only allow 'oci' commands
|
||||||
|
if command == "oci":
|
||||||
|
command = "oci --help"
|
||||||
|
if not command.startswith("oci "):
|
||||||
|
raise HTTPException(400, "Apenas comandos 'oci' são permitidos. Ex: oci iam user list")
|
||||||
|
if _TERMINAL_BLOCKED_PATTERNS.search(command):
|
||||||
|
raise HTTPException(400, "Comando contém operações não permitidas")
|
||||||
|
# Build config path
|
||||||
|
cfg_path = OCI_DIR / oci_config_id / "config"
|
||||||
|
if not cfg_path.exists():
|
||||||
|
raise HTTPException(400, "OCI config file não encontrado. Reconfigure as credenciais.")
|
||||||
|
# Execute with timeout
|
||||||
|
cmd_args = shlex.split(command) + ["--config-file", str(cfg_path)]
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(_chat_executor, lambda: subprocess.run(
|
||||||
|
cmd_args, capture_output=True, text=True, timeout=60, cwd="/tmp",
|
||||||
|
env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"}
|
||||||
|
)), timeout=65)
|
||||||
|
output = result.stdout or ""
|
||||||
|
error = result.stderr or ""
|
||||||
|
exit_code = result.returncode
|
||||||
|
# Save to history
|
||||||
|
with db() as c:
|
||||||
|
c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,output,error) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(str(uuid.uuid4()), u["id"], oci_config_id, command, exit_code, output[:10000], error[:2000]))
|
||||||
|
return {
|
||||||
|
"exit_code": exit_code,
|
||||||
|
"output": output[:50000],
|
||||||
|
"error": error[:5000] if exit_code != 0 else "",
|
||||||
|
"resolved_cmd": resolved_cmd,
|
||||||
|
}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
with db() as c:
|
||||||
|
c.execute("INSERT INTO terminal_history (id,user_id,oci_config_id,command,exit_code,error) VALUES (?,?,?,?,?,?)",
|
||||||
|
(str(uuid.uuid4()), u["id"], oci_config_id, command, -1, "Timeout: 60s"))
|
||||||
|
return {"exit_code": -1, "output": "", "error": "Timeout: comando excedeu 60 segundos"}
|
||||||
|
except Exception as e:
|
||||||
|
return {"exit_code": -1, "output": "", "error": str(e)[:2000]}
|
||||||
|
|
||||||
|
@router.get("/api/terminal/history")
|
||||||
|
async def terminal_history(oci_config_id: str = Query(""), limit: int = Query(50), u=Depends(current_user)):
|
||||||
|
"""Get recent terminal commands for the user."""
|
||||||
|
with db() as c:
|
||||||
|
if oci_config_id:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT * FROM terminal_history WHERE user_id=? AND oci_config_id=? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(u["id"], oci_config_id, limit)).fetchall()
|
||||||
|
else:
|
||||||
|
rows = c.execute(
|
||||||
|
"SELECT * FROM terminal_history WHERE user_id=? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
(u["id"], limit)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# OCI CLI autocomplete cache
|
||||||
|
_oci_completions_cache: dict = {}
|
||||||
|
|
||||||
|
def _parse_oci_help(args: list[str]) -> list[str]:
|
||||||
|
"""Run oci <args> --help and extract available commands/options."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["oci"] + args + ["--help"], capture_output=True, text=True, timeout=10,
|
||||||
|
env={**os.environ, "OCI_CLI_SUPPRESS_FILE_PERMISSIONS_WARNING": "True", "HOME": "/tmp"})
|
||||||
|
text = result.stdout + result.stderr
|
||||||
|
commands = []
|
||||||
|
in_commands = False
|
||||||
|
for line in text.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
# Detect transition into command/service listing
|
||||||
|
if not in_commands:
|
||||||
|
if re.match(r'^(Commands|Options|Core|Others|Networking|Database|Identity|Storage|Compute|Security)', stripped):
|
||||||
|
in_commands = True
|
||||||
|
continue
|
||||||
|
if not stripped:
|
||||||
|
continue
|
||||||
|
# Section headers (single word or capitalized phrase not indented) — skip
|
||||||
|
if not line.startswith(" "):
|
||||||
|
continue
|
||||||
|
# Capture " command-name Description text" pattern
|
||||||
|
m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line)
|
||||||
|
if m and len(m.group(1)) > 1:
|
||||||
|
commands.append(m.group(1))
|
||||||
|
continue
|
||||||
|
# Capture 2-space indent commands (sub-command help)
|
||||||
|
m = re.match(r'^ ([a-z][a-z0-9\-_]+)\s', line)
|
||||||
|
if m and len(m.group(1)) > 2:
|
||||||
|
commands.append(m.group(1))
|
||||||
|
continue
|
||||||
|
# Capture --options
|
||||||
|
if stripped.startswith("--"):
|
||||||
|
opt = stripped.split()[0].rstrip(",")
|
||||||
|
if opt.startswith("--"): commands.append(opt)
|
||||||
|
return sorted(set(commands))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@router.get("/api/terminal/completions")
|
||||||
|
async def terminal_completions(prefix: str = Query(""), u=Depends(current_user)):
|
||||||
|
"""Get OCI CLI autocomplete suggestions for a partial command."""
|
||||||
|
parts = prefix.strip().split()
|
||||||
|
if not parts or parts[0] != "oci":
|
||||||
|
return {"suggestions": []}
|
||||||
|
# Build cache key from command path (without final partial word)
|
||||||
|
# e.g. "oci iam us" → lookup completions for ["oci", "iam"], filter by "us"
|
||||||
|
if prefix.endswith(" "):
|
||||||
|
cmd_parts = parts[1:]
|
||||||
|
filter_prefix = ""
|
||||||
|
else:
|
||||||
|
cmd_parts = parts[1:-1]
|
||||||
|
filter_prefix = parts[-1] if len(parts) > 1 else ""
|
||||||
|
cache_key = " ".join(cmd_parts)
|
||||||
|
if cache_key not in _oci_completions_cache:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
completions = await loop.run_in_executor(_chat_executor, lambda: _parse_oci_help(cmd_parts))
|
||||||
|
_oci_completions_cache[cache_key] = completions
|
||||||
|
suggestions = _oci_completions_cache[cache_key]
|
||||||
|
if filter_prefix:
|
||||||
|
suggestions = [s for s in suggestions if s.startswith(filter_prefix)]
|
||||||
|
# Commands first, then options
|
||||||
|
cmds = [s for s in suggestions if not s.startswith("--")]
|
||||||
|
opts = [s for s in suggestions if s.startswith("--")]
|
||||||
|
return {"suggestions": (cmds + opts)[:50]}
|
||||||
1014
backend/routes/oci_explorer.py
Normal file
1014
backend/routes/oci_explorer.py
Normal file
File diff suppressed because it is too large
Load Diff
569
backend/routes/reports.py
Normal file
569
backend/routes/reports.py
Normal file
@@ -0,0 +1,569 @@
|
|||||||
|
"""Report routes — CIS report CRUD, compliance report, OCI health/services."""
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from functools import partial
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import (
|
||||||
|
APIRouter, HTTPException, Depends, Query, BackgroundTasks, Request,
|
||||||
|
)
|
||||||
|
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
|
||||||
|
from config import REPORTS, VERSION, _chat_executor, log
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import (
|
||||||
|
current_user, _user_from_token, require, _audit,
|
||||||
|
_verify_config_access, _verify_report_access,
|
||||||
|
)
|
||||||
|
from models import RunReportReq
|
||||||
|
from utils import running_reports
|
||||||
|
from services.genai import _get_adb_connection
|
||||||
|
from services.cis_reports import _exec_report
|
||||||
|
from services.compliance_gen import (
|
||||||
|
_extract_section,
|
||||||
|
_extract_remediation_section,
|
||||||
|
_extract_audit_section,
|
||||||
|
_extract_rationale_section,
|
||||||
|
_extract_description_section,
|
||||||
|
_format_remediation_html,
|
||||||
|
_search_cis_remediation,
|
||||||
|
_build_findings_table,
|
||||||
|
_check_oci_services,
|
||||||
|
_OCI_SERVICE_DESCRIPTIONS,
|
||||||
|
_build_compliance_html,
|
||||||
|
_generate_compliance_report,
|
||||||
|
)
|
||||||
|
from services.compliance_docx import (
|
||||||
|
_generate_camo_strip,
|
||||||
|
_add_floating_image_to_header,
|
||||||
|
_generate_compliance_docx,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# ── Report CRUD ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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")
|
||||||
|
_verify_config_access("oci", req.config_id, u)
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reports")
|
||||||
|
async def list_reports(skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
q = "SELECT id,user_id,tenancy_name,config_id,status,progress,level,obp_checks,raw_data,redact_output,created_at,completed_at,error_msg FROM reports"
|
||||||
|
rows = c.execute(q+" WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?",(u["id"], limit, skip)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reports/{rid}/progress")
|
||||||
|
async def get_report_progress(rid: str, u=Depends(current_user)):
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/reports/{rid}/cancel")
|
||||||
|
async def cancel_report(rid: str, u=Depends(require("admin", "user"))):
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/reports/{rid}")
|
||||||
|
async def delete_report(rid: str, u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT id, user_id, status, json_path, html_path 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)
|
||||||
|
if r["status"] == "running": raise HTTPException(400, "Cannot delete a running report")
|
||||||
|
# Remove associated files
|
||||||
|
for path_col in ("json_path", "html_path"):
|
||||||
|
p = r[path_col]
|
||||||
|
if p and Path(p).exists():
|
||||||
|
try: Path(p).unlink()
|
||||||
|
except OSError: pass
|
||||||
|
c.execute("DELETE FROM reports WHERE id=?", (rid,))
|
||||||
|
_audit(u["id"], u["username"], "delete_report", rid)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.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]
|
||||||
|
|
||||||
|
|
||||||
|
@router.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"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/api/reports/{rid}/html", methods=["GET", "HEAD"])
|
||||||
|
async def report_html(rid, 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")
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# OCI Health & Services
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@router.get("/api/oci-health")
|
||||||
|
async def oci_health(u=Depends(current_user)):
|
||||||
|
"""Proxy Oracle Cloud Infrastructure public status API."""
|
||||||
|
import httpx
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=15) as client:
|
||||||
|
r = await client.get("https://ocistatus.oraclecloud.com/api/v2/components.json")
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.json()
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"OCI Health API failed: {e}")
|
||||||
|
raise HTTPException(502, f"Failed to fetch OCI status: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
_OCI_SERVICE_NAMES = ["Vulnerability Scanning Service", "Data Safe", "Cloud Guard", "Bastion", "Security Zones", "Vault"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/oci-services/{config_id}")
|
||||||
|
async def get_oci_services_status(config_id: str, detect: int = Query(1), u=Depends(current_user)):
|
||||||
|
"""Get OCI services status (auto-detected + user overrides). Use detect=0 to skip auto-detect."""
|
||||||
|
_verify_config_access("oci", config_id, u)
|
||||||
|
import json as _json
|
||||||
|
# Load user overrides
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT value FROM app_settings WHERE key=?", (f"oci_services_{config_id}",)).fetchone()
|
||||||
|
overrides = _json.loads(row["value"]) if row else {}
|
||||||
|
# Auto-detect (skip if detect=0)
|
||||||
|
auto = {}
|
||||||
|
if detect:
|
||||||
|
try:
|
||||||
|
detected = _check_oci_services(config_id)
|
||||||
|
for svc in detected:
|
||||||
|
auto[svc["service"]] = {"status": svc["status"], "description": svc["description"]}
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"OCI services auto-detect failed: {e}")
|
||||||
|
# Merge: override wins
|
||||||
|
result = []
|
||||||
|
for name in _OCI_SERVICE_NAMES:
|
||||||
|
ov = overrides.get(name, {})
|
||||||
|
au = auto.get(name, {"status": "WARNING", "description": "Unable to verify"})
|
||||||
|
result.append({
|
||||||
|
"service": name,
|
||||||
|
"auto_status": au["status"],
|
||||||
|
"auto_description": au["description"],
|
||||||
|
"status": ov.get("status", au["status"]),
|
||||||
|
"description": ov.get("description", au["description"]),
|
||||||
|
"overridden": bool(ov),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/oci-services/{config_id}")
|
||||||
|
async def set_oci_services_status(config_id: str, request: Request, u=Depends(current_user)):
|
||||||
|
"""Save user overrides for OCI services status."""
|
||||||
|
_verify_config_access("oci", config_id, u)
|
||||||
|
import json as _json
|
||||||
|
body = await request.json()
|
||||||
|
overrides = {}
|
||||||
|
for svc in body:
|
||||||
|
name = svc.get("service", "")
|
||||||
|
if name in _OCI_SERVICE_NAMES and svc.get("overridden"):
|
||||||
|
overrides[name] = {"status": svc.get("status", "OK"), "description": svc.get("description", "")}
|
||||||
|
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",
|
||||||
|
(f"oci_services_{config_id}", _json.dumps(overrides)))
|
||||||
|
_audit(u["id"], u["username"], "set_oci_services", config_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# LAD A-Team CIS Compliance Report
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
@router.api_route("/api/reports/{rid}/compliance-report", methods=["GET", "HEAD"])
|
||||||
|
async def compliance_report(rid: str, regen: int = Query(0), token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||||
|
"""Serve cached LAD A-Team CIS Compliance Report, or return 404 if not generated yet."""
|
||||||
|
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||||
|
if not u: raise HTTPException(401, "Not authenticated")
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
rdir = REPORTS / rid
|
||||||
|
cache_path = rdir / "compliance_report.html"
|
||||||
|
|
||||||
|
if regen == 1 and cache_path.exists():
|
||||||
|
cache_path.unlink()
|
||||||
|
|
||||||
|
if cache_path.exists():
|
||||||
|
return FileResponse(cache_path, media_type="text/html",
|
||||||
|
headers={"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||||
|
"Pragma": "no-cache", "Expires": "0"})
|
||||||
|
|
||||||
|
# Return a friendly HTML page instead of JSON 404 (shown in iframe)
|
||||||
|
marker = rdir / ".compliance_generating"
|
||||||
|
if marker.exists():
|
||||||
|
msg = "Generating compliance report... Please wait."
|
||||||
|
else:
|
||||||
|
msg = "Compliance report not generated yet."
|
||||||
|
return HTMLResponse(
|
||||||
|
f'<html><body style="display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:Calibri,sans-serif;color:#666">'
|
||||||
|
f'<div style="text-align:center"><p style="font-size:14px">{msg}</p></div></body></html>',
|
||||||
|
status_code=200,
|
||||||
|
headers={"Cache-Control": "no-cache"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/reports/{rid}/compliance-report/generate")
|
||||||
|
async def generate_compliance_report(rid: str, bg: BackgroundTasks, u=Depends(current_user)):
|
||||||
|
"""Start generating the compliance report in background."""
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
with db() as c:
|
||||||
|
report = c.execute("SELECT * FROM reports WHERE id=?", (rid,)).fetchone()
|
||||||
|
if not report: raise HTTPException(404, "Report not found")
|
||||||
|
|
||||||
|
rdir = REPORTS / rid
|
||||||
|
csv_path = rdir / "cis_summary_report.csv"
|
||||||
|
if not csv_path.exists():
|
||||||
|
raise HTTPException(404, "cis_summary_report.csv not found")
|
||||||
|
|
||||||
|
# Reject if already generating
|
||||||
|
marker = rdir / ".compliance_generating"
|
||||||
|
if marker.exists():
|
||||||
|
age = time.time() - marker.stat().st_mtime
|
||||||
|
if age < 600:
|
||||||
|
return {"status": "already_generating", "has_rag": False}
|
||||||
|
|
||||||
|
# Mark as generating BEFORE deleting cache
|
||||||
|
marker.write_text(str(uuid.uuid4()), encoding="utf-8")
|
||||||
|
|
||||||
|
# Delete existing cache
|
||||||
|
cache_path = rdir / "compliance_report.html"
|
||||||
|
if cache_path.exists():
|
||||||
|
cache_path.unlink()
|
||||||
|
|
||||||
|
has_adb = False
|
||||||
|
with db() as c:
|
||||||
|
adb_cfgs = c.execute("SELECT * FROM adb_vector_configs WHERE user_id=? AND is_active=1", (u["id"],)).fetchall()
|
||||||
|
if adb_cfgs:
|
||||||
|
has_adb = True
|
||||||
|
|
||||||
|
# Pre-check ADB connection before starting (fail fast instead of timeout loop)
|
||||||
|
if has_adb:
|
||||||
|
adb_cfg = dict(adb_cfgs[0])
|
||||||
|
try:
|
||||||
|
conn = _get_adb_connection(adb_cfg)
|
||||||
|
conn.ping()
|
||||||
|
conn.close()
|
||||||
|
log.info(f"ADB connection OK for compliance report {rid}")
|
||||||
|
except Exception as e:
|
||||||
|
marker.unlink(missing_ok=True)
|
||||||
|
log.warning(f"ADB connection failed for compliance report {rid}: {e}")
|
||||||
|
raise HTTPException(503, f"Conexao com ADB falhou. Verifique a whitelist e a configuracao do ADB. Erro: {e}")
|
||||||
|
|
||||||
|
user_id = u["id"]
|
||||||
|
log.info(f"Compliance report generation started for {rid} (has_adb={has_adb})")
|
||||||
|
|
||||||
|
def _bg_generate():
|
||||||
|
try:
|
||||||
|
log.info(f"Compliance report thread running for {rid}")
|
||||||
|
_generate_compliance_report(rid, user_id, force_rag=has_adb)
|
||||||
|
log.info(f"Compliance report generation completed for {rid}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Compliance report generation failed for {rid}: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
# Remove generating marker
|
||||||
|
m = REPORTS / rid / ".compliance_generating"
|
||||||
|
if m.exists():
|
||||||
|
m.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
_chat_executor.submit(_bg_generate)
|
||||||
|
return {"status": "generating", "has_rag": has_adb}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reports/{rid}/compliance-report/status")
|
||||||
|
async def compliance_report_status(rid: str, u=Depends(current_user)):
|
||||||
|
"""Check compliance report status: ready, generating, or not started."""
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
rdir = REPORTS / rid
|
||||||
|
cache_path = rdir / "compliance_report.html"
|
||||||
|
marker = rdir / ".compliance_generating"
|
||||||
|
if cache_path.exists():
|
||||||
|
# Clean stale marker if report is ready
|
||||||
|
if marker.exists():
|
||||||
|
marker.unlink(missing_ok=True)
|
||||||
|
return {"ready": True, "generating": False}
|
||||||
|
if marker.exists():
|
||||||
|
age = time.time() - marker.stat().st_mtime
|
||||||
|
if age > 600:
|
||||||
|
marker.unlink(missing_ok=True)
|
||||||
|
log.warning(f"Expired stale compliance marker for {rid} (age={age:.0f}s)")
|
||||||
|
return {"ready": False, "generating": False}
|
||||||
|
# Read progress from marker file
|
||||||
|
try:
|
||||||
|
import json as _json
|
||||||
|
progress = _json.loads(marker.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
progress = {}
|
||||||
|
return {"ready": False, "generating": True,
|
||||||
|
"current": progress.get("current", 0),
|
||||||
|
"total": progress.get("total", 0),
|
||||||
|
"step": progress.get("step", ""),
|
||||||
|
"rec": progress.get("rec", "")}
|
||||||
|
return {"ready": False, "generating": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reports/{rid}/compliance-report/docx")
|
||||||
|
async def compliance_report_docx(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||||
|
"""Download DOCX compliance report directly."""
|
||||||
|
import re as _re
|
||||||
|
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||||
|
if not u: raise HTTPException(401, "Not authenticated")
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
with db() as c:
|
||||||
|
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
|
||||||
|
if not report: raise HTTPException(404)
|
||||||
|
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
|
||||||
|
tenancy = report["tenancy_name"] or "report"
|
||||||
|
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
|
||||||
|
try:
|
||||||
|
file_map = {f["file_name"]: f["file_path"] for f in files}
|
||||||
|
docx_bytes = _generate_compliance_docx(rid, tenancy, file_map)
|
||||||
|
if not docx_bytes:
|
||||||
|
raise HTTPException(500, "DOCX generation failed")
|
||||||
|
log.info(f"DOCX direct download: {len(docx_bytes)} bytes")
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"DOCX generation failed: {e}", exc_info=True)
|
||||||
|
raise HTTPException(500, f"DOCX generation failed: {e}")
|
||||||
|
return Response(
|
||||||
|
content=docx_bytes,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.docx"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/reports/{rid}/compliance-report/download")
|
||||||
|
async def compliance_report_download(rid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||||
|
"""Download ZIP with compliance report PDF (Chromium headless) + CSV files."""
|
||||||
|
import re as _re
|
||||||
|
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||||
|
if not u: raise HTTPException(401, "Not authenticated")
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
|
||||||
|
rdir = REPORTS / rid
|
||||||
|
html_path = rdir / "compliance_report.html"
|
||||||
|
if not html_path.exists():
|
||||||
|
raise HTTPException(404, "Compliance report not generated yet")
|
||||||
|
|
||||||
|
with db() as c:
|
||||||
|
report = c.execute("SELECT tenancy_name FROM reports WHERE id=?", (rid,)).fetchone()
|
||||||
|
if not report: raise HTTPException(404)
|
||||||
|
files = c.execute("SELECT file_name, file_path FROM report_files WHERE report_id=?", (rid,)).fetchall()
|
||||||
|
|
||||||
|
tenancy = report["tenancy_name"] or "report"
|
||||||
|
safe_name = _re.sub(r'[^\w\-]', '_', tenancy)
|
||||||
|
file_map = {f["file_name"]: f["file_path"] for f in files}
|
||||||
|
|
||||||
|
# Prepare HTML for PDF: rewrite CSV links to relative paths
|
||||||
|
html = html_path.read_text(encoding="utf-8")
|
||||||
|
html = _re.sub(r'<button class="print-btn[^"]*"[^>]*>.*?</button>', '', html, flags=_re.DOTALL)
|
||||||
|
html = _re.sub(r'<script>.*?</script>', '', html, flags=_re.DOTALL)
|
||||||
|
for fname in file_map:
|
||||||
|
html = _re.sub(
|
||||||
|
r'href="[^"]*?/download[^"]*?"([^>]*>)\s*\[CSV\]\s*' + _re.escape(fname),
|
||||||
|
f'href="csv/{fname}"\\1[CSV] {fname}',
|
||||||
|
html
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate PDF via Chromium headless (same engine as browser print)
|
||||||
|
pdf_bytes = None
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
tmp_html = Path(tmpdir) / "report.html"
|
||||||
|
tmp_pdf = Path(tmpdir) / "report.pdf"
|
||||||
|
tmp_html.write_text(html, encoding="utf-8")
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(None, partial(
|
||||||
|
subprocess.run, [
|
||||||
|
"chromium", "--headless", "--no-sandbox", "--disable-gpu",
|
||||||
|
"--disable-software-rasterizer", "--run-all-compositor-stages-before-draw",
|
||||||
|
f"--print-to-pdf={tmp_pdf}", "--no-pdf-header-footer",
|
||||||
|
f"file://{tmp_html}"
|
||||||
|
], capture_output=True, timeout=120)),
|
||||||
|
timeout=120)
|
||||||
|
if tmp_pdf.exists():
|
||||||
|
pdf_bytes = tmp_pdf.read_bytes()
|
||||||
|
log.info(f"Chromium PDF generated: {len(pdf_bytes)} bytes")
|
||||||
|
else:
|
||||||
|
log.error(f"Chromium PDF failed: {result.stderr.decode()[:500]}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"PDF generation failed: {e}", exc_info=True)
|
||||||
|
|
||||||
|
if not pdf_bytes:
|
||||||
|
raise HTTPException(500, "PDF generation failed")
|
||||||
|
|
||||||
|
# Generate DOCX
|
||||||
|
docx_bytes = None
|
||||||
|
try:
|
||||||
|
docx_bytes = _generate_compliance_docx(rid, tenancy, file_map)
|
||||||
|
if docx_bytes:
|
||||||
|
log.info(f"DOCX generated: {len(docx_bytes)} bytes")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"DOCX generation failed (non-fatal): {e}")
|
||||||
|
|
||||||
|
# Build ZIP: PDF + DOCX + CSVs
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr(f"CIS_Compliance_Report_{safe_name}.pdf", pdf_bytes)
|
||||||
|
if docx_bytes:
|
||||||
|
zf.writestr(f"CIS_Compliance_Report_{safe_name}.docx", docx_bytes)
|
||||||
|
for fname, fpath in file_map.items():
|
||||||
|
p = Path(fpath)
|
||||||
|
if p.exists() and fname.lower().endswith('.csv'):
|
||||||
|
zf.write(str(p), f"csv/{fname}")
|
||||||
|
|
||||||
|
buf.seek(0)
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="CIS_Compliance_Report_{safe_name}.zip"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.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")
|
||||||
|
_verify_report_access(rid, u)
|
||||||
|
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}")
|
||||||
300
backend/routes/settings.py
Normal file
300
backend/routes/settings.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""Settings routes — audit log, config logs, chat logs, app settings, system prompts, config export/import."""
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
|
from config import VERSION, OCI_DIR, log
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, require, _audit
|
||||||
|
from auth.crypto import _enc, _dec, _mask, _safe_dec
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Audit Log ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/api/audit-log")
|
||||||
|
async def audit_log(skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=500), u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
if u["role"] == "admin":
|
||||||
|
rows = c.execute("SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?", (limit, skip)).fetchall()
|
||||||
|
else:
|
||||||
|
rows = c.execute("SELECT * FROM audit_log WHERE user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (u["id"], limit, skip)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config Logs ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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 ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_SENSITIVE_SETTING_PREFIXES = ("oidc_", "secret_", "app_secret", "encryption_")
|
||||||
|
|
||||||
|
@router.get("/api/settings/{key}")
|
||||||
|
async def get_setting(key: str, u=Depends(current_user)):
|
||||||
|
if any(key.startswith(p) or key == p for p in _SENSITIVE_SETTING_PREFIXES) and u["role"] != "admin":
|
||||||
|
raise HTTPException(403, "Acesso negado a configuração sensível")
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT value FROM app_settings WHERE key=?", (key,)).fetchone()
|
||||||
|
val = row["value"] if row else ""
|
||||||
|
if key == "oidc_client_secret" and val:
|
||||||
|
val = _mask(_safe_dec(val))
|
||||||
|
return {"key": key, "value": val}
|
||||||
|
|
||||||
|
@router.put("/api/settings/{key}")
|
||||||
|
async def put_setting(key: str, body: dict, u=Depends(require("admin"))):
|
||||||
|
value = body.get("value", "")
|
||||||
|
if key == "oidc_client_secret" and value and "\u2022" in value:
|
||||||
|
return {"key": key, "value": value}
|
||||||
|
if key == "oidc_client_secret" and value:
|
||||||
|
value = _enc(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}
|
||||||
|
|
||||||
|
@router.get("/api/settings/timezone/options")
|
||||||
|
async def timezone_options(u=Depends(current_user)):
|
||||||
|
"""List common timezone options."""
|
||||||
|
return {"timezones": [
|
||||||
|
"America/Sao_Paulo", "America/New_York", "America/Chicago", "America/Denver",
|
||||||
|
"America/Los_Angeles", "America/Toronto", "America/Mexico_City", "America/Bogota",
|
||||||
|
"America/Argentina/Buenos_Aires", "America/Santiago",
|
||||||
|
"Europe/London", "Europe/Paris", "Europe/Berlin", "Europe/Madrid", "Europe/Lisbon",
|
||||||
|
"Asia/Tokyo", "Asia/Shanghai", "Asia/Singapore", "Asia/Kolkata", "Asia/Dubai",
|
||||||
|
"Australia/Sydney", "Pacific/Auckland", "UTC",
|
||||||
|
]}
|
||||||
|
|
||||||
|
@router.put("/api/users/me/timezone")
|
||||||
|
async def set_user_timezone(body: dict, u=Depends(current_user)):
|
||||||
|
"""Set the current user's timezone."""
|
||||||
|
tz = body.get("timezone", "").strip()
|
||||||
|
if not tz:
|
||||||
|
raise HTTPException(400, "timezone is required")
|
||||||
|
try:
|
||||||
|
import zoneinfo
|
||||||
|
zoneinfo.ZoneInfo(tz)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, f"Invalid timezone: {tz}")
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE users SET timezone=? WHERE id=?", (tz, u["id"]))
|
||||||
|
_audit(u["id"], u["username"], "set_timezone", tz)
|
||||||
|
return {"ok": True, "timezone": tz}
|
||||||
|
|
||||||
|
@router.get("/api/users/me/timezone")
|
||||||
|
async def get_user_timezone(u=Depends(current_user)):
|
||||||
|
"""Get the current user's timezone."""
|
||||||
|
return {"timezone": u.get("timezone", "America/Sao_Paulo")}
|
||||||
|
|
||||||
|
|
||||||
|
# ── System Prompts ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.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]
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.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 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/api/config/export")
|
||||||
|
async def export_config(u=Depends(require("admin"))):
|
||||||
|
"""Export configurations as JSON. Excludes sensitive credentials (private keys, passwords, secrets)."""
|
||||||
|
_OCI_SAFE_COLS = "id,user_id,tenancy_name,region,compartment_id,auth_type,ssh_public_key,created_at"
|
||||||
|
_ADB_SAFE_COLS = "id,user_id,config_name,dsn,username,table_name,use_mtls,is_active,is_global,genai_config_id,embedding_model_id,created_at"
|
||||||
|
data = {"version": VERSION, "exported_at": datetime.now().isoformat()}
|
||||||
|
with db() as c:
|
||||||
|
# OCI configs (no private keys, no encrypted OCIDs, no passphrases)
|
||||||
|
data["oci_configs"] = [dict(r) for r in c.execute(f"SELECT {_OCI_SAFE_COLS} FROM oci_configs").fetchall()]
|
||||||
|
# GenAI configs (no secrets — model info only)
|
||||||
|
data["genai_configs"] = [dict(r) for r in c.execute("SELECT * FROM genai_configs").fetchall()]
|
||||||
|
# MCP servers
|
||||||
|
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 (exclude sensitive keys)
|
||||||
|
data["app_settings"] = [dict(r) for r in c.execute("SELECT * FROM app_settings").fetchall()
|
||||||
|
if not any(r["key"].startswith(p) for p in _SENSITIVE_SETTING_PREFIXES)]
|
||||||
|
# ADB vector configs (no passwords, no wallet passwords)
|
||||||
|
data["adb_vector_configs"] = [dict(r) for r in c.execute(f"SELECT {_ADB_SAFE_COLS} FROM adb_vector_configs").fetchall()]
|
||||||
|
# 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"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.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)."""
|
||||||
|
from utils import validate_upload
|
||||||
|
await validate_upload(file, {".json"})
|
||||||
|
file.file.seek(0)
|
||||||
|
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
|
||||||
|
# Import metadata only — credentials must be reconfigured manually
|
||||||
|
cdir = OCI_DIR / cfg["id"]; cdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
kp = cdir / "oci_api_key.pem"
|
||||||
|
# Restore private key only if present in legacy export format
|
||||||
|
if cfg.get("_private_key_b64"):
|
||||||
|
kp.write_bytes(base64.b64decode(cfg["_private_key_b64"])); kp.chmod(0o600)
|
||||||
|
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",""),
|
||||||
|
_enc(cfg["tenancy_ocid"]) if cfg.get("tenancy_ocid") else "",
|
||||||
|
_enc(cfg["user_ocid"]) if cfg.get("user_ocid") else "",
|
||||||
|
_enc(cfg["fingerprint"]) if cfg.get("fingerprint") else "",
|
||||||
|
cfg.get("region",""), str(kp),
|
||||||
|
_enc(cfg["compartment_id"]) if cfg.get("compartment_id") else None,
|
||||||
|
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,reasoning_effort,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("reasoning_effort"), 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,is_global,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("is_global",0), 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", []):
|
||||||
|
if any(s["key"].startswith(p) for p in _SENSITIVE_SETTING_PREFIXES):
|
||||||
|
counts["skipped"] += 1; continue
|
||||||
|
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,is_global,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("is_global",0), 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
|
||||||
641
backend/routes/terraform.py
Normal file
641
backend/routes/terraform.py
Normal file
@@ -0,0 +1,641 @@
|
|||||||
|
"""Terraform Agent routes — OCI resource actions, workspace CRUD, plan/apply/destroy."""
|
||||||
|
import uuid
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks, Request
|
||||||
|
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||||
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
from config import TERRAFORM_DIR, _chat_executor, log
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, _user_from_token, _audit, _verify_config_access
|
||||||
|
from auth.crypto import _safe_dec
|
||||||
|
from services.oci_helpers import _get_oci_config
|
||||||
|
from services.terraform_exec import _split_tf_monolith, _write_tf_files, _terraform_exec
|
||||||
|
from services.genai import (
|
||||||
|
_call_genai,
|
||||||
|
_TF_RESOURCE_REF_PATH,
|
||||||
|
_load_tf_resource_reference,
|
||||||
|
_regenerate_tf_reference,
|
||||||
|
_get_tf_docs_for_resources,
|
||||||
|
_detect_tf_resource_types,
|
||||||
|
)
|
||||||
|
from services.chat_background import _chat_start, _chat_background
|
||||||
|
from utils import running_terraform
|
||||||
|
from models import ChatMsg, TfPromptReq, TfWorkspaceReq
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fetch_compartment_resources(oci_config_id: str, compartment_id: str, region: str = None) -> dict:
|
||||||
|
"""Fetch existing OCI resources in a compartment for terraform context."""
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if region:
|
||||||
|
config["region"] = region
|
||||||
|
resources = {}
|
||||||
|
try:
|
||||||
|
vn = oci.core.VirtualNetworkClient(config)
|
||||||
|
vcns = vn.list_vcns(compartment_id).data
|
||||||
|
resources["vcns"] = [{"id": v.id, "display_name": v.display_name, "cidr_blocks": v.cidr_blocks, "state": v.lifecycle_state} for v in vcns if v.lifecycle_state == "AVAILABLE"]
|
||||||
|
subs = vn.list_subnets(compartment_id).data
|
||||||
|
resources["subnets"] = [{"id": s.id, "display_name": s.display_name, "cidr_block": s.cidr_block, "vcn_id": s.vcn_id, "public": not s.prohibit_public_ip_on_vnic, "state": s.lifecycle_state} for s in subs if s.lifecycle_state == "AVAILABLE"]
|
||||||
|
igws = vn.list_internet_gateways(compartment_id).data
|
||||||
|
resources["internet_gateways"] = [{"id": g.id, "display_name": g.display_name, "vcn_id": g.vcn_id, "enabled": g.is_enabled} for g in igws if g.lifecycle_state == "AVAILABLE"]
|
||||||
|
ngws = vn.list_nat_gateways(compartment_id).data
|
||||||
|
resources["nat_gateways"] = [{"id": g.id, "display_name": g.display_name, "vcn_id": g.vcn_id} for g in ngws if g.lifecycle_state == "AVAILABLE"]
|
||||||
|
rts = vn.list_route_tables(compartment_id).data
|
||||||
|
resources["route_tables"] = [{"id": r.id, "display_name": r.display_name, "vcn_id": r.vcn_id} for r in rts if r.lifecycle_state == "AVAILABLE"]
|
||||||
|
sls = vn.list_security_lists(compartment_id).data
|
||||||
|
resources["security_lists"] = [{"id": s.id, "display_name": s.display_name, "vcn_id": s.vcn_id} for s in sls if s.lifecycle_state == "AVAILABLE"]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to fetch networking resources: {e}")
|
||||||
|
try:
|
||||||
|
compute = oci.core.ComputeClient(config)
|
||||||
|
insts = compute.list_instances(compartment_id).data
|
||||||
|
resources["instances"] = [{"id": i.id, "display_name": i.display_name, "shape": i.shape, "state": i.lifecycle_state} for i in insts if i.lifecycle_state in ("RUNNING", "STOPPED")]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to fetch compute resources: {e}")
|
||||||
|
try:
|
||||||
|
db_client = oci.database.DatabaseClient(config)
|
||||||
|
adbs = db_client.list_autonomous_databases(compartment_id).data
|
||||||
|
resources["autonomous_databases"] = [{"id": a.id, "display_name": a.display_name, "db_name": a.db_name, "state": a.lifecycle_state, "is_free_tier": a.is_free_tier} for a in adbs if a.lifecycle_state in ("AVAILABLE", "STOPPED")]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to fetch database resources: {e}")
|
||||||
|
try:
|
||||||
|
bs = oci.core.BlockstorageClient(config)
|
||||||
|
vols = bs.list_volumes(compartment_id).data
|
||||||
|
resources["block_volumes"] = [{"id": v.id, "display_name": v.display_name, "size_in_gbs": v.size_in_gbs, "state": v.lifecycle_state} for v in vols if v.lifecycle_state == "AVAILABLE"]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to fetch block storage resources: {e}")
|
||||||
|
try:
|
||||||
|
lb_client = oci.load_balancer.LoadBalancerClient(config)
|
||||||
|
lbs = lb_client.list_load_balancers(compartment_id).data
|
||||||
|
resources["load_balancers"] = [{"id": l.id, "display_name": l.display_name, "shape_name": l.shape_name, "state": l.lifecycle_state} for l in lbs if l.lifecycle_state == "ACTIVE"]
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to fetch load balancer resources: {e}")
|
||||||
|
return resources
|
||||||
|
|
||||||
|
|
||||||
|
def _build_resource_context(resources: dict) -> str:
|
||||||
|
"""Build a text summary of existing resources for injection into system prompt."""
|
||||||
|
lines = ["## RECURSOS OCI EXISTENTES NO COMPARTMENT"]
|
||||||
|
total = 0
|
||||||
|
resource_labels = {
|
||||||
|
"vcns": ("VCNs", lambda r: f" - {r['display_name']} (CIDR: {','.join(r.get('cidr_blocks') or [])}) [OCID: {r['id'][:30]}...]"),
|
||||||
|
"subnets": ("Subnets", lambda r: f" - {r['display_name']} (CIDR: {r['cidr_block']}, {'pública' if r.get('public') else 'privada'}, VCN: {r['vcn_id'][:30]}...)"),
|
||||||
|
"internet_gateways": ("Internet Gateways", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
|
||||||
|
"nat_gateways": ("NAT Gateways", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
|
||||||
|
"route_tables": ("Route Tables", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
|
||||||
|
"security_lists": ("Security Lists", lambda r: f" - {r['display_name']} (VCN: {r['vcn_id'][:30]}...)"),
|
||||||
|
"instances": ("Compute Instances", lambda r: f" - {r['display_name']} (Shape: {r['shape']}, Estado: {r['state']})"),
|
||||||
|
"autonomous_databases": ("Autonomous Databases", lambda r: f" - {r['display_name']} (DB: {r['db_name']}, Free Tier: {r.get('is_free_tier', False)})"),
|
||||||
|
"block_volumes": ("Block Volumes", lambda r: f" - {r['display_name']} ({r.get('size_in_gbs', '?')} GB)"),
|
||||||
|
"load_balancers": ("Load Balancers", lambda r: f" - {r['display_name']} (Shape: {r.get('shape_name', '?')})"),
|
||||||
|
}
|
||||||
|
for key, (label, formatter) in resource_labels.items():
|
||||||
|
items = resources.get(key, [])
|
||||||
|
if items:
|
||||||
|
total += len(items)
|
||||||
|
lines.append(f"\n**{label}** ({len(items)}):")
|
||||||
|
for item in items[:15]: # limit to 15 per category
|
||||||
|
lines.append(formatter(item))
|
||||||
|
if len(items) > 15:
|
||||||
|
lines.append(f" ... e mais {len(items) - 15}")
|
||||||
|
if total == 0:
|
||||||
|
lines.append("\nNenhum recurso encontrado neste compartment. Todos os recursos serão novos.")
|
||||||
|
else:
|
||||||
|
lines.append(f"\n**Total: {total} recursos existentes.** REUTILIZE-OS sempre que possível usando data sources.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _fix_single_line_blocks(content: str) -> str:
|
||||||
|
"""Expand ANY single-line HCL block with multiple args into multi-line format.
|
||||||
|
Matches both top-level (variable, resource) AND nested blocks (ingress_security_rules, route_rules, etc.)
|
||||||
|
Does NOT match map assignments (tags = { ... }) because of the '=' before '{'."""
|
||||||
|
import re as _re
|
||||||
|
def _expand(m):
|
||||||
|
header, body = m.group(1), m.group(2)
|
||||||
|
# Split by comma first
|
||||||
|
args = [a.strip() for a in body.split(',') if a.strip()]
|
||||||
|
if len(args) < 2:
|
||||||
|
# Try space-separated key=value
|
||||||
|
args = _re.findall(r'\w+\s*=\s*(?:"[^"]*"|\S+)', body)
|
||||||
|
if len(args) < 2:
|
||||||
|
return m.group(0)
|
||||||
|
indent = _re.match(r'^(\s*)', header).group(1)
|
||||||
|
return header.rstrip() + ' {\n' + '\n'.join(indent + ' ' + a for a in args) + '\n' + indent + '}'
|
||||||
|
return _re.sub(
|
||||||
|
r'^(\s*[a-z]\w*(?:\s+"[^"]*")*)\s*\{\s*(.+?)\s*\}\s*$',
|
||||||
|
_expand, content, flags=_re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Constants ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TFP_SYSTEM_PROMPT = """Você é um arquiteto sênior de infraestrutura OCI com profundo conhecimento do Terraform OCI Provider.
|
||||||
|
O usuário vai descrever a infraestrutura desejada em linguagem natural. Sua tarefa é gerar um **prompt detalhado e estruturado** que será enviado a um agente Terraform para criar o código HCL.
|
||||||
|
|
||||||
|
### Formato do Prompt Gerado
|
||||||
|
Retorne APENAS o prompt estruturado (não gere código Terraform). Use markdown:
|
||||||
|
|
||||||
|
1. **Título**: Uma linha descrevendo o projeto (ex: "## Infraestrutura Multi-Region HA para Aplicação Web")
|
||||||
|
2. **Arquitetura**: Diagrama textual ou descrição da topologia
|
||||||
|
3. **Recursos por Categoria**: Organizados em seções ## (Networking, Compute, Storage, Database, Security, Observability)
|
||||||
|
- Para cada recurso: nome, especificações técnicas, relacionamentos
|
||||||
|
4. **Networking**: Sempre inclua CIDRs (10.0.0.0/16 para VCN, /24 para subnets), gateways necessários, route tables, security lists/NSGs
|
||||||
|
5. **Dependências**: Liste dependências entre recursos (ex: "Compute depende de Subnet, que depende de VCN")
|
||||||
|
6. **Requisitos Técnicos**: Seção final com regras para o agente Terraform:
|
||||||
|
- Separação em arquivos (provider.tf, variables.tf, vcn.tf, subnets.tf, compute.tf, outputs.tf)
|
||||||
|
- Variáveis obrigatórias (compartment_id, region, ssh_public_key, etc.)
|
||||||
|
- Naming convention (ex: proj-env-resource)
|
||||||
|
- Outputs necessários (IPs, OCIDs, endpoints)
|
||||||
|
- Tags padrão (Environment, Project, ManagedBy=Terraform)
|
||||||
|
|
||||||
|
### Regras de Inferência
|
||||||
|
- Se pedir VCN, inclua automaticamente: subnets (pública + privada), internet gateway, NAT gateway, service gateway, route tables, security lists
|
||||||
|
- Se pedir Compute, inclua: boot volume, NSG, cloud-init básico, shape e imagem
|
||||||
|
- Se pedir Database, inclua: subnet privada dedicada, NSG com porta do DB, backup policy
|
||||||
|
- Se pedir OKE, inclua: VCN com topologia para K8s (API endpoint subnet, workers subnet, pods subnet, services LB subnet)
|
||||||
|
- Se mencionar multi-region, use providers aliased e detalhe RPC/DRG
|
||||||
|
- Se mencionar HA/DR, inclua redundância cross-AD ou cross-region
|
||||||
|
- Se mencionar segurança/CIS, inclua Vault, Cloud Guard, WAF, logging, encryption at rest
|
||||||
|
|
||||||
|
### Restrições do OCI Provider
|
||||||
|
- RPC (Remote Peering Connection): NÃO usar oci_core_drg_attachment para RPC. O attachment é criado automaticamente pelo recurso oci_core_remote_peering_connection
|
||||||
|
- Cada VCN suporta apenas 1 DRG attachment — não criar duplicados
|
||||||
|
- DRG attachment type para VCN é "VCN", não "REMOTE_PEERING_CONNECTION"
|
||||||
|
|
||||||
|
### Restrições do Workspace
|
||||||
|
- NUNCA sugira o uso de `module` blocks. O workspace é flat (diretório único, sem subdiretórios). Toda infraestrutura deve ser definida com `resource` e `data` blocks diretamente.
|
||||||
|
- Variáveis devem ser declaradas SOMENTE em `variables.tf`. Nunca duplicar declarações de variáveis entre arquivos.
|
||||||
|
|
||||||
|
### Tom
|
||||||
|
- Seja técnico e preciso
|
||||||
|
- Use português brasileiro
|
||||||
|
- Não gere código, apenas o prompt estruturado"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── OCI Resource Actions ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/api/oci/instances/{instance_id}/action")
|
||||||
|
async def oci_instance_action(instance_id: str, req: dict, u=Depends(current_user)):
|
||||||
|
action = req.get("action", "").upper()
|
||||||
|
oci_config_id = req.get("oci_config_id", "")
|
||||||
|
region = req.get("region")
|
||||||
|
if action not in ("START", "STOP"):
|
||||||
|
raise HTTPException(400, "Ação inválida. Use START ou STOP.")
|
||||||
|
if not oci_config_id:
|
||||||
|
raise HTTPException(400, "oci_config_id obrigatório")
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
try:
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if region:
|
||||||
|
config["region"] = region
|
||||||
|
compute = oci.core.ComputeClient(config)
|
||||||
|
compute.instance_action(instance_id, action)
|
||||||
|
_audit(u["id"], u["username"], f"instance_{action.lower()}", instance_id)
|
||||||
|
return {"ok": True, "action": action, "instance_id": instance_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/oci/autonomous-databases/{adb_id}/action")
|
||||||
|
async def oci_adb_action(adb_id: str, req: dict, u=Depends(current_user)):
|
||||||
|
action = req.get("action", "").lower()
|
||||||
|
oci_config_id = req.get("oci_config_id", "")
|
||||||
|
region = req.get("region")
|
||||||
|
if action not in ("start", "stop"):
|
||||||
|
raise HTTPException(400, "Ação inválida. Use start ou stop.")
|
||||||
|
if not oci_config_id:
|
||||||
|
raise HTTPException(400, "oci_config_id obrigatório")
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
try:
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if region:
|
||||||
|
config["region"] = region
|
||||||
|
db_client = oci.database.DatabaseClient(config)
|
||||||
|
if action == "start":
|
||||||
|
db_client.start_autonomous_database(adb_id)
|
||||||
|
else:
|
||||||
|
db_client.stop_autonomous_database(adb_id)
|
||||||
|
_audit(u["id"], u["username"], f"adb_{action}", adb_id)
|
||||||
|
return {"ok": True, "action": action, "adb_id": adb_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/oci/autonomous-databases/{adb_id}/update-network-access")
|
||||||
|
async def oci_adb_update_network(adb_id: str, req: dict, u=Depends(current_user)):
|
||||||
|
oci_config_id = req.get("oci_config_id", "")
|
||||||
|
ip = req.get("ip", "")
|
||||||
|
region = req.get("region")
|
||||||
|
if not oci_config_id:
|
||||||
|
raise HTTPException(400, "oci_config_id obrigatório")
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
if not ip:
|
||||||
|
raise HTTPException(400, "ip obrigatório")
|
||||||
|
try:
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if region:
|
||||||
|
config["region"] = region
|
||||||
|
db_client = oci.database.DatabaseClient(config)
|
||||||
|
adb = db_client.get_autonomous_database(adb_id).data
|
||||||
|
current_acl = adb.whitelisted_ips or []
|
||||||
|
# Build new ACL: keep existing entries, add new IP if not present
|
||||||
|
ip_cidr = ip if "/" in ip else ip + "/32"
|
||||||
|
new_acl = list(set(current_acl) | {ip_cidr})
|
||||||
|
db_client.update_autonomous_database(
|
||||||
|
adb_id,
|
||||||
|
oci.database.models.UpdateAutonomousDatabaseDetails(whitelisted_ips=new_acl))
|
||||||
|
_audit(u["id"], u["username"], "adb_update_network", adb_id, f"ip={ip_cidr}")
|
||||||
|
return {"ok": True, "adb_id": adb_id, "ip_added": ip_cidr, "acl": new_acl}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/oci/db-systems/{db_system_id}/action")
|
||||||
|
async def oci_db_system_action(db_system_id: str, req: dict, u=Depends(current_user)):
|
||||||
|
action = req.get("action", "").upper()
|
||||||
|
oci_config_id = req.get("oci_config_id", "")
|
||||||
|
region = req.get("region")
|
||||||
|
if action not in ("START", "STOP"):
|
||||||
|
raise HTTPException(400, "Ação inválida. Use START ou STOP.")
|
||||||
|
if not oci_config_id:
|
||||||
|
raise HTTPException(400, "oci_config_id obrigatório")
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
try:
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if region: config["region"] = region
|
||||||
|
db_client = oci.database.DatabaseClient(config)
|
||||||
|
if action == "START":
|
||||||
|
db_client.start_db_system(db_system_id)
|
||||||
|
else:
|
||||||
|
db_client.stop_db_system(db_system_id)
|
||||||
|
_audit(u["id"], u["username"], f"db_system_{action.lower()}", db_system_id)
|
||||||
|
return {"ok": True, "action": action, "db_system_id": db_system_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/oci/mysql-db-systems/{db_system_id}/action")
|
||||||
|
async def oci_mysql_action(db_system_id: str, req: dict, u=Depends(current_user)):
|
||||||
|
action = req.get("action", "").lower()
|
||||||
|
oci_config_id = req.get("oci_config_id", "")
|
||||||
|
region = req.get("region")
|
||||||
|
if action not in ("start", "stop"):
|
||||||
|
raise HTTPException(400, "Ação inválida. Use start ou stop.")
|
||||||
|
if not oci_config_id:
|
||||||
|
raise HTTPException(400, "oci_config_id obrigatório")
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
try:
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if region: config["region"] = region
|
||||||
|
mysql = oci.mysql.DbSystemClient(config)
|
||||||
|
if action == "start":
|
||||||
|
mysql.start_db_system(db_system_id)
|
||||||
|
else:
|
||||||
|
shutdown_type = req.get("shutdown_type", "SLOW")
|
||||||
|
mysql.stop_db_system(db_system_id, oci.mysql.models.StopDbSystemDetails(shutdown_type=shutdown_type))
|
||||||
|
_audit(u["id"], u["username"], f"mysql_{action}", db_system_id)
|
||||||
|
return {"ok": True, "action": action, "db_system_id": db_system_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/oci/container-instances/{ci_id}/action")
|
||||||
|
async def oci_container_instance_action(ci_id: str, req: dict, u=Depends(current_user)):
|
||||||
|
action = req.get("action", "").lower()
|
||||||
|
oci_config_id = req.get("oci_config_id", "")
|
||||||
|
region = req.get("region")
|
||||||
|
if action not in ("start", "stop"):
|
||||||
|
raise HTTPException(400, "Ação inválida. Use start ou stop.")
|
||||||
|
if not oci_config_id:
|
||||||
|
raise HTTPException(400, "oci_config_id obrigatório")
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
try:
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if region: config["region"] = region
|
||||||
|
ci = oci.container_instances.ContainerInstanceClient(config)
|
||||||
|
if action == "start":
|
||||||
|
ci.start_container_instance(ci_id)
|
||||||
|
else:
|
||||||
|
ci.stop_container_instance(ci_id)
|
||||||
|
_audit(u["id"], u["username"], f"container_instance_{action}", ci_id)
|
||||||
|
return {"ok": True, "action": action, "container_instance_id": ci_id}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/oci/my-ip")
|
||||||
|
async def get_my_ip(request: Request):
|
||||||
|
"""Return the caller's public IP address."""
|
||||||
|
forwarded = request.headers.get("x-forwarded-for", "")
|
||||||
|
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "")
|
||||||
|
return {"ip": ip}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Terraform Resources ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/api/terraform/resources")
|
||||||
|
async def tf_list_resources(oci_config_id: str = Query(...), compartment_id: str = Query(...), region: str = Query(None), u=Depends(current_user)):
|
||||||
|
"""List existing OCI resources in a compartment for terraform context."""
|
||||||
|
_verify_config_access("oci", oci_config_id, u)
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
resources = await loop.run_in_executor(
|
||||||
|
_chat_executor, partial(_fetch_compartment_resources, oci_config_id, compartment_id, region))
|
||||||
|
return resources
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, str(e)[:500])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Terraform Generate Prompt ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/api/terraform/generate-prompt")
|
||||||
|
async def tf_generate_prompt(req: TfPromptReq, bg: BackgroundTasks, u=Depends(current_user)):
|
||||||
|
gc = None
|
||||||
|
if req.genai_config:
|
||||||
|
if req.genai_config.get("genai_config_id"):
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT * FROM genai_configs WHERE id=?", (req.genai_config["genai_config_id"],)).fetchone()
|
||||||
|
if row: gc = dict(row)
|
||||||
|
elif req.genai_config.get("oci_config_id"):
|
||||||
|
oci_id = req.genai_config["oci_config_id"]
|
||||||
|
with db() as c:
|
||||||
|
oc = c.execute("SELECT * FROM oci_configs WHERE id=?", (oci_id,)).fetchone()
|
||||||
|
if oc:
|
||||||
|
region = req.genai_config.get("genai_region") or oc["region"]
|
||||||
|
compartment = _safe_dec(dict(oc).get("compartment_id") or oc["tenancy_ocid"])
|
||||||
|
gc = {
|
||||||
|
"oci_config_id": oci_id, "model_id": req.genai_config.get("model_id", "openai.gpt-4.1"),
|
||||||
|
"model_ocid": None, "compartment_id": compartment, "genai_region": region,
|
||||||
|
"endpoint": f"https://inference.generativeai.{region}.oci.oraclecloud.com",
|
||||||
|
"serving_type": "ON_DEMAND", "dedicated_endpoint_id": None,
|
||||||
|
"temperature": 0.7, "max_tokens": 4000, "top_p": 0.9,
|
||||||
|
}
|
||||||
|
if not gc:
|
||||||
|
with db() as c:
|
||||||
|
row = c.execute("SELECT * FROM genai_configs WHERE user_id=? AND is_default=1", (u["id"],)).fetchone()
|
||||||
|
if not row:
|
||||||
|
row = c.execute("SELECT * FROM genai_configs WHERE user_id=? LIMIT 1", (u["id"],)).fetchone()
|
||||||
|
if row: gc = dict(row)
|
||||||
|
if not gc:
|
||||||
|
raise HTTPException(400, "Nenhuma configuração GenAI disponível")
|
||||||
|
# Session persistence
|
||||||
|
is_new = not req.session_id
|
||||||
|
sid = req.session_id or str(uuid.uuid4())
|
||||||
|
with db() as c:
|
||||||
|
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(str(uuid.uuid4()), sid, u["id"], "user", req.message, gc.get("model_id"), "done"))
|
||||||
|
if is_new:
|
||||||
|
title = (req.message or "Prompt Generator")[:80].strip()
|
||||||
|
c.execute("INSERT OR IGNORE INTO chat_sessions (id,user_id,agent_type,title) VALUES (?,?,?,?)",
|
||||||
|
(sid, u["id"], "tf-prompt", title))
|
||||||
|
else:
|
||||||
|
c.execute("UPDATE chat_sessions SET updated_at=datetime('now') WHERE id=?", (sid,))
|
||||||
|
# Load TF resource reference (compact — same as Terraform Agent)
|
||||||
|
tf_ref = _load_tf_resource_reference()
|
||||||
|
system_with_ref = TFP_SYSTEM_PROMPT
|
||||||
|
if tf_ref:
|
||||||
|
sections = []
|
||||||
|
for line in tf_ref.split('\n'):
|
||||||
|
if line.startswith('## All Resource Types'):
|
||||||
|
break
|
||||||
|
sections.append(line)
|
||||||
|
ref_compact = '\n'.join(sections).strip()
|
||||||
|
if ref_compact:
|
||||||
|
system_with_ref += "\n\n### Referência de Recursos OCI Terraform (gerado do provider schema)\n" + \
|
||||||
|
"Use EXATAMENTE estes nomes de resource types. Se o recurso não estiver nesta lista, ele NÃO EXISTE no provider.\n\n" + \
|
||||||
|
ref_compact
|
||||||
|
|
||||||
|
# Detect resource types from user message + history and fetch official docs
|
||||||
|
detect_text = req.message
|
||||||
|
if req.history:
|
||||||
|
for h in req.history[-3:]:
|
||||||
|
detect_text += "\n" + (h.get("content", "") or "")
|
||||||
|
resource_types = _detect_tf_resource_types(detect_text)
|
||||||
|
if resource_types:
|
||||||
|
docs_ctx = _get_tf_docs_for_resources(resource_types)
|
||||||
|
if docs_ctx:
|
||||||
|
system_with_ref += "\n\n" + docs_ctx
|
||||||
|
log.info(f"TF Prompt Generator: injected {len(resource_types)} resource docs ({len(docs_ctx)} chars)")
|
||||||
|
|
||||||
|
gc["system_prompt"] = system_with_ref
|
||||||
|
gc["temperature"] = 0.7
|
||||||
|
gc["max_tokens"] = 4000
|
||||||
|
# Build history from conversation (normalize roles to lowercase for _call_genai)
|
||||||
|
hist = None
|
||||||
|
if req.history:
|
||||||
|
hist = [{"role": "user" if m.get("role","").upper() in ("USER","user") else "assistant",
|
||||||
|
"content": m.get("content", "")} for m in req.history]
|
||||||
|
# Async background processing — same pattern as Chat/Terraform agents
|
||||||
|
mid = str(uuid.uuid4())
|
||||||
|
with db() as c:
|
||||||
|
c.execute("INSERT INTO chat_messages (id,session_id,user_id,role,content,model_id,status) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(mid, sid, u["id"], "assistant", "", gc.get("model_id"), "processing"))
|
||||||
|
def _tfp_background():
|
||||||
|
try:
|
||||||
|
answer, _, _ = _call_genai(gc, req.message, history=hist)
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE chat_messages SET content=?, status='done' WHERE id=?", (answer, mid))
|
||||||
|
log.info(f"TF Prompt Generator completed: mid={mid} len={len(answer)}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"tf_generate_prompt error: {e}")
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE chat_messages SET content=?, status='failed' WHERE id=?", (str(e)[:500], mid))
|
||||||
|
bg.add_task(_tfp_background)
|
||||||
|
return {"message_id": mid, "session_id": sid, "status": "processing"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Terraform Chat ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/api/terraform/chat")
|
||||||
|
async def terraform_chat(msg: ChatMsg, bg: BackgroundTasks, u=Depends(current_user)):
|
||||||
|
sid, mid_or_result, genai_cfg = _chat_start(msg, u, agent_type="terraform")
|
||||||
|
if genai_cfg is None:
|
||||||
|
return mid_or_result
|
||||||
|
bg.add_task(_chat_background, mid_or_result, sid, msg, dict(u), genai_cfg, None, "terraform")
|
||||||
|
return {"message_id": mid_or_result, "session_id": sid, "status": "processing"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Terraform Workspaces CRUD ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/api/terraform/workspaces")
|
||||||
|
async def tf_create_workspace(req: TfWorkspaceReq, u=Depends(current_user)):
|
||||||
|
wid = str(uuid.uuid4())
|
||||||
|
with db() as c:
|
||||||
|
c.execute(
|
||||||
|
"INSERT INTO terraform_workspaces (id,user_id,session_id,oci_config_id,compartment_id,name,tf_code,status) VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
(wid, u["id"], req.session_id, req.oci_config_id, req.compartment_id, req.name, req.tf_code, "draft"))
|
||||||
|
return {"id": wid, "status": "draft"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/terraform/workspaces")
|
||||||
|
async def tf_list_workspaces(u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
rows = c.execute(
|
||||||
|
"""SELECT tw.id,tw.name,tw.session_id,tw.oci_config_id,tw.compartment_id,tw.status,
|
||||||
|
tw.plan_output,tw.apply_output,tw.destroy_output,tw.error,tw.created_at,tw.updated_at,
|
||||||
|
cs.title as session_title
|
||||||
|
FROM terraform_workspaces tw
|
||||||
|
INNER JOIN chat_sessions cs ON cs.id = tw.session_id AND cs.user_id = tw.user_id
|
||||||
|
WHERE tw.user_id=? ORDER BY tw.updated_at DESC""",
|
||||||
|
(u["id"],)).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/terraform/workspaces/{wid}")
|
||||||
|
async def tf_get_workspace(wid: str, u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
|
||||||
|
if not r: raise HTTPException(404)
|
||||||
|
return dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/terraform/workspaces/{wid}/code")
|
||||||
|
async def tf_update_code(wid: str, req: dict, u=Depends(current_user)):
|
||||||
|
code = req.get("tf_code", "")
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT id FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
|
||||||
|
if not r: raise HTTPException(404)
|
||||||
|
c.execute("UPDATE terraform_workspaces SET tf_code=?, status='draft', updated_at=datetime('now') WHERE id=?", (code, wid))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/terraform/workspaces/{wid}/plan")
|
||||||
|
async def tf_run_plan(wid: str, bg: BackgroundTasks, u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
|
||||||
|
if not ws: raise HTTPException(404)
|
||||||
|
if ws["status"] in ("planning", "applying", "destroying"):
|
||||||
|
raise HTTPException(400, f"Workspace is {ws['status']}")
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE terraform_workspaces SET status='planning', plan_output='', apply_output='', destroy_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,))
|
||||||
|
bg.add_task(_terraform_exec, wid, "plan", dict(u))
|
||||||
|
return {"status": "planning"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/terraform/workspaces/{wid}/apply")
|
||||||
|
async def tf_run_apply(wid: str, bg: BackgroundTasks, u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
|
||||||
|
if not ws: raise HTTPException(404)
|
||||||
|
if ws["status"] not in ("planned", "applied", "destroyed", "failed"):
|
||||||
|
raise HTTPException(400, f"Cannot apply in status {ws['status']}. Run plan first.")
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE terraform_workspaces SET status='applying', apply_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,))
|
||||||
|
bg.add_task(_terraform_exec, wid, "apply", dict(u))
|
||||||
|
return {"status": "applying"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/terraform/workspaces/{wid}/destroy")
|
||||||
|
async def tf_run_destroy(wid: str, req: dict, bg: BackgroundTasks, u=Depends(current_user)):
|
||||||
|
if req.get("confirm") != "DESTROY":
|
||||||
|
raise HTTPException(400, "Confirmação obrigatória: envie {\"confirm\": \"DESTROY\"}")
|
||||||
|
with db() as c:
|
||||||
|
ws = c.execute("SELECT * FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
|
||||||
|
if not ws: raise HTTPException(404)
|
||||||
|
if ws["status"] in ("planning", "applying", "destroying"):
|
||||||
|
raise HTTPException(400, f"Workspace is {ws['status']}")
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE terraform_workspaces SET status='destroying', destroy_output='', error=NULL, updated_at=datetime('now') WHERE id=?", (wid,))
|
||||||
|
bg.add_task(_terraform_exec, wid, "destroy", dict(u))
|
||||||
|
return {"status": "destroying"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/terraform/workspaces/{wid}/status")
|
||||||
|
async def tf_workspace_status(wid: str, u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT status, plan_output, apply_output, destroy_output, error, updated_at FROM terraform_workspaces WHERE id=? AND user_id=?",
|
||||||
|
(wid, u["id"])).fetchone()
|
||||||
|
if not r: raise HTTPException(404)
|
||||||
|
return dict(r)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/terraform/workspaces/{wid}/download")
|
||||||
|
async def tf_download(wid: str, token: str = Query(None), cred: HTTPAuthorizationCredentials = Depends(HTTPBearer(auto_error=False))):
|
||||||
|
u = _user_from_token(token) if token else (await current_user(cred) if cred else None)
|
||||||
|
if not u: raise HTTPException(401, "Not authenticated")
|
||||||
|
with db() as c:
|
||||||
|
r = c.execute("SELECT tf_code, name FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"])).fetchone()
|
||||||
|
if not r or not r["tf_code"]: raise HTTPException(404, "No code found")
|
||||||
|
import re as _re
|
||||||
|
parts = _re.split(r'^//\s*filename:\s*(\S+)\s*$', r["tf_code"], flags=_re.MULTILINE)
|
||||||
|
if len(parts) >= 3:
|
||||||
|
import io, zipfile
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
if parts[0].strip():
|
||||||
|
zf.writestr("main.tf", parts[0].strip())
|
||||||
|
for i in range(1, len(parts), 2):
|
||||||
|
fname = parts[i].strip().replace("/", "_").replace("..", "_")
|
||||||
|
content = parts[i + 1].strip() if i + 1 < len(parts) else ""
|
||||||
|
if fname and content:
|
||||||
|
zf.writestr(fname, content)
|
||||||
|
buf.seek(0)
|
||||||
|
ws_name = r["name"] or "terraform"
|
||||||
|
return Response(content=buf.read(), media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{ws_name}.zip"'})
|
||||||
|
return Response(content=r["tf_code"], media_type="text/plain",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="main.tf"'})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/terraform/workspaces/{wid}/cancel")
|
||||||
|
async def tf_cancel(wid: str, u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
ws = c.execute("SELECT user_id FROM terraform_workspaces WHERE id=?", (wid,)).fetchone()
|
||||||
|
if not ws or ws["user_id"] != u["id"]: raise HTTPException(404)
|
||||||
|
proc = running_terraform.get(wid)
|
||||||
|
if proc:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(proc.wait(), timeout=5)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
proc.kill()
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE terraform_workspaces SET status='failed', error='Cancelado pelo usuário', updated_at=datetime('now') WHERE id=?", (wid,))
|
||||||
|
running_terraform.pop(wid, None)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/terraform/workspaces/{wid}")
|
||||||
|
async def tf_delete_workspace(wid: str, u=Depends(current_user)):
|
||||||
|
with db() as c:
|
||||||
|
c.execute("DELETE FROM terraform_workspaces WHERE id=? AND user_id=?", (wid, u["id"]))
|
||||||
|
wdir = TERRAFORM_DIR / wid
|
||||||
|
if wdir.exists():
|
||||||
|
shutil.rmtree(wdir, ignore_errors=True)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/terraform/refresh-reference")
|
||||||
|
async def tf_refresh_reference(u=Depends(current_user)):
|
||||||
|
"""Regenerate the OCI Terraform resource reference from provider schema (internal, triggered by UI button)."""
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
try:
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(_chat_executor, _regenerate_tf_reference),
|
||||||
|
timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.error("TF reference regeneration timed out after 300s")
|
||||||
|
raise HTTPException(504, "Geração de referência Terraform demorou demais. Tente novamente.")
|
||||||
|
# Add status info
|
||||||
|
p = Path(_TF_RESOURCE_REF_PATH)
|
||||||
|
if p.exists():
|
||||||
|
content = _load_tf_resource_reference()
|
||||||
|
result["lines"] = content.count('\n') if content else 0
|
||||||
|
result["updated_at"] = datetime.fromtimestamp(p.stat().st_mtime).strftime('%d/%m/%Y %H:%M')
|
||||||
|
return result
|
||||||
64
backend/routes/users.py
Normal file
64
backend/routes/users.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""User routes — list, me, update, delete, timezone."""
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import current_user, require, _audit
|
||||||
|
from models import UserUpdateReq
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ── Users ─────────────────────────────────────────────────────────────────────
|
||||||
|
@router.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,timezone,is_active,created_at,last_login,auth_provider FROM users").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
@router.get("/api/users/me")
|
||||||
|
async def me(u=Depends(current_user)):
|
||||||
|
fields = ("id","first_name","last_name","username","email","role","mfa_enabled","timezone","auth_provider","must_change_password")
|
||||||
|
r = {k: u.get(k, "local" if k == "auth_provider" else None) for k in fields}
|
||||||
|
r["must_change_password"] = bool(r.get("must_change_password", 0))
|
||||||
|
return r
|
||||||
|
|
||||||
|
@router.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 req.timezone is not None: sets.append("timezone=?"); vals.append(req.timezone)
|
||||||
|
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}
|
||||||
|
|
||||||
|
@router.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}
|
||||||
|
|
||||||
|
@router.put("/api/users/me/timezone")
|
||||||
|
async def set_user_timezone(body: dict, u=Depends(current_user)):
|
||||||
|
"""Set the current user's timezone."""
|
||||||
|
tz = body.get("timezone", "").strip()
|
||||||
|
if not tz:
|
||||||
|
raise HTTPException(400, "timezone is required")
|
||||||
|
try:
|
||||||
|
import zoneinfo
|
||||||
|
zoneinfo.ZoneInfo(tz)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(400, f"Invalid timezone: {tz}")
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE users SET timezone=? WHERE id=?", (tz, u["id"]))
|
||||||
|
_audit(u["id"], u["username"], "set_timezone", tz)
|
||||||
|
return {"ok": True, "timezone": tz}
|
||||||
|
|
||||||
|
@router.get("/api/users/me/timezone")
|
||||||
|
async def get_user_timezone(u=Depends(current_user)):
|
||||||
|
"""Get the current user's timezone."""
|
||||||
|
return {"timezone": u.get("timezone", "America/Sao_Paulo")}
|
||||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
469
backend/services/chat_background.py
Normal file
469
backend/services/chat_background.py
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
"""Chat Agent background processing — GenAI call, RAG, MCP tool use."""
|
||||||
|
import os, json, uuid, time, re, asyncio, concurrent.futures
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from config import (
|
||||||
|
DATA, OCI_DIR, REPORTS, log, _chat_executor,
|
||||||
|
COMPACT_TOKEN_THRESHOLD, COMPACT_KEEP_RECENT,
|
||||||
|
)
|
||||||
|
from database import db
|
||||||
|
from auth.crypto import _make_token, _safe_dec
|
||||||
|
from auth.jwt_auth import _audit, _chat_log, _verify_config_access
|
||||||
|
from services.genai import (
|
||||||
|
_call_genai, _embed_text, _build_rag_context,
|
||||||
|
_get_active_adb_configs, _get_tables_for_config,
|
||||||
|
_resolve_embed_config, _DIM_TO_MODEL, _get_table_embedding_dim,
|
||||||
|
_vector_search_multi, _relevant_tables,
|
||||||
|
_estimate_tokens, _should_compact, _compact_history,
|
||||||
|
RAG_CONTEXT_TEMPLATE, RAG_DEFAULT_SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
_verify_config_access("genai", msg.genai_config_id, u)
|
||||||
|
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
|
||||||
|
if msg.reasoning_effort is not None: genai_cfg["reasoning_effort"] = msg.reasoning_effort
|
||||||
|
elif msg.model_id and msg.oci_config_id:
|
||||||
|
_verify_config_access("oci", msg.oci_config_id, u)
|
||||||
|
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,
|
||||||
|
"reasoning_effort": msg.reasoning_effort,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = ""
|
||||||
|
# For short follow-up questions, enrich with previous context for better RAG search
|
||||||
|
import re as _re_chat
|
||||||
|
rag_query = msg.message
|
||||||
|
if len(msg.message.split()) <= 8 and history:
|
||||||
|
# Short question — get previous user messages (excluding current which is already in history)
|
||||||
|
prev_user_msgs = [h["content"] for h in history if h["role"] == "user" and h["content"] != msg.message]
|
||||||
|
if prev_user_msgs:
|
||||||
|
rag_query = prev_user_msgs[-1] + " " + msg.message
|
||||||
|
log.info(f"RAG: enriched short query → '{rag_query[:100]}'")
|
||||||
|
elif len(history) >= 2:
|
||||||
|
# Fallback: use last assistant response for context
|
||||||
|
last_assistant = [h["content"][:200] for h in history if h["role"] == "assistant"]
|
||||||
|
if last_assistant:
|
||||||
|
rag_query = last_assistant[-1] + " " + msg.message
|
||||||
|
log.info(f"RAG: enriched from assistant context")
|
||||||
|
|
||||||
|
# Detect CIS recommendation number for exact text filtering
|
||||||
|
cis_chat_match = _re_chat.search(r'(?:cis|recommendation)\s*(\d+\.\d+)', rag_query, _re_chat.IGNORECASE)
|
||||||
|
cis_chat_filter = f"CIS Recommendation: {cis_chat_match.group(1)}" if cis_chat_match else None
|
||||||
|
if cis_chat_filter:
|
||||||
|
log.info(f"RAG: detected CIS filter '{cis_chat_filter}'")
|
||||||
|
|
||||||
|
adb_cfgs = _get_active_adb_configs(user["id"]) if agent_type != "terraform" else []
|
||||||
|
rag_errors = []
|
||||||
|
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, user_id=user["id"])
|
||||||
|
if not emb_genai:
|
||||||
|
continue
|
||||||
|
default_model = adb_cfg.get("embedding_model_id", "cohere.embed-v4.0")
|
||||||
|
tables = _get_tables_for_config(adb_cfg["id"], active_only=True)
|
||||||
|
if not tables:
|
||||||
|
tables = [{"table_name": adb_cfg.get("table_name", "")}]
|
||||||
|
all_table_names = [t["table_name"] for t in tables if t["table_name"]]
|
||||||
|
# Smart skip: only search relevant tables
|
||||||
|
relevant = _relevant_tables(rag_query, all_table_names)
|
||||||
|
skipped = set(all_table_names) - set(relevant)
|
||||||
|
if skipped:
|
||||||
|
log.info(f"RAG: skipped {', '.join(skipped)} (not relevant)")
|
||||||
|
# Auto-detect embedding model (use first table's dim as representative)
|
||||||
|
emb_model = default_model
|
||||||
|
for tbl_name in relevant:
|
||||||
|
try:
|
||||||
|
dim = _get_table_embedding_dim(adb_cfg, tbl_name)
|
||||||
|
if dim and dim in _DIM_TO_MODEL:
|
||||||
|
emb_model = _DIM_TO_MODEL[dim]
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
query_embedding = _embed_text(rag_query, emb_genai, emb_model)
|
||||||
|
# Single connection, multi-table search
|
||||||
|
tbl_top_k = 10 if cis_chat_filter else 3
|
||||||
|
docs = _vector_search_multi(adb_cfg, query_embedding, relevant,
|
||||||
|
top_k_per_table=tbl_top_k, tenancy=rag_tenancy,
|
||||||
|
text_filter=cis_chat_filter, user_id=user["id"])
|
||||||
|
if docs:
|
||||||
|
all_documents.extend(docs)
|
||||||
|
sources = {}
|
||||||
|
for d in docs:
|
||||||
|
sources[d["source"]] = sources.get(d["source"], 0) + 1
|
||||||
|
log.info(f"RAG: {len(docs)} docs — {', '.join(f'{k}:{v}' for k,v in sources.items())}")
|
||||||
|
except Exception as e:
|
||||||
|
err_short = str(e)[:150]
|
||||||
|
log.warning(f"RAG retrieval failed for {adb_cfg.get('config_name','?')}: {err_short}")
|
||||||
|
if "DPY-6001" in str(e) or "DPY-6005" in str(e) or "timeout" in str(e).lower() or "connect" in str(e).lower():
|
||||||
|
rag_errors.append(f"Não foi possível conectar ao ADB ({adb_cfg.get('config_name','?')})")
|
||||||
|
if all_documents:
|
||||||
|
all_documents.sort(key=lambda d: d["distance"])
|
||||||
|
top_limit = 15 if cis_chat_filter else 8
|
||||||
|
rag_context = _build_rag_context(all_documents[:top_limit], max_total_chars=16000 if cis_chat_filter else 12000)
|
||||||
|
# Append connection errors to context so LLM can inform the user
|
||||||
|
if rag_errors and not rag_context:
|
||||||
|
rag_context = "⚠️ AVISO: " + "; ".join(set(rag_errors)) + ". A base de conhecimento não está disponível no momento. Responda com base no seu conhecimento geral, informando que os dados do ADB não puderam ser consultados."
|
||||||
|
elif rag_errors:
|
||||||
|
rag_context += "\n\n⚠️ AVISO: Algumas bases não puderam ser consultadas: " + "; ".join(set(rag_errors))
|
||||||
|
|
||||||
|
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 (capped at 65K to avoid context overflow) + force reasoning_effort=high ──
|
||||||
|
if agent_type == "terraform":
|
||||||
|
tf_model_info = GENAI_MODELS.get(cfg_dict.get("model_id", ""), {})
|
||||||
|
tf_model_max = tf_model_info.get("max_tokens", 32768)
|
||||||
|
cfg_dict["max_tokens"] = min(tf_model_max, 65000)
|
||||||
|
if tf_model_info.get("reasoning") and not cfg_dict.get("reasoning_effort"):
|
||||||
|
cfg_dict["reasoning_effort"] = "HIGH"
|
||||||
|
|
||||||
|
# ── 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()
|
||||||
|
try:
|
||||||
|
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(
|
||||||
|
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||||
|
tool_defs, None, None, attachments)),
|
||||||
|
timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.error(f"GenAI call timed out after 300s for session {sid}")
|
||||||
|
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
|
||||||
|
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(
|
||||||
|
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||||
|
tool_defs, cohere_results)),
|
||||||
|
timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.error(f"GenAI tool-use call timed out after 300s (Cohere, iteration {iterations})")
|
||||||
|
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
resp_text, tool_calls, tool_calls_raw = await asyncio.wait_for(
|
||||||
|
loop.run_in_executor(
|
||||||
|
_chat_executor, partial(_call_genai, cfg_dict, augmented_message, hist,
|
||||||
|
tool_defs, None, accumulated_msgs)),
|
||||||
|
timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.error(f"GenAI tool-use call timed out after 300s (Generic, iteration {iterations})")
|
||||||
|
raise HTTPException(504, "O modelo de IA demorou demais para responder. Tente novamente.")
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
|
||||||
80
backend/services/cis_reports.py
Normal file
80
backend/services/cis_reports.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""CIS report execution — subprocess management."""
|
||||||
|
import os, json, uuid, asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config import OCI_DIR, REPORTS, log
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import _audit, _config_log
|
||||||
|
from utils import running_reports, classify_report_file
|
||||||
|
|
||||||
|
|
||||||
|
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])
|
||||||
1000
backend/services/compliance_docx.py
Normal file
1000
backend/services/compliance_docx.py
Normal file
File diff suppressed because it is too large
Load Diff
1429
backend/services/compliance_gen.py
Normal file
1429
backend/services/compliance_gen.py
Normal file
File diff suppressed because it is too large
Load Diff
550
backend/services/embeddings.py
Normal file
550
backend/services/embeddings.py
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
"""Embeddings service — chunking, metadata, ADB vector ingest."""
|
||||||
|
import os, json, uuid, time, re, hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
from config import DATA, REPORTS, WALLET_DIR, log, _chat_executor, _EMBED_STATUS_DIR
|
||||||
|
from database import db
|
||||||
|
from auth.jwt_auth import _config_log, _verify_config_access
|
||||||
|
from services.genai import (
|
||||||
|
_get_adb_connection, _resolve_embed_config, _embed_text,
|
||||||
|
_DIM_TO_MODEL, _get_table_embedding_dim, _get_active_adb_configs,
|
||||||
|
_get_tables_for_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_metadata_json(tenancy: str = "", compartments: str = "", section: str = "",
|
||||||
|
report_date: str = "", user_id: 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 user_id:
|
||||||
|
meta["user_id"] = user_id
|
||||||
|
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, task_id: 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-detect embedding dimension from existing table data and use matching model
|
||||||
|
try:
|
||||||
|
actual_dim = _get_table_embedding_dim(cfg, table_name)
|
||||||
|
if actual_dim and actual_dim in _DIM_TO_MODEL:
|
||||||
|
detected_model = _DIM_TO_MODEL[actual_dim]
|
||||||
|
if detected_model != emb_model:
|
||||||
|
log.info(f"Ingest: table '{table_name}' has {actual_dim} dims, switching model from {emb_model} to {detected_model}")
|
||||||
|
emb_model = detected_model
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Ingest: failed to detect dimension for '{table_name}': {e}")
|
||||||
|
total = len(documents)
|
||||||
|
# Track status
|
||||||
|
if task_id:
|
||||||
|
_set_embed_status(task_id, {"status": "running", "table": table_name, "tenancy": tenancy or "",
|
||||||
|
"inserted": 0, "total": total, "user_id": user_id, "message": "Iniciando embedding..."})
|
||||||
|
# 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 i, doc in enumerate(documents):
|
||||||
|
try:
|
||||||
|
content = doc.get("content", "")
|
||||||
|
if not content: continue
|
||||||
|
embedding = _embed_text(content, genai_cfg, emb_model)
|
||||||
|
vec = array.array('f', [float(x) for x in 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 "",
|
||||||
|
user_id=user_id,
|
||||||
|
extra={"legacy_metadata": doc.get("metadata", "")} if doc.get("metadata") else None
|
||||||
|
)
|
||||||
|
cur.execute(f"""
|
||||||
|
INSERT INTO "{table_name}" (ID, TEXT, EMBEDDING, METADATA)
|
||||||
|
VALUES (HEXTORAW(:1), :2, :3, :4)
|
||||||
|
""", [uuid.uuid4().hex.upper(), content, vec, metadata])
|
||||||
|
inserted += 1
|
||||||
|
if task_id:
|
||||||
|
_update_embed_status(task_id, {"inserted": inserted, "message": f"Embedding {inserted}/{total}..."})
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to ingest document: {e}")
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
msg = f"{inserted}/{total} documentos ingeridos em {table_name}" + (f" (tenancy: {tenancy})" if tenancy else "")
|
||||||
|
log.info(f"Ingested {inserted}/{total} 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", msg, user_id, username)
|
||||||
|
if task_id:
|
||||||
|
_update_embed_status(task_id, {"status": "done", "inserted": inserted, "message": msg})
|
||||||
|
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)
|
||||||
|
if task_id:
|
||||||
|
_update_embed_status(task_id, {"status": "error", "message": str(e)[:300]})
|
||||||
|
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_cis_pdf(text: str, filename: str, target_chars: int = 7000, overlap_chars: int = 500) -> list:
|
||||||
|
"""Chunk a CIS Foundations Benchmark PDF by recommendation number.
|
||||||
|
Each recommendation (1.1, 1.2, etc.) becomes one or more chunks with overlap.
|
||||||
|
Port of the JavaScript embedding pipeline."""
|
||||||
|
import re as _re
|
||||||
|
|
||||||
|
def normalize(t):
|
||||||
|
t = t.replace('\r', '\n')
|
||||||
|
t = _re.sub(r'[ \t]+\n', '\n', t)
|
||||||
|
t = _re.sub(r'\n{3,}', '\n\n', t)
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
def strip_page_headers(t):
|
||||||
|
# Remove "Page XX" both standalone and at start of lines
|
||||||
|
t = _re.sub(r'^\s*Page\s+\d+\s*$', '', t, flags=_re.MULTILINE | _re.IGNORECASE)
|
||||||
|
t = _re.sub(r'^Page\s+\d+\s+', '', t, flags=_re.MULTILINE | _re.IGNORECASE)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def remove_toc(t):
|
||||||
|
# Remove everything from "Table of Contents" up to the actual recommendations section
|
||||||
|
# The real content starts with "Recommendations\n1 Identity" or "Profile Applicability"
|
||||||
|
toc_start = _re.search(r'\bTable of Contents\b', t, _re.IGNORECASE)
|
||||||
|
if not toc_start:
|
||||||
|
return t
|
||||||
|
# Find where actual recommendation content begins
|
||||||
|
content_start = _re.search(r'\bRecommendations\s*\n\s*1\s+Identity', t[toc_start.start():], _re.IGNORECASE)
|
||||||
|
if not content_start:
|
||||||
|
content_start = _re.search(r'\bProfile Applicability\b', t[toc_start.start():], _re.IGNORECASE)
|
||||||
|
if not content_start:
|
||||||
|
content_start = _re.search(r'\bOverview\b', t[toc_start.start():], _re.IGNORECASE)
|
||||||
|
if not content_start:
|
||||||
|
return t
|
||||||
|
end_pos = toc_start.start() + content_start.start()
|
||||||
|
if end_pos <= toc_start.start():
|
||||||
|
return t
|
||||||
|
return normalize(t[:toc_start.start()] + '\n\n' + t[end_pos:])
|
||||||
|
|
||||||
|
def is_chapter_header(line):
|
||||||
|
l = line.strip()
|
||||||
|
return bool(_re.match(r'^\d+\s+[A-Za-z].+', l)) and not _re.match(r'^\d+\.\d+', l)
|
||||||
|
|
||||||
|
def is_rec_header_start(line):
|
||||||
|
l = line.strip()
|
||||||
|
# Must be "1.1 Word..." but NOT a TOC line (with dots/page numbers)
|
||||||
|
if not _re.match(r'^\d+\.\d+(\.\d+)?\s+[A-Z]', l):
|
||||||
|
return False
|
||||||
|
# Skip TOC lines: contain "...." or end with a page number
|
||||||
|
if '....' in l or _re.search(r'\.\s*\d+\s*$', l):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def header_looks_complete(h):
|
||||||
|
# Complete if has (Manual)/(Automated) or ends with a closing paren
|
||||||
|
if _re.search(r'\(\s*(Manual|Automated)\s*\)', h, _re.IGNORECASE):
|
||||||
|
return True
|
||||||
|
# Also stop if next line starts a known section like "Profile Applicability"
|
||||||
|
return False
|
||||||
|
|
||||||
|
def chunk_text(t):
|
||||||
|
if not t:
|
||||||
|
return []
|
||||||
|
paragraphs = [p.strip() for p in t.split('\n\n') if p.strip()]
|
||||||
|
chunks = []
|
||||||
|
buf = ""
|
||||||
|
def push():
|
||||||
|
nonlocal buf
|
||||||
|
b = buf.strip()
|
||||||
|
if b:
|
||||||
|
chunks.append(b)
|
||||||
|
buf = ""
|
||||||
|
for p in paragraphs:
|
||||||
|
if len(p) > target_chars:
|
||||||
|
push()
|
||||||
|
i = 0
|
||||||
|
while i < len(p):
|
||||||
|
chunks.append(p[i:i + target_chars].strip())
|
||||||
|
i += max(1, target_chars - overlap_chars)
|
||||||
|
continue
|
||||||
|
candidate = f"{buf}\n\n{p}" if buf else p
|
||||||
|
if len(candidate) <= target_chars:
|
||||||
|
buf = candidate
|
||||||
|
else:
|
||||||
|
push()
|
||||||
|
if chunks and overlap_chars > 0:
|
||||||
|
prev = chunks[-1]
|
||||||
|
overlap = prev[max(0, len(prev) - overlap_chars):]
|
||||||
|
buf = f"{overlap}\n\n{p}".strip()
|
||||||
|
else:
|
||||||
|
buf = p
|
||||||
|
push()
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
def remove_appendix(t):
|
||||||
|
"""Remove appendix sections (Assessment Status, Change History, etc.) that pollute embeddings."""
|
||||||
|
for marker in [r'\bAppendix\b', r'\bAssessment Status\b', r'\bChange History\b',
|
||||||
|
r'\bCIS Controls v\d', r'\bDate\s+Version\s+Changes']:
|
||||||
|
m = _re.search(marker, t, _re.IGNORECASE)
|
||||||
|
if m and m.start() > len(t) * 0.7: # only cut if in last 30% of doc
|
||||||
|
t = t[:m.start()].rstrip()
|
||||||
|
break
|
||||||
|
return t
|
||||||
|
|
||||||
|
# Pipeline
|
||||||
|
text = normalize(text)
|
||||||
|
text = strip_page_headers(text)
|
||||||
|
text = remove_toc(text)
|
||||||
|
text = remove_appendix(text)
|
||||||
|
lines = text.split('\n')
|
||||||
|
|
||||||
|
# Segment by recommendation
|
||||||
|
segments = []
|
||||||
|
current = None
|
||||||
|
current_chapter = ""
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(lines):
|
||||||
|
line = lines[i]
|
||||||
|
if is_chapter_header(line):
|
||||||
|
current_chapter = line.strip()
|
||||||
|
if is_rec_header_start(line):
|
||||||
|
if current:
|
||||||
|
segments.append(current)
|
||||||
|
header = line.strip()
|
||||||
|
j = i + 1
|
||||||
|
while j < len(lines) and not header_looks_complete(header):
|
||||||
|
next_line = lines[j].strip()
|
||||||
|
if is_rec_header_start(next_line):
|
||||||
|
break
|
||||||
|
# Stop consuming if we hit a known section start
|
||||||
|
if next_line.startswith('Profile Applicability') or next_line.startswith('Description:'):
|
||||||
|
break
|
||||||
|
if next_line:
|
||||||
|
header = _re.sub(r'\s+', ' ', f"{header} {next_line}").strip()
|
||||||
|
j += 1
|
||||||
|
i = j - 1
|
||||||
|
current = {"header": header, "chapter": current_chapter, "body_lines": []}
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if current:
|
||||||
|
current["body_lines"].append(line)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if current:
|
||||||
|
segments.append(current)
|
||||||
|
|
||||||
|
# Generate chunks
|
||||||
|
documents = []
|
||||||
|
for seg in segments:
|
||||||
|
body = normalize('\n'.join(seg["body_lines"]))
|
||||||
|
if not body:
|
||||||
|
continue
|
||||||
|
rec_match = _re.match(r'^(\d+(\.\d+)+)', seg["header"])
|
||||||
|
rec_number = rec_match.group(1) if rec_match else "unknown"
|
||||||
|
canonical = normalize('\n'.join(filter(None, [
|
||||||
|
f"Recommendation: {seg['header']}",
|
||||||
|
f"Chapter: {seg['chapter']}" if seg['chapter'] else "",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
])))
|
||||||
|
chunks = chunk_text(canonical)
|
||||||
|
for idx, chunk in enumerate(chunks):
|
||||||
|
documents.append({
|
||||||
|
"content": chunk,
|
||||||
|
"source": filename,
|
||||||
|
"metadata": json.dumps({
|
||||||
|
"filename": filename,
|
||||||
|
"recommendationNumber": rec_number,
|
||||||
|
"chapter": seg["chapter"],
|
||||||
|
"source": "CIS-OCI-PDF",
|
||||||
|
"chunkIndex": idx + 1,
|
||||||
|
"chunkCount": len(chunks),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
log.info(f"CIS PDF chunking: {len(segments)} recommendations → {len(documents)} chunks from {filename}")
|
||||||
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_text_file(text: str, filename: str, chunk_size: int = 1000, overlap: int = 200) -> list:
|
||||||
|
"""Split text into chunks by paragraphs with overlap to avoid losing context at boundaries."""
|
||||||
|
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||||||
|
documents = []
|
||||||
|
current_chunk = ""
|
||||||
|
prev_tail = "" # last N chars of previous chunk for overlap
|
||||||
|
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
|
||||||
|
# Keep overlap from end of current chunk
|
||||||
|
prev_tail = current_chunk[-overlap:] if len(current_chunk) > overlap else current_chunk
|
||||||
|
current_chunk = prev_tail + "\n\n" + 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, user_id: str = None):
|
||||||
|
"""Load ADB config and resolve embed config (scoped to user_id).
|
||||||
|
Priority: ADB.genai_config_id → genai by oci_config_id → oci_config directly → user's default."""
|
||||||
|
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, user_id=user_id or cfg.get("user_id"))
|
||||||
|
return cfg, gc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_summary_csv(csv_path: str, tenancy: str, extract_date: str) -> list:
|
||||||
|
"""Chunk the cis_summary_report.csv into documents with tenancy/date metadata.
|
||||||
|
Each row becomes a document with structured content for vector search."""
|
||||||
|
import csv as csvmod
|
||||||
|
p = Path(csv_path)
|
||||||
|
if not p.exists():
|
||||||
|
return []
|
||||||
|
documents = []
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
rows = list(csvmod.DictReader(f))
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
# Group rows by section for richer context
|
||||||
|
sections: dict = {}
|
||||||
|
for row in rows:
|
||||||
|
sec = row.get("Section", "Unknown")
|
||||||
|
sections.setdefault(sec, []).append(row)
|
||||||
|
for sec_name, sec_rows in sections.items():
|
||||||
|
lines = []
|
||||||
|
for r in sec_rows:
|
||||||
|
rec = r.get("Recommendation #", "")
|
||||||
|
title = r.get("Title", "")
|
||||||
|
compliant = r.get("Compliant", "")
|
||||||
|
pct = r.get("Compliance Percentage Per Recommendation", "")
|
||||||
|
findings = r.get("Findings", "0")
|
||||||
|
total = r.get("Total", "0")
|
||||||
|
lines.append(f"Recommendation {rec}: {title} | Status: {compliant} | Compliance: {pct}% | Findings: {findings}/{total}")
|
||||||
|
content = (
|
||||||
|
f"Tenancy: {tenancy}\n"
|
||||||
|
f"Extract Date: {extract_date}\n"
|
||||||
|
f"Section: {sec_name}\n"
|
||||||
|
f"Total Recommendations: {len(sec_rows)}\n\n"
|
||||||
|
+ "\n".join(lines)
|
||||||
|
)
|
||||||
|
documents.append({
|
||||||
|
"content": content,
|
||||||
|
"section": sec_name,
|
||||||
|
"tenancy": tenancy,
|
||||||
|
"metadata": json.dumps({"tenancy": tenancy, "extract_date": extract_date, "section": sec_name})
|
||||||
|
})
|
||||||
|
return documents
|
||||||
|
|
||||||
|
|
||||||
|
# ── CIS Report CSV → ADB Table Mapping ──
|
||||||
|
_CIS_TABLE_MAP = {
|
||||||
|
"Identity_and_Access_Management": "identityandaccess",
|
||||||
|
"Networking": "networking",
|
||||||
|
"Compute": "computeinstances",
|
||||||
|
"Logging_and_Monitoring": "loggingandmonitoring",
|
||||||
|
"Storage_Object_Storage": "objectstorage",
|
||||||
|
"Storage_Block_Volumes": "storageblockvolume",
|
||||||
|
"Storage_File_Storage_Service": "filestorageservice",
|
||||||
|
"Asset_Management": "assetmanagement",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _purge_table_by_tenancy(cfg: dict, table_name: str, tenancy: str, extract_date: str = "") -> int:
|
||||||
|
"""Delete existing embeddings from a table for a specific tenancy (and optionally extract_date).
|
||||||
|
Returns number of rows deleted."""
|
||||||
|
try:
|
||||||
|
conn = _get_adb_connection(cfg)
|
||||||
|
cur = conn.cursor()
|
||||||
|
if extract_date:
|
||||||
|
cur.execute(f"""DELETE FROM "{table_name}" WHERE
|
||||||
|
JSON_VALUE(METADATA, '$.tenancy') = :1 AND
|
||||||
|
JSON_VALUE(METADATA, '$.extract_date') = :2""", [tenancy, extract_date])
|
||||||
|
else:
|
||||||
|
cur.execute(f"""DELETE FROM "{table_name}" WHERE
|
||||||
|
JSON_VALUE(METADATA, '$.tenancy') = :1""", [tenancy])
|
||||||
|
deleted = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
if deleted:
|
||||||
|
log.info(f"Purged {deleted} rows from {table_name} (tenancy={tenancy}, date={extract_date or 'all'})")
|
||||||
|
return deleted
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Purge failed for {table_name}: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_table_for_csv(filename: str) -> str | None:
|
||||||
|
"""Map a CIS report CSV filename to its ADB vector table."""
|
||||||
|
if filename == "cis_summary_report.csv":
|
||||||
|
return "summaryreportcsvvector"
|
||||||
|
for pattern, table in _CIS_TABLE_MAP.items():
|
||||||
|
if pattern in filename:
|
||||||
|
return table
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_findings_csv(csv_path: str, tenancy: str, extract_date: str, max_chars: int = 8000) -> list:
|
||||||
|
"""Chunk a CIS findings CSV into documents. Each row becomes one or more documents.
|
||||||
|
If a row exceeds max_chars (~6000 tokens), it's split into smaller chunks with
|
||||||
|
a context header (tenancy, resource name, ID) repeated in each part."""
|
||||||
|
import csv as csvmod, re as _re
|
||||||
|
p = Path(csv_path)
|
||||||
|
if not p.exists():
|
||||||
|
return []
|
||||||
|
documents = []
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
rows = list(csvmod.DictReader(f))
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
skip_cols = {"extract_date", "deep_link", "domain_deeplink", "defined_tags",
|
||||||
|
"freeform_tags", "system_tags", "external_identifier"}
|
||||||
|
# Extract CIS recommendation number from filename (e.g., cis_Identity_and_Access_Management_1-1.csv → 1.1)
|
||||||
|
rec_match = _re.search(r'_(\d+)-(\d+(?:\.\d+)?)\.csv$', p.name)
|
||||||
|
cis_rec = f"{rec_match.group(1)}.{rec_match.group(2)}" if rec_match else ""
|
||||||
|
# Extract section name from filename
|
||||||
|
sec_match = _re.search(r'^cis_(.+?)_\d+-', p.name)
|
||||||
|
cis_section = sec_match.group(1).replace("_", " ") if sec_match else ""
|
||||||
|
|
||||||
|
meta = json.dumps({"tenancy": tenancy, "extract_date": extract_date, "source": p.name, "cis_recommendation": cis_rec})
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
# Build context header (always repeated in each chunk)
|
||||||
|
header_parts = [f"Tenancy: {tenancy}", f"Extract Date: {extract_date}"]
|
||||||
|
if cis_rec:
|
||||||
|
header_parts.append(f"CIS Recommendation: {cis_rec}")
|
||||||
|
if cis_section:
|
||||||
|
header_parts.append(f"Section: {cis_section}")
|
||||||
|
header_parts.append(f"Status: Non-Compliant")
|
||||||
|
body_parts = []
|
||||||
|
# Identify key fields for the header
|
||||||
|
name = row.get("name") or row.get("display_name") or row.get("username") or ""
|
||||||
|
rid = row.get("id", "")
|
||||||
|
if name:
|
||||||
|
header_parts.append(f"Resource: {name}")
|
||||||
|
if rid:
|
||||||
|
header_parts.append(f"ID: {rid}")
|
||||||
|
|
||||||
|
for col, val in row.items():
|
||||||
|
if col.lower() in skip_cols or not val or not val.strip():
|
||||||
|
continue
|
||||||
|
if col.lower() in ("name", "display_name", "username", "id"):
|
||||||
|
continue # already in header
|
||||||
|
# Clean HYPERLINK formulas
|
||||||
|
if val.startswith("=HYPERLINK"):
|
||||||
|
m = _re.search(r',\s*"([^"]+)"', val)
|
||||||
|
val = m.group(1) if m else val
|
||||||
|
body_parts.append(f"{col}: {val}")
|
||||||
|
|
||||||
|
header = "\n".join(header_parts)
|
||||||
|
body = "\n".join(body_parts)
|
||||||
|
full_content = header + "\n" + body
|
||||||
|
|
||||||
|
if len(full_content) <= max_chars:
|
||||||
|
if len(full_content) > 50:
|
||||||
|
documents.append({"content": full_content, "tenancy": tenancy, "metadata": meta})
|
||||||
|
else:
|
||||||
|
# Split body into chunks, each prefixed with context header
|
||||||
|
chunk_size = max_chars - len(header) - 50 # reserve space for header + part label
|
||||||
|
chunks = []
|
||||||
|
current = ""
|
||||||
|
for line in body_parts:
|
||||||
|
if len(current) + len(line) + 2 > chunk_size and current:
|
||||||
|
chunks.append(current)
|
||||||
|
current = line
|
||||||
|
else:
|
||||||
|
current = current + "\n" + line if current else line
|
||||||
|
if current:
|
||||||
|
chunks.append(current)
|
||||||
|
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
part_label = f"(part {i + 1}/{len(chunks)})" if len(chunks) > 1 else ""
|
||||||
|
content = f"{header}\n{part_label}\n{chunk}".strip()
|
||||||
|
if len(content) > 50:
|
||||||
|
documents.append({"content": content, "tenancy": tenancy, "metadata": meta})
|
||||||
|
|
||||||
|
return documents
|
||||||
|
|
||||||
|
|
||||||
1171
backend/services/genai.py
Normal file
1171
backend/services/genai.py
Normal file
File diff suppressed because it is too large
Load Diff
93
backend/services/mcp_client.py
Normal file
93
backend/services/mcp_client.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""MCP client — tool discovery from MCP servers."""
|
||||||
|
import json
|
||||||
|
from config import MCP_DIR, log
|
||||||
|
from database import db
|
||||||
|
|
||||||
|
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 is_global=1)",
|
||||||
|
(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 Exception as e:
|
||||||
|
log.warning(f"Failed to parse tools JSON for MCP server '{srv.get('name', '?')}': {e}")
|
||||||
|
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 ─────────────────────────────────────────────────────
|
||||||
31
backend/services/oci_helpers.py
Normal file
31
backend/services/oci_helpers.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""OCI SDK client helpers — config loading, signer creation, client factory."""
|
||||||
|
from config import OCI_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def _get_oci_config(oci_config_id: str) -> dict:
|
||||||
|
import oci
|
||||||
|
config_path = str(OCI_DIR / oci_config_id / "config")
|
||||||
|
config = oci.config.from_file(config_path, "DEFAULT")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def _get_oci_signer(oci_config_id: str):
|
||||||
|
"""Return (config, signer) tuple. For session_token auth, returns SecurityTokenSigner; for api_key returns None."""
|
||||||
|
import oci
|
||||||
|
config = _get_oci_config(oci_config_id)
|
||||||
|
if config.get("security_token_file"):
|
||||||
|
from oci.auth.signers import SecurityTokenSigner
|
||||||
|
token_path = config["security_token_file"]
|
||||||
|
with open(token_path) as f:
|
||||||
|
token = f.read().strip()
|
||||||
|
signer = SecurityTokenSigner(token, config["key_file"])
|
||||||
|
return config, signer
|
||||||
|
return config, None
|
||||||
|
|
||||||
|
|
||||||
|
def _oci_client(client_class, oci_config_id: str, **kwargs):
|
||||||
|
"""Create an OCI client with correct auth (api_key or session_token)."""
|
||||||
|
config, signer = _get_oci_signer(oci_config_id)
|
||||||
|
if signer:
|
||||||
|
return client_class(config=config, signer=signer, **kwargs)
|
||||||
|
return client_class(config, **kwargs)
|
||||||
421
backend/services/terraform_exec.py
Normal file
421
backend/services/terraform_exec.py
Normal file
@@ -0,0 +1,421 @@
|
|||||||
|
"""Terraform execution — split monolith, write files, plan/apply/destroy."""
|
||||||
|
import os, json, uuid, asyncio, subprocess, re
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from config import TERRAFORM_DIR, OCI_DIR, log
|
||||||
|
from database import db
|
||||||
|
from auth.crypto import _safe_dec
|
||||||
|
from auth.jwt_auth import _audit, _verify_config_access
|
||||||
|
|
||||||
|
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 for f2 in resource_locations.get(key, []))
|
||||||
|
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: MUST use var.region — model is required to declare it
|
||||||
|
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", "")
|
||||||
|
if not has_region_var:
|
||||||
|
error_msg = (
|
||||||
|
f'ERRO: O código Terraform não declarou variable "region". '
|
||||||
|
f'Isso é obrigatório para garantir que os recursos sejam provisionados na região correta. '
|
||||||
|
f'Adicione no variables.tf: variable "region" {{ type = string; default = "{primary_region}" }}'
|
||||||
|
)
|
||||||
|
log.warning(f"Workspace {wid}: missing variable 'region' — blocking plan")
|
||||||
|
with db() as c:
|
||||||
|
c.execute("UPDATE terraform_workspaces SET status='failed', plan_output=?, error=?, updated_at=datetime('now') WHERE id=?",
|
||||||
|
(error_msg, error_msg, wid))
|
||||||
|
return
|
||||||
|
region_value = 'var.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, ssh_public_key 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 "")
|
||||||
|
try:
|
||||||
|
ssh_pub_key = oci_row["ssh_public_key"] if oci_row else ""
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
ssh_pub_key = ""
|
||||||
|
ssh_pub_key = ssh_pub_key or ""
|
||||||
|
|
||||||
|
# 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", ""),
|
||||||
|
"ssh_public_key": ssh_pub_key,
|
||||||
|
"ssh_authorized_keys": ssh_pub_key,
|
||||||
|
}
|
||||||
|
# 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 ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -25,11 +25,11 @@ def anyio_backend():
|
|||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
def app():
|
def app():
|
||||||
"""Create the FastAPI app with a fresh DB, manually init since ASGI transport skips lifespan."""
|
"""Create the FastAPI app with a fresh DB, manually init since ASGI transport skips lifespan."""
|
||||||
import app as app_module
|
import auth.rate_limit as rl_module
|
||||||
# Disable rate limiting for tests
|
rl_module._LOGIN_MAX = 999999
|
||||||
app_module._LOGIN_MAX = 999999
|
from app import app as fastapi_app, init_db
|
||||||
app_module.init_db()
|
init_db()
|
||||||
return app_module.app
|
return fastapi_app
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
|
|||||||
60
backend/utils.py
Normal file
60
backend/utils.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Shared utilities — upload validation, embed status tracking, global process registries."""
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from fastapi import HTTPException, UploadFile
|
||||||
|
from config import _EMBED_STATUS_DIR, _MAX_UPLOAD_BYTES
|
||||||
|
|
||||||
|
# ── Global process registries (used by routes and shutdown handler) ───────────
|
||||||
|
running_reports: dict[str, asyncio.subprocess.Process] = {}
|
||||||
|
running_terraform: dict[str, asyncio.subprocess.Process] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Embed status (file-based) ────────────────────────────────────────────────
|
||||||
|
def set_embed_status(task_id: str, data: dict):
|
||||||
|
(_EMBED_STATUS_DIR / f"{task_id}.json").write_text(json.dumps(data), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def get_embed_status(task_id: str) -> dict | None:
|
||||||
|
p = _EMBED_STATUS_DIR / f"{task_id}.json"
|
||||||
|
if p.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_embed_status(task_id: str, updates: dict):
|
||||||
|
current = get_embed_status(task_id) or {}
|
||||||
|
current.update(updates)
|
||||||
|
set_embed_status(task_id, current)
|
||||||
|
|
||||||
|
|
||||||
|
# ── File upload validation ────────────────────────────────────────────────────
|
||||||
|
async def validate_upload(file: UploadFile, allowed_exts: set[str] | None = None, max_bytes: int = _MAX_UPLOAD_BYTES):
|
||||||
|
"""Validate uploaded file size and extension. Returns file data bytes."""
|
||||||
|
data = await file.read()
|
||||||
|
if len(data) > max_bytes:
|
||||||
|
raise HTTPException(400, f"Arquivo excede o limite de {max_bytes // (1024*1024)}MB")
|
||||||
|
if allowed_exts and file.filename:
|
||||||
|
ext = Path(file.filename).suffix.lower()
|
||||||
|
if ext not in allowed_exts:
|
||||||
|
raise HTTPException(400, f"Extensão '{ext}' não permitida. Aceitas: {', '.join(sorted(allowed_exts))}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ── Report file classification ────────────────────────────────────────────────
|
||||||
|
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"
|
||||||
Reference in New Issue
Block a user