Spaces:
Configuration error
Configuration error
| """ | |
| database_router.py — Router Query Intelligente per Separazione Workload Supabase A/B/C/D | |
| Architettura: | |
| A: Analytics/Cache/Read-Heavy (reporting, dashboard, cache distribuito) | |
| B: Sync/State/Transazioni (stato globale, sincronizzazione cluster — PRIMARY) | |
| C: Memory/RAG/Embeddings (backend memoria, vector search, skill index) | |
| D: Audit/Logging/Compliance (event log, audit trail, compliance records) | |
| Routing Logic: | |
| 1. Query di LETTURA (SELECT) → Preferisci A (read replica), fallback a B | |
| 2. Query di SCRITTURA (INSERT/UPDATE) → Usa B (PRIMARY) | |
| 3. Query su MEMORIA/RAG (skill_memory, embeddings, conversations) → Usa C | |
| 4. Query su AUDIT/LOG (audit_events, compliance_log) → Usa D | |
| 5. Query di SINCRONIZZAZIONE (cluster_state, global_state) → Usa B | |
| """ | |
| import asyncio | |
| import os | |
| import logging | |
| import re as _re | |
| from typing import Optional, Literal | |
| from enum import Enum | |
| from fastapi import APIRouter, HTTPException, Request, Depends | |
| from .auth_guard import require_role, AuthRole | |
| from pydantic import BaseModel | |
| router = APIRouter(prefix="/api/database", tags=["database"]) | |
| _logger = logging.getLogger("database_router") | |
| # ─── Enumerazione Nodi Supabase ─────────────────────────────────────────── | |
| class SupabaseNode(str, Enum): | |
| A = "A" # Analytics/Cache | |
| B = "B" # PRIMARY (Sync/State) | |
| C = "C" # Memory/RAG | |
| D = "D" # Audit/Logging | |
| # ─── Configurazione Nodi ────────────────────────────────────────────────── | |
| SUPABASE_CONFIG = { | |
| "A": { | |
| "url": os.getenv("SUPABASE_URL_A", ""), | |
| "key": os.getenv("SUPABASE_KEY_A", ""), | |
| "role": "Analytics/Cache (Read-Heavy)", | |
| "priority": 1, # Preferito per letture | |
| }, | |
| "B": { | |
| "url": os.getenv("SUPABASE_URL", ""), # PRIMARY | |
| "key": os.getenv("SUPABASE_KEY", ""), | |
| "role": "Sync/State (PRIMARY)", | |
| "priority": 0, # Fallback universale | |
| }, | |
| "C": { | |
| "url": os.getenv("SUPABASE_URL_C", ""), | |
| "key": os.getenv("SUPABASE_KEY_C", ""), | |
| "role": "Memory/RAG/Embeddings", | |
| "priority": 2, | |
| }, | |
| "D": { | |
| "url": os.getenv("SUPABASE_URL_D", ""), | |
| "key": os.getenv("SUPABASE_KEY_D", ""), | |
| "role": "Audit/Logging/Compliance", | |
| "priority": 3, | |
| }, | |
| } | |
| # ─── Keyword Pericolosi (per read-only) ─────────────────────────────────── | |
| _DANGEROUS = frozenset( | |
| {"drop", "truncate", "delete", "update", "insert", "alter", "create", "grant", "revoke"} | |
| ) | |
| _MAX_ROWS = 500 | |
| # ─── Modelli Pydantic ───────────────────────────────────────────────────── | |
| class QueryRequest(BaseModel): | |
| sql: str | |
| params: list = [] | |
| read_only: bool = True | |
| preferred_node: Optional[SupabaseNode] = None # Override routing logic | |
| class QueryResponse(BaseModel): | |
| ok: bool | |
| rows: list = [] | |
| columns: list = [] | |
| count: int = 0 | |
| truncated: bool = False | |
| node_used: Optional[str] = None | |
| error: Optional[str] = None | |
| # ─── Funzioni Utility ───────────────────────────────────────────────────── | |
| def _is_dangerous(sql: str) -> Optional[str]: | |
| """Rilevamento keyword pericolose robusto contro CTE e multi-spazio.""" | |
| s_norm = " ".join(sql.strip().lower().split()) | |
| tokens = _re.split(r"[\s\(\),;]+", s_norm) | |
| for tok in tokens: | |
| if tok in _DANGEROUS: | |
| return tok | |
| for m in _re.finditer(r"\bas\s*\(\s*(\w+)", s_norm): | |
| first_word = m.group(1).lower() | |
| if first_word in _DANGEROUS: | |
| return first_word | |
| return None | |
| def _detect_query_type(sql: str) -> Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"]: | |
| """Rileva il tipo di query (SELECT, INSERT, UPDATE, DELETE, OTHER).""" | |
| s_norm = " ".join(sql.strip().upper().split()) | |
| if s_norm.startswith("SELECT"): | |
| return "SELECT" | |
| elif s_norm.startswith("INSERT"): | |
| return "INSERT" | |
| elif s_norm.startswith("UPDATE"): | |
| return "UPDATE" | |
| elif s_norm.startswith("DELETE"): | |
| return "DELETE" | |
| return "OTHER" | |
| def _detect_table_context(sql: str) -> Optional[str]: | |
| """Rileva il contesto della tabella per routing intelligente.""" | |
| sql_lower = sql.lower() | |
| # Tabelle di memoria/RAG → Nodo C | |
| if any(t in sql_lower for t in ["skill_memory", "embeddings", "conversations", "rag_index", "vector_store"]): | |
| return "C" | |
| # Tabelle di audit/logging → Nodo D | |
| if any(t in sql_lower for t in ["audit_events", "audit_log", "compliance_log", "event_log", "activity_log"]): | |
| return "D" | |
| # Tabelle di stato globale → Nodo B | |
| if any(t in sql_lower for t in ["cluster_state", "global_state", "sync_state", "agent_state", "daemon_status"]): | |
| return "B" | |
| return None | |
| def _choose_node( | |
| query_type: Literal["SELECT", "INSERT", "UPDATE", "DELETE", "OTHER"], | |
| table_context: Optional[str], | |
| preferred_node: Optional[SupabaseNode], | |
| ) -> SupabaseNode: | |
| """ | |
| Logica di routing intelligente per scegliere il nodo Supabase. | |
| Priorità: | |
| 1. preferred_node (override esplicito) | |
| 2. table_context (rilevamento tabella) | |
| 3. query_type (tipo di query) | |
| 4. Fallback a B (PRIMARY) | |
| """ | |
| # 1. Override esplicito | |
| if preferred_node: | |
| return preferred_node | |
| # 2. Routing per contesto tabella | |
| if table_context: | |
| return SupabaseNode(table_context) | |
| # 3. Routing per tipo query | |
| if query_type == "SELECT": | |
| # Preferisci A (read replica) se disponibile, altrimenti B | |
| if SUPABASE_CONFIG["A"]["url"]: | |
| return SupabaseNode.A | |
| return SupabaseNode.B | |
| elif query_type in ("INSERT", "UPDATE", "DELETE"): | |
| # Sempre su B (PRIMARY) | |
| return SupabaseNode.B | |
| # 4. Fallback a B (PRIMARY) | |
| return SupabaseNode.B | |
| # ─── Endpoint Principale ────────────────────────────────────────────────── | |
| async def database_query( | |
| req: QueryRequest, | |
| role: AuthRole = Depends(require_role(AuthRole.MACHINE)), | |
| ): | |
| """ | |
| Endpoint query con routing intelligente tra nodi Supabase A/B/C/D. | |
| Parametri: | |
| - sql: query SQL | |
| - params: parametri query | |
| - read_only: blocca query pericolose (default: true) | |
| - preferred_node: forza un nodo specifico (opzionale) | |
| Ritorna: | |
| - ok: successo | |
| - rows: righe risultato | |
| - columns: nomi colonne | |
| - count: numero righe | |
| - truncated: se risultato è stato troncato | |
| - node_used: nodo Supabase utilizzato | |
| """ | |
| # Rileva tipo query e contesto | |
| query_type = _detect_query_type(req.sql) | |
| table_context = _detect_table_context(req.sql) | |
| # Scegli nodo | |
| chosen_node = _choose_node(query_type, table_context, req.preferred_node) | |
| # Verifica configurazione nodo | |
| node_config = SUPABASE_CONFIG.get(chosen_node.value) | |
| if not node_config or not node_config["url"]: | |
| # Fallback a B se nodo non configurato | |
| if chosen_node != SupabaseNode.B: | |
| _logger.warning( | |
| f"Nodo {chosen_node.value} non configurato, fallback a B. " | |
| f"Configura SUPABASE_URL_{chosen_node.value} e SUPABASE_KEY_{chosen_node.value}." | |
| ) | |
| chosen_node = SupabaseNode.B | |
| node_config = SUPABASE_CONFIG["B"] | |
| if not node_config["url"]: | |
| return QueryResponse( | |
| ok=False, | |
| error=f"Nodo {chosen_node.value} non configurato. Imposta SUPABASE_URL_{chosen_node.value}.", | |
| node_used=chosen_node.value, | |
| ) | |
| # ── GAP-DB-QUERY-PUBLIC fix: forziamo read_only basandoci sul contenuto ── | |
| # Non ci fidiamo di req.read_only dal client per la sicurezza. | |
| _sql_upper = req.sql.upper() | |
| _is_write = any(kw in _sql_upper for kw in ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"]) | |
| # Se la query contiene keyword di scrittura, richiediamo esplicitamente permessi OPERATOR o superiore | |
| # In questo endpoint MACHINE (default) permettiamo solo SELECT. | |
| if _is_write and role < AuthRole.OPERATOR: | |
| raise HTTPException(403, "Permessi insufficienti per query di scrittura (richiesto OPERATOR)") | |
| # Verifica read-only | |
| if req.read_only or not _is_write: | |
| kw = _is_dangerous(req.sql) | |
| if kw: | |
| return QueryResponse( | |
| ok=False, | |
| error=f"Query bloccata (read-only): '{kw.upper()}' non consentito.", | |
| node_used=chosen_node.value, | |
| ) | |
| # Esegui query | |
| try: | |
| result = await _execute_query( | |
| node_config["url"], | |
| node_config["key"], | |
| req.sql, | |
| req.params, | |
| ) | |
| result["node_used"] = chosen_node.value | |
| return QueryResponse(**result) | |
| except Exception as e: | |
| _logger.error(f"Errore query su nodo {chosen_node.value}: {str(e)}") | |
| return QueryResponse( | |
| ok=False, | |
| error=str(e)[:400], | |
| node_used=chosen_node.value, | |
| ) | |
| # ─── Esecuzione Query (Supabase PostgreSQL) ─────────────────────────────── | |
| async def _execute_query(url: str, key: str, sql: str, params: list) -> dict: | |
| """Esegue query su Supabase PostgreSQL.""" | |
| try: | |
| import psycopg2 | |
| import psycopg2.extras | |
| except ImportError: | |
| return { | |
| "ok": False, | |
| "error": "psycopg2 non installato. Aggiungi 'psycopg2-binary' a requirements.txt.", | |
| } | |
| def _run(): | |
| conn = psycopg2.connect(url) | |
| try: | |
| cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) | |
| cur.execute(sql, params or None) | |
| try: | |
| rows = [dict(r) for r in cur.fetchmany(_MAX_ROWS)] | |
| cols = [d.name for d in (cur.description or [])] | |
| except psycopg2.ProgrammingError: | |
| rows, cols = [], [] | |
| conn.commit() | |
| finally: | |
| conn.close() | |
| return rows, cols | |
| rows, cols = await asyncio.to_thread(_run) | |
| return { | |
| "ok": True, | |
| "rows": rows, | |
| "columns": cols, | |
| "count": len(rows), | |
| "truncated": len(rows) == _MAX_ROWS, | |
| } | |
| # ─── Endpoint Debug (info nodi) ─────────────────────────────────────────── | |
| async def nodes_status(): | |
| """Ritorna lo stato di configurazione di tutti i nodi Supabase.""" | |
| status = {} | |
| for node_id, config in SUPABASE_CONFIG.items(): | |
| status[node_id] = { | |
| "role": config["role"], | |
| "configured": bool(config["url"]), | |
| "url_preview": config["url"][:30] + "..." if config["url"] else "NOT SET", | |
| } | |
| return {"nodes": status} | |