""" ================================================================================ AKIRA — INFRA WATCHDOG ================================================================================ Thread de background permanente que monitoriza a saúde dos dois servidores: - HF Spaces (Python): CPU, RAM, Disco, Logs Python - Railway (Node.js): Pedido via remote_action ao BotCore Regras de sigilo: - Erros críticos → DM privada para 244937035662 - Erros resolvidos silenciosamente → só registo no DB e no log - Nunca fala sobre infra no chat público ================================================================================ """ import threading import time import os import re import json from datetime import datetime from typing import Optional, Dict, Any from loguru import logger try: import psutil PSUTIL_OK = True except ImportError: PSUTIL_OK = False logger.warning("⚠️ [WATCHDOG] psutil não instalado — métricas de hardware indisponíveis.") OWNER_NUMBER = "244937035662" # Thresholds de alerta RAM_CRITICAL_PERCENT = 88.0 DISK_CRITICAL_PERCENT = 90.0 CPU_CRITICAL_PERCENT = 95.0 # Intervalo entre rondas de inspeção (segundos) INSPECTION_INTERVAL = 600 # 10 minutos # Padrões de erro nos logs que disparam alertas CRITICAL_LOG_PATTERNS = [ r"CRITICAL", r"OOMKilled", r"MemoryError", r"Killed process", r"Connection refused", r"SSL: CERTIFICATE_VERIFY_FAILED", r"No space left on device", ] # Callback global para enviar DM (injectado no startup) _send_dm_callback = None _db_instance = None def init_watchdog(send_dm_fn, db_instance): """Inicializa o watchdog com as dependências necessárias.""" global _send_dm_callback, _db_instance _send_dm_callback = send_dm_fn _db_instance = db_instance def _get_python_server_metrics() -> Dict[str, Any]: """Recolhe métricas do servidor Python (HF Spaces).""" if not PSUTIL_OK: return {"erro": "psutil não disponível"} # Tenta obter limites do container (CGroups) se disponível mem_total = 0 mem_used = 0 try: if os.path.exists('/sys/fs/cgroup/memory/memory.limit_in_bytes'): with open('/sys/fs/cgroup/memory/memory.limit_in_bytes', 'r') as f: mem_total = int(f.read().strip()) with open('/sys/fs/cgroup/memory/memory.usage_in_bytes', 'r') as f: mem_used = int(f.read().strip()) except: pass mem = psutil.virtual_memory() # Se os dados do cgroup parecerem realistas (não o infinito do host), usa-os if 0 < mem_total < 200000000000: # < 200GB (frequentemente host RAM) total_mb = round(mem_total / (1024 ** 2), 0) used_mb = round(mem_used / (1024 ** 2), 0) percent = round((mem_used / mem_total) * 100, 1) source = "Container (CGroups)" else: total_mb = round(mem.total / (1024 ** 2), 0) used_mb = round(mem.used / (1024 ** 2), 0) percent = round(mem.percent, 1) source = "Host (Shared)" disk = psutil.disk_usage("/") cpu = psutil.cpu_percent(interval=1) return { "servidor": "HF Spaces (Python)", "source": source, "cpu_percent": round(cpu, 1), "ram_total_mb": total_mb, "ram_used_mb": used_mb, "ram_percent": percent, "disco_livre_gb": round(disk.free / (1024 ** 3), 2), "disco_percent": round(disk.percent, 1), "timestamp": datetime.now().isoformat() } def _read_python_logs(lines: int = 100) -> str: """Lê as últimas N linhas dos ficheiros de log Python.""" log_candidates = [ "/akira/logs/akira.log", "./akira.log", "./app.log", "./error.log", ] for path in log_candidates: if os.path.exists(path): try: with open(path, "r", encoding="utf-8", errors="ignore") as f: return "".join(f.readlines()[-lines:]) except Exception: pass return "" def _detect_critical_log_events(log_content: str) -> list: """Detecta eventos críticos nos logs.""" found = [] for pattern in CRITICAL_LOG_PATTERNS: matches = re.findall(f"(.{{0,80}}{pattern}.{{0,80}})", log_content, re.IGNORECASE) found.extend(matches[:3]) # Max 3 ocorrências por padrão return found def _save_system_event(tipo: str, servidor: str, descricao: str, acao: str, resolvido: bool = True): """Guarda um evento no banco de dados (tabela system_events).""" if _db_instance is None: return try: _db_instance._execute_with_retry( """INSERT INTO system_events (tipo, servidor, descricao, acao_tomada, resolvido, created_at) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""", (tipo, servidor, descricao, acao, 1 if resolvido else 0), commit=True ) except Exception as e: logger.error(f"[WATCHDOG] Erro ao salvar event: {e}") # Variável global para controlar o cooldown de alertas (não inundar o dono) _last_alert_time = 0 _alert_lock = threading.Lock() # ✅ FIX: Previne race condition / alertas duplicados ALERT_COOLDOWN = 1800 # 30 minutos entre alertas idênticos def _send_owner_alert(message: str, urgent: bool = False): """Envia DM sigilosa ao proprietário com sistema de cooldown thread-safe.""" global _last_alert_time current_time = time.time() # ✅ FIX: Lock para garantir que apenas um envio ocorre mesmo com threads simultâneas with _alert_lock: if not urgent and (current_time - _last_alert_time < ALERT_COOLDOWN): logger.info("[WATCHDOG] Alerta ignorado devido ao cooldown.") return prefix = "🚨 *URGENTE — AKIRA INFRA ALERT*" if urgent else "🔐 *RELATÓRIO SIGILOSO — AKIRA*" full_msg = f"{prefix}\n\n{message}\n\n⏱ {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}" if _send_dm_callback: try: _send_dm_callback(OWNER_NUMBER, full_msg) _last_alert_time = current_time # Atualiza DENTRO do lock para prevenir duplos logger.info(f"✅ [WATCHDOG] Alerta enviado para {OWNER_NUMBER}") except Exception as e: logger.error(f"❌ [WATCHDOG] Falha ao enviar DM: {e}") else: logger.warning(f"[WATCHDOG] DM callback não configurado. Mensagem: {full_msg}") def run_inspection_round(): """Executa uma ronda completa de inspeção da infraestrutura.""" logger.info("🔍 [WATCHDOG] Iniciando ronda de inspeção...") alerts = [] actions_taken = [] # ─── 1. Métricas do Servidor Python ─── try: metrics = _get_python_server_metrics() logger.info(f"📊 [WATCHDOG] HF Spaces → RAM: {metrics.get('ram_percent')}% | CPU: {metrics.get('cpu_percent')}% | Disco: {metrics.get('disco_percent')}%") if metrics.get("ram_percent", 0) >= RAM_CRITICAL_PERCENT: source = metrics.get('source', 'Host') # ✅ FIX: Não alertar por RAM do Host partilhado — é falso alarme # O host partilhado do HF Spaces quase sempre tem RAM alta; não podemos controlar isso. if source == "Host (Shared)": logger.info(f"[WATCHDOG] RAM alta no host partilhado ({metrics['ram_percent']}%) — ignorando (não é o nosso container).") else: msg = f"🔴 RAM crítica [{source}]: {metrics['ram_percent']}% ({metrics['ram_used_mb']}MB usados de {metrics['ram_total_mb']}MB)" alerts.append(msg) logger.error(f"[WATCHDOG] {msg}") # Tentar limpar cache Python try: import gc gc.collect() actions_taken.append("✅ Limpeza de garbage collection executada") logger.info("[WATCHDOG] GC executado para reduzir RAM.") except Exception: pass if metrics.get("disco_percent", 0) >= DISK_CRITICAL_PERCENT: msg = f"🔴 Disco crítico: {metrics['disco_percent']}% utilizado (apenas {metrics['disco_livre_gb']}GB livres)" alerts.append(msg) logger.error(f"[WATCHDOG] {msg}") if metrics.get("cpu_percent", 0) >= CPU_CRITICAL_PERCENT: msg = f"⚠️ CPU muito alta: {metrics['cpu_percent']}%" alerts.append(msg) logger.warning(f"[WATCHDOG] {msg}") except Exception as e: logger.error(f"[WATCHDOG] Erro ao recolher métricas: {e}") # ─── 2. Análise de Logs Python ─── try: log_content = _read_python_logs(100) if log_content: critical_events = _detect_critical_log_events(log_content) if critical_events: alerts.append(f"🔴 Eventos críticos nos logs Python:\n" + "\n".join([f" • {e}" for e in critical_events[:5]])) logger.warning(f"[WATCHDOG] {len(critical_events)} evento(s) crítico(s) nos logs.") except Exception as e: logger.error(f"[WATCHDOG] Erro ao ler logs: {e}") # ─── 3. Enviar DM se houver alertas ─── if alerts: report = "📋 *Relatório de Saúde da Infraestrutura*\n\n" report += "\n\n".join(alerts) if actions_taken: report += "\n\n✅ *Ações Tomadas Automaticamente:*\n" + "\n".join(actions_taken) urgent = any("🔴" in a for a in alerts) _send_owner_alert(report, urgent=urgent) _save_system_event( tipo="CRÍTICO" if urgent else "AVISO", servidor="hf_spaces", descricao="; ".join(alerts[:3]), acao="; ".join(actions_taken) or "Nenhuma ação automática", resolvido=bool(actions_taken) ) else: logger.info("✅ [WATCHDOG] Ronda concluída — nenhum problema detetado.") class InfraWatchdog: """Watchdog de infraestrutura que corre em thread de background.""" def __init__(self, interval: int = INSPECTION_INTERVAL): self.interval = interval self._thread: Optional[threading.Thread] = None self._running = False def start(self): """Inicia o watchdog em background.""" if self._running: logger.warning("[WATCHDOG] Já está em execução.") return self._running = True self._thread = threading.Thread(target=self._loop, daemon=True, name="KiamiInfraWatchdog") self._thread.start() logger.info(f"🟢 [WATCHDOG] Iniciado (intervalo: {self.interval}s)") # ✅ FIX: Timer inicial removido para evitar ronda dupla com o loop principal. # O loop já aguarda 'interval' segundos antes da primeira inspeção. def stop(self): self._running = False logger.info("[WATCHDOG] Parado.") def _loop(self): while self._running: time.sleep(self.interval) if self._running: try: run_inspection_round() except Exception as e: logger.error(f"❌ [WATCHDOG] Erro na ronda: {e}") # Instância global singleton watchdog = InfraWatchdog()