"""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")