feat(dron): Migración a AtomicDrones con arquitectura DB-First

Implementación del sistema AtomicDrones basado en "Menos es Más":
- Refactorización de dron.rb a dispatcher + módulos atómicos
- Nuevos módulos: ejecutor, vigilante, sanador, bitacora
- DronDB para acceso a datos en PostgreSQL
- Tablas: dron_logs, dron_avances, dron_metricas
- Views: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal
- Health check con detección de zombies (heartbeat > 5min)
- Dashboard compacto de la flota
- Saneamiento con reintentos (backoff exponencial) y limpieza

Comandos soportados:
- dron lanzar: Ejecutar comando como dron
- dron flota: Dashboard de la flota
- dron salud: Health check activo
- dron estado <ID>: Estado detallado
- dron sanear: Saneamiento completo
- dron limpiar: Limpieza de antiguos
- dron reintentar: Re-intentar fallidos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ricardo Monla
2026-04-07 11:35:13 -03:00
co-authored by Claude Opus 4.6
parent b5e89144e3
commit 3543e62263
9 changed files with 1968 additions and 485 deletions
+197
View File
@@ -0,0 +1,197 @@
# frozen_string_literal: true
# Dron::Bitacora — Módulo atómico para registro en bitácora humana
#
# Responsabilidad única: Gestionar registro de eventos en bitácora
# - Crea eventos ⏳ al iniciar dron
# - Actualiza eventos ✅ o ❌ al finalizar
# - Soporta modo AUTO (crea y cierra automáticamente)
#
# Uso:
# require_relative 'dron/bitacora'
# Dron::Bitacora.iniciar(nota: 'Backup SQL')
# Dron::Bitacora.finalizar(evento_id, exit_code: 0)
require_relative '../../db/core/bitacora_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Bitacora
class << self
# Registrar inicio de actividad en bitácora
#
# @param nota [String] Nota descriptiva de la actividad
# @param nodo_id [Integer, nil] ID del nodo (default: 1 = srv-ns8)
# @param modo [String] Modo de ejecución (P=presencial, R=remoto, A=automático)
# @return [Integer, nil] ID del evento creado o nil si falló
def iniciar(nota:, nodo_id: 1, modo: 'A')
@logger = ADN::Logger.new
descripcion = "🛸 dron: #{nota}"
hora_inicio = Time.now.strftime('%H:%M')
@logger.info("📝 Bitácora: Iniciando evento - #{nota}")
sql = <<-SQL
INSERT INTO bitacoras.events (nodo_id, descripcion, estado, modo, inicio)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
SQL
resultado = BitacorasDB::BitacoraDB.ejecutar(sql, [nodo_id, descripcion, '⏳', modo, hora_inicio])
evento_id = resultado.first['id']
@logger.info("📝 Evento creado: ##{evento_id}")
evento_id
rescue StandardError => e
@logger.error("📝 Error creando evento: #{e.message}")
nil
end
# Registrar finalización de actividad en bitácora
#
# @param evento_id [Integer] ID del evento a actualizar
# @param exit_code [Integer] Código de salida (0=éxito, >0=fallo)
# @param nota [String, nil] Nota adicional para el resumen
# @return [Boolean] true si se actualizó correctamente
def finalizar(evento_id, exit_code:, nota: nil)
@logger = ADN::Logger.new
estado_icono = exit_code == 0 ? '✅' : '❌'
hora_fin = Time.now.strftime('%H:%M')
# Obtener evento actual para construir descripción
evento = obtener_evento(evento_id)
return false unless evento
# Construir nueva descripción
descripcion_original = evento['descripcion']
nota_adicional = nota ? "#{nota}" : ""
# Extraer nota original si existe
nota_original = descripcion_original.gsub('🛸 dron: ', '').split(' — ').first
descripcion = "🛸 dron: #{nota_original}#{estado_icono}#{nota_adicional}"
@logger.info("📝 Bitácora: Finalizando evento ##{evento_id} - #{estado_icono}")
sql = <<-SQL
UPDATE bitacoras.events
SET descripcion = $1,
estado = $2,
fin = $3,
updated_at = NOW()
WHERE id = $4
SQL
BitacorasDB::BitacoraDB.ejecutar(sql, [descripcion, estado_icono, hora_fin, evento_id])
@logger.info("📝 Evento ##{evento_id} actualizado")
true
rescue StandardError => e
@logger.error("📝 Error actualizando evento: #{e.message}")
false
end
# Registrar inicio y finalización automática (modo AUTO)
# Útil para comandos rápidos que no requieren seguimiento
#
# @param nota [String] Nota descriptiva
# @param bloque [Proc] Bloque de código a ejecutar
# @return [Hash] Resultado de la ejecución
def auto(nota:, &bloque)
@logger = ADN::Logger.new
# Registrar inicio
evento_id = iniciar(nota: nota)
# Ejecutar bloque
inicio = Time.now
resultado = nil
exit_code = 0
begin
resultado = yield
exit_code = 0
rescue StandardError => e
@logger.error("📝 Error en ejecución AUTO: #{e.message}")
exit_code = 1
end
duracion = (Time.now - inicio).round(2)
# Registrar finalización
finalizar(evento_id, exit_code: exit_code, nota: "#{duracion}s") if evento_id
{
evento_id: evento_id,
exit_code: exit_code,
resultado: resultado,
duracion: duracion
}
end
# Actualizar descripción de un evento
#
# @param evento_id [Integer] ID del evento
# @param descripcion [String] Nueva descripción
# @return [Boolean] true si se actualizó correctamente
def actualizar(evento_id, descripcion:)
@logger = ADN::Logger.new
sql = <<-SQL
UPDATE bitacoras.events
SET descripcion = $1, updated_at = NOW()
WHERE id = $2
SQL
BitacorasDB::BitacoraDB.ejecutar(sql, [descripcion, evento_id])
@logger.info("📝 Evento ##{evento_id} actualizado")
true
rescue StandardError => e
@logger.error("📝 Error actualizando evento: #{e.message}")
false
end
# Obtener información de un evento
#
# @param evento_id [Integer] ID del evento
# @return [Hash, nil] Datos del evento o nil si no existe
def obtener_evento(evento_id)
sql = 'SELECT * FROM bitacoras.events WHERE id = $1'
resultado = BitacorasDB::BitacoraDB.ejecutar(sql, [evento_id])
resultado.first
end
# Vincular un evento con un flujo de drones
#
# @param evento_id [Integer] ID del evento
# @param flujo_id [String] ID del flujo
# @return [Boolean] true si se vinculó correctamente
def vincular_flujo(evento_id, flujo_id)
@logger = ADN::Logger.new
evento = obtener_evento(evento_id)
return false unless evento
metadata = evento['metadata'] || '{}'
metadata_hash = JSON.parse(metadata) rescue {}
metadata_hash['flujo_id'] = flujo_id
sql = <<-SQL
UPDATE bitacoras.events
SET metadata = $1
WHERE id = $2
SQL
BitacorasDB::BitacoraDB.ejecutar(sql, [metadata_hash.to_json, evento_id])
@logger.info("📝 Evento ##{evento_id} vinculado al flujo #{flujo_id}")
true
rescue StandardError => e
@logger.error("📝 Error vinculando flujo: #{e.message}")
false
end
end
end
end
+278
View File
@@ -0,0 +1,278 @@
# frozen_string_literal: true
# Dron::Dispatcher — Dispatcher de subcomandos para AtomicDrones
#
# Responsabilidad única: Despachar comandos a los módulos atómicos correctos
# - Parsea argumentos de línea de comandos
# - Invoca el módulo atómico correspondiente
# - Muestra ayuda formateada
#
# Uso:
# require_relative 'dron/dispatcher'
# Dron::Dispatcher.ejecutar(ARGV)
require_relative '../../core/help_formatter'
require_relative '../../core/logger'
require_relative '../../core/colores'
module Dron
class Dispatcher
def self.ejecutar(args)
@logger ||= ADN::Logger.new
subcomando = args.shift
case subcomando
when 'lanzar', 'run', nil
EjecutorComando.ejecutar(args)
when 'flota', 'list'
VigilanteComando.flota(args)
when 'salud', 'health'
VigilanteComando.salud(args)
when 'sanear', 'clean'
SanadorComando.sanear(args)
when 'limpiar', 'cleanup'
SanadorComando.limpiar(args)
when 'reintentar', 'retry'
SanadorComando.reintentar(args)
when 'estado', 'status'
VigilanteComando.estado(args)
when 'ayuda', 'help', '--help', '-h'
mostrar_ayuda
else
@logger.error("❌ Subcomando desconocido: #{subcomando}")
mostrar_ayuda
exit 1
end
end
def self.mostrar_ayuda
lines = []
lines << "#{Color::BOLD}🛸 Sistema de Drones ADN (AtomicDrones)#{Color::RESET}"
lines << "#{Color::DIM}#{'=' * 50}#{Color::RESET}"
lines << ""
lines << "#{Color::CYAN}Gestión de drones atómicos especializados para automatización de tareas#{Color::RESET}"
lines << ""
lines << "Uso: #{Color::YELLOW}./adn/tools/run dron <subcomando> [opciones]#{Color::RESET}"
lines << ""
lines << "\n#{Color::BOLD}Subcomandos:#{Color::RESET}"
lines << " #{Color::GREEN}lanzar [OPTIONS]#{Color::RESET} Lanzar tarea como dron ejecutor"
lines << " #{Color::GREEN}flota#{Color::RESET} Dashboard compacto de la flota"
lines << " #{Color::GREEN}salud#{Color::RESET} Health check activo (detecta zombies)"
lines << " #{Color::GREEN}estado <ID>#{Color::RESET} Estado detallado de un dron"
lines << " #{Color::GREEN}sanear#{Color::RESET} Sanear flota (reintentar + limpiar)"
lines << " #{Color::GREEN}limpiar#{Color::RESET} Limpiar drones antiguos"
lines << " #{Color::GREEN}reintentar#{Color::RESET} Re-intentar drones fallidos"
lines << ""
lines << "\n#{Color::BOLD}Opciones:#{Color::RESET}"
lines << " #{Color::GREEN}--nota \"texto\"#{Color::RESET} Nota descriptiva para la bitácora"
lines << " #{Color::GREEN}--evento ID#{Color::RESET} Vincular a evento existente"
lines << " #{Color::GREEN}--timeout N#{Color::RESET} Timeout en segundos (default: 3600)"
lines << " #{Color::GREEN}--dry-run#{Color::RESET} Simulación sin ejecutar"
lines << " #{Color::GREEN}--help#{Color::RESET} Mostrar esta ayuda"
lines << ""
lines << "\n#{Color::BOLD}Ejemplos:#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron lanzar --nota 'Backup SQL' -- vzdump 103#{Color::RESET}"
lines << " #{Color::CYAN}Lanzar backup como dron#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron flota#{Color::RESET}"
lines << " #{Color::CYAN}Ver estado de la flota#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron salud#{Color::RESET}"
lines << " #{Color::CYAN}Health check de la flota#{Color::RESET}"
lines << ""
puts lines.join("\n")
end
end
# ============================================================================
# Comandos encapsulados para cada módulo atómico
# ============================================================================
class EjecutorComando
def self.ejecutar(args)
require_relative 'ejecutor'
# Parsear argumentos
opciones = parsear_opciones(args)
if opciones[:cmd].nil?
puts "❌ Error: Debes especificar un comando a ejecutar"
puts ""
puts "Uso: ./adn/tools/run dron lanzar --nota \"Nota\" -- <comando>"
exit 1
end
# Lanzar dron ejecutor
resultado = Dron::Ejecutor.lanzar(
cmd: opciones[:cmd],
nota: opciones[:nota],
evento_id: opciones[:evento_id],
timeout: opciones[:timeout],
flujo_id: opciones[:flujo_id]
)
# Mostrar resultado
puts "\n🛸 Resultado del Dron Ejecutor"
puts "" * 60
puts " Dron ID: #{resultado[:dron_id]}"
puts " Estado: #{resultado[:exit_code] == 0 ? '✅ Completado' : '❌ Fallido'}"
puts " Exit Code: #{resultado[:exit_code]}"
puts " Duración: #{resultado[:duration]}s"
puts "" * 60
exit resultado[:exit_code]
end
def self.parsear_opciones(args)
opciones = {
nota: nil,
evento_id: nil,
timeout: 3600,
flujo_id: nil,
cmd: nil
}
# Parsear opciones con nombre
while args.first&.start_with?('--')
arg = args.shift
case arg
when '--nota'
opciones[:nota] = args.shift
when '--evento'
opciones[:evento_id] = args.shift.to_i
when '--timeout'
opciones[:timeout] = args.shift.to_i
when '--flujo'
opciones[:flujo_id] = args.shift
when '--help', '-h'
Dispatcher.mostrar_ayuda
exit 0
end
end
# El resto es el comando a ejecutar
opciones[:cmd] = args.join(' ') unless args.empty?
opciones
end
end
class VigilanteComando
def self.flota(args)
require_relative 'vigilante'
output = Dron::Vigilante.dashboard
puts output
end
def self.salud(args)
require_relative 'vigilante'
diagnosticos = Dron::Vigilante.salud
exit(diagnosticos[:saludable] ? 0 : 1)
end
def self.estado(args)
require_relative 'vigilante'
require_relative '../../db/core/dron_db'
dron_id = args.first
if dron_id.nil?
puts "❌ Error: Debes especificar un dron_id"
puts "Uso: ./adn/tools/run dron estado <dron_id>"
exit 1
end
dron = ADN::DB::DronDB.obtener_dron(dron_id)
if dron.nil?
puts "❌ Dron no encontrado: #{dron_id}"
exit 1
end
puts "\n📊 Estado del Dron"
puts "" * 60
puts " ID: #{dron['dron_id']}"
puts " Tipo: #{dron['tipo']}"
puts " Estado: #{dron['estado']}"
puts " Comando: #{dron['cmd']}"
puts " Inicio: #{dron['started_at']}"
puts " Fin: #{dron['completed_at'] || '—'}"
puts " Heartbeat: #{dron['heartbeat']}"
puts " Reintentos: #{dron['reintentos']}"
puts " Exit Code: #{dron['exit_code'] || '—'}"
puts "" * 60
# Mostrar avances si existen
require_relative '../../db/core/bitacora_db'
sql = 'SELECT * FROM bitacoras.dron_avances WHERE dron_id = $1 ORDER BY paso ASC'
avances = BitacorasDB::BitacoraDB.ejecutar(sql, [dron_id])
unless avances.empty?
puts "\n📝 Avances:"
avances.each do |avance|
puts " [#{avance['estado']}] Paso #{avance['paso']}: #{avance['descripcion']}"
end
end
end
end
class SanadorComando
def self.sanear(args)
require_relative 'sanador'
opciones = parsear_opciones(args)
resultado = Dron::Sanador.sanear(
dias_limpieza: opciones[:dias],
dry_run: opciones[:dry_run]
)
exit(0)
end
def self.limpiar(args)
require_relative 'sanador'
opciones = parsear_opciones(args)
resultado = Dron::Sanador.limpiar_antiguos(
dias: opciones[:dias],
dry_run: opciones[:dry_run]
)
exit(0)
end
def self.reintentar(args)
require_relative 'sanador'
opciones = parsear_opciones(args)
resultado = Dron::Sanador.reintentar_fallidos(
dry_run: opciones[:dry_run]
)
exit(0)
end
def self.parsear_opciones(args)
opciones = {
dias: 7,
dry_run: false
}
args.each do |arg|
case arg
when '--dry-run'
opciones[:dry_run] = true
when '--dias'
idx = args.index(arg)
opciones[:dias] = args[idx + 1].to_i if args[idx + 1]
end
end
opciones
end
end
end
+233
View File
@@ -0,0 +1,233 @@
# frozen_string_literal: true
# Dron::Ejecutor — Módulo atómico para ejecución de comandos
#
# Responsabilidad única: Ejecutar un comando y reportar resultado
# - Lanza el comando en background
# - Registra inicio/fin en dron_logs (DB)
# - Actualiza heartbeat cada 30s
# - Captura output y exit_code
#
# Uso:
# require_relative 'dron/ejecutor'
# Dron::Ejecutor.lanzar(cmd: 'vzdump 103', nota: 'Backup SQL')
require_relative '../../db/core/dron_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Ejecutor
# Timeout por defecto para comandos (en segundos)
DEFAULT_TIMEOUT = 3600 # 1 hora
# Intervalo de heartbeat (en segundos)
HEARTBEAT_INTERVAL = 30
class << self
# Lanzar un comando como dron ejecutor
#
# @param cmd [String] Comando a ejecutar
# @param nota [String, nil] Nota descriptiva para bitácora
# @param evento_id [Integer, nil] ID de evento en bitácora (si existe)
# @param timeout [Integer] Timeout en segundos (default: 3600)
# @param flujo_id [String, nil] ID de orquestación (si es parte de un flujo)
# @return [Hash] Resultado de la ejecución
def lanzar(cmd:, nota: nil, evento_id: nil, timeout: DEFAULT_TIMEOUT, flujo_id: nil)
@logger = ADN::Logger.new
# Generar ID único para este dron
dron_id = ADN::DB::DronDB.generar_dron_id
# Registrar inicio en DB (dron_logs)
ADN::DB::DronDB.registrar_inicio(
tipo: 'ejecutor',
cmd: cmd,
flujo_id: flujo_id,
metadata: { nota: nota, evento_id: evento_id, timeout: timeout }
)
@logger.info("🛠️ Dron Ejecutor iniciado: #{dron_id}")
@logger.info(" Comando: #{cmd}")
@logger.info(" Timeout: #{timeout}s") if timeout
# Registrar en bitácora humana (evento ⏳)
evento_id = registrar_inicio_bitacora(nota, cmd, evento_id) if nota
# Ejecutar comando en background
resultado = ejecutar_comando(cmd, dron_id, timeout)
# Registrar fin en DB
ADN::DB::DronDB.registrar_fin(dron_id, resultado[:exit_code], resultado[:output])
# Registrar en bitácora humana (evento ✅ o ❌)
registrar_fin_bitacora(evento_id, resultado, nota) if evento_id
@logger.info("🛠️ Dron Ejecutor completado: #{dron_id}")
@logger.info(" Estado: #{resultado[:exit_code] == 0 ? '✅ Completado' : '❌ Fallido'}")
@logger.info(" Duración: #{resultado[:duration]}s")
{
dron_id: dron_id,
exit_code: resultado[:exit_code],
output: resultado[:output],
duration: resultado[:duration],
evento_id: evento_id
}
rescue StandardError => e
# Registrar fallo crítico
ADN::DB::DronDB.registrar_fin(dron_id, 1, "Error: #{e.message}") if dron_id
registrar_fin_bitacora(evento_id, { exit_code: 1, output: e.message, duration: 0 }, nota) if evento_id
@logger.error("🛠️ Dron Ejecutor falló: #{dron_id}")
@logger.error(" Error: #{e.message}")
{
dron_id: dron_id,
exit_code: 1,
output: e.message,
duration: 0,
evento_id: evento_id,
error: e.message
}
end
private
# Ejecutar comando y capturar output
#
# @param cmd [String] Comando a ejecutar
# @param dron_id [String] ID del dron
# @param timeout [Integer] Timeout en segundos
# @return [Hash] Resultado con exit_code, output, duration
def ejecutar_comando(cmd, dron_id, timeout)
start_time = Time.now
output = []
exit_code = nil
# Thread para actualizar heartbeat
heartbeat_thread = Thread.new do
loop do
sleep(HEARTBEAT_INTERVAL)
ADN::DB::DronDB.actualizar_heartbeat(dron_id)
@logger.debug("💓 Heartbeat: #{dron_id}")
end
end
begin
# Ejecutar comando con timeout
Timeout.timeout(timeout) do
# Usar Open3 para capturar output en tiempo real
require 'open3'
Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr|
# Leer stdout y stderr concurrentemente
while stdout.readline || stderr.readline
begin
line = stdout.readline.chomp
output << line if line && !line.empty?
@logger.debug(" #{line}") if line && !line.empty?
rescue EOFError
begin
line = stderr.readline.chomp
output << "[ERR] #{line}" if line && !line.empty?
@logger.debug(" [ERR] #{line}") if line && !line.empty?
rescue EOFError
break
end
end
end
exit_code = wait_thr.value.exitstatus
end
end
rescue Timeout::Error
exit_code = 124 # Código estándar para timeout
output << "\n[TIMEOUT] Comando excedió #{timeout}s de ejecución"
@logger.error(" [TIMEOUT] Comando excedió #{timeout}s")
rescue StandardError => e
exit_code = 1
output << "\n[ERROR] #{e.message}"
@logger.error(" [ERROR] #{e.message}")
ensure
# Detener thread de heartbeat
heartbeat_thread.exit
end
duration = (Time.now - start_time).round(2)
{
exit_code: exit_code,
output: output.join("\n"),
duration: duration
}
end
# Registrar inicio en bitácora humana (evento ⏳)
#
# @param nota [String] Nota descriptiva
# @param cmd [String] Comando ejecutado
# @param evento_id [Integer, nil] ID de evento existente
# @return [Integer] ID del evento creado/actualizado
def registrar_inicio_bitacora(nota, cmd, evento_id = nil)
require_relative '../../db/core/bitacora_db'
descripcion = "🛸 dron: #{nota}"
if evento_id
# Actualizar evento existente
BitacorasDB::BitacoraDB.actualizar_evento(evento_id, descripcion: descripcion)
evento_id
else
# Crear nuevo evento
BitacorasDB::BitacoraDB.crear_evento(
nodo_id: 1, # srv-ns8 por defecto (mejora: hacer configurable)
descripcion: descripcion,
inicio: Time.now.strftime('%H:%M'),
estado: '⏳'
)
end
rescue StandardError => e
@logger.warn("⚠️ No se pudo registrar en bitácora: #{e.message}")
nil
end
# Registrar fin en bitácora humana (evento ✅ o ❌)
#
# @param evento_id [Integer] ID del evento
# @param resultado [Hash] Resultado de la ejecución
# @param nota [String] Nota descriptiva
def registrar_fin_bitacora(evento_id, resultado, nota)
require_relative '../../db/core/bitacora_db'
return unless evento_id
estado = resultado[:exit_code] == 0 ? '✅' : '❌'
duracion = formato_duracion(resultado[:duration])
descripcion = "🛸 dron: #{nota}#{estado} (#{duracion})"
BitacorasDB::BitacoraDB.actualizar_evento(
evento_id,
descripcion: descripcion,
fin: Time.now.strftime('%H:%M')
)
rescue StandardError => e
@logger.warn("⚠️ No se pudo actualizar bitácora: #{e.message}")
end
# Formatear duración en segundos a formato legible
#
# @param segundos [Numeric] Duración en segundos
# @return [String] Duración formateada (ej: "2min 30s")
def formato_duracion(segundos)
minutos = (segundos / 60).to_i
secs = (segundos % 60).to_i
if minutos > 0
"#{minutos}min #{secs}s"
else
"#{secs}s"
end
end
end
end
end
+207
View File
@@ -0,0 +1,207 @@
# frozen_string_literal: true
# Dron::Sanador — Módulo atómico para reparación de drones
#
# Responsabilidad única: Reparar drones zombies/fallidos
# - Re-intenta drones fallidos (máx 3 intentos, backoff exponencial)
# - Limpia drones completados antiguos
# - Notifica fallos persistentes
#
# Uso:
# require_relative 'dron/sanador'
# Dron::Sanador.reintentar_fallidos
# Dron::Sanador.limpiar_completados
require_relative '../../db/core/dron_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Sanador
# Máximo de reintentos antes de marcar como fallo persistente
MAX_REINTENTOS = 3
# Backoff exponencial: 1min, 2min, 4min entre reintentos
BACKOFF_BASE = 60 # segundos
# Días de antigüedad para limpiar completados
CLEANUP_DAYS = 7
class << self
# Re-intentar drones fallidos (máx 3 intentos con backoff)
#
# @param dry_run [Boolean] Si true, solo muestra qué haría
# @return [Hash] Resultado de la operación
def reintentar_fallidos(dry_run: false)
@logger = ADN::Logger.new
@logger.info("🩹 Dron Sanador: Re-intentando drones fallidos...")
# Obtener drones fallidos que puedan reintentarse
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE estado = 'failed'
AND reintentos < #{MAX_REINTENTOS}
AND cmd IS NOT NULL
ORDER BY completed_at ASC
SQL
require_relative '../../db/core/bitacora_db'
fallidos = BitacorasDB::BitacoraDB.ejecutar(sql)
if fallidos.empty?
@logger.info("🩹 No hay drones fallidos para re-intentar")
return { reintentados: 0, omitidos: 0 }
end
reintentados = 0
omitidos = 0
fallidos.each do |dron|
reintentos = dron['reintentos'].to_i
# Calcular tiempo de espera con backoff exponencial
espera = BACKOFF_BASE * (2 ** reintentos) # 60s, 120s, 240s
# Verificar si ya pasó el tiempo de backoff
tiempo_desde_fallo = Time.now - dron['completed_at']
if tiempo_desde_fallo < espera
@logger.debug("#{dron['dron_id']} en backoff (#{tiempo_desde_fallo.round}s/#{espera}s)")
omitidos += 1
next
end
if dry_run
@logger.info("🩹 [DRY RUN] Re-intentaría: #{dron['dron_id']}")
reintentados += 1
else
# Re-intentar el comando
@logger.info("🩹 Re-intentando #{dron['dron_id']} (intento #{reintentos + 1}/#{MAX_REINTENTOS})")
# Incrementar contador de reintentos
nuevos_reintentos = ADN::DB::DronDB.incrementar_reintentos(dron['dron_id'])
# Volver a lanzar el comando
require_relative 'ejecutor'
resultado = Dron::Ejecutor.lanzar(
cmd: dron['cmd'],
nota: "Reintento #{nuevos_reintentos + 1}: #{dron.dig('metadata', 'nota')}",
flujo_id: dron['flujo_id']
)
if resultado[:exit_code] == 0
@logger.info("🩹 ✅ Reintento exitoso: #{dron['dron_id']}")
reintentados += 1
else
@logger.warn("🩹 ❌ Reintento fallido: #{dron['dron_id']}")
# Si llegó al máximo de reintentos, notificar
if nuevos_reintentos >= MAX_REINTENTOS
notificar_fallo_persistente(dron)
end
end
end
end
puts "\n🩹 Resultado de reintentos:"
puts " Reintentados: #{reintentados}"
puts " Omitidos (backoff): #{omitidos}"
{ reintentados: reintentados, omitidos: omitidos }
end
# Limpiar drones completados/failed/zombies antiguos
#
# @param dias [Integer] Días de antigüedad para limpiar
# @param dry_run [Boolean] Si true, solo muestra qué limpiaría
# @return [Hash] Resultado de la operación
def limpiar_antiguos(dias: CLEANUP_DAYS, dry_run: false)
@logger = ADN::Logger.new
@logger.info("🩹 Dron Sanador: Limpiando drones antiguos (>#{dias} días)...")
sql = <<-SQL
SELECT COUNT(*) as cantidad FROM bitacoras.dron_logs
WHERE estado IN ('completed', 'failed', 'zombie')
AND completed_at < NOW() - INTERVAL '#{dias} days'
SQL
require_relative '../../db/core/bitacora_db'
resultado = BitacorasDB::BitacoraDB.ejecutar(sql)
cantidad = resultado.first['cantidad'].to_i
if cantidad == 0
@logger.info("🩹 No hay drones antiguos para limpiar")
return { eliminados: 0 }
end
if dry_run
@logger.info("🩹 [DRY RUN] Eliminaría #{cantidad} drones antiguos")
puts "\n🩹 Drones a limpiar: #{cantidad}"
return { eliminados: 0 }
end
# Eliminar drones antiguos
sql_delete = <<-SQL
DELETE FROM bitacoras.dron_logs
WHERE estado IN ('completed', 'failed', 'zombie')
AND completed_at < NOW() - INTERVAL '#{dias} days'
SQL
BitacorasDB::BitacoraDB.ejecutar(sql_delete)
@logger.info("🩹 ✅ Limpiados #{cantidad} drones antiguos")
puts "\n🩹 Drones eliminados: #{cantidad}"
{ eliminados: cantidad }
end
# Sanear toda la flota (reintentar + limpiar)
#
# @param dias_limpieza [Integer] Días para limpieza
# @param dry_run [Boolean] Si true, modo simulación
# @return [Hash] Resultado combinado
def sanear(dias_limpieza: CLEANUP_DAYS, dry_run: false)
@logger = ADN::Logger.new
@logger.info("🩹 Dron Sanador: Iniciando saneamiento completo...")
resultados = {
reintentos: reintentar_fallidos(dry_run: dry_run),
limpieza: limpiar_antiguos(dias: dias_limpieza, dry_run: dry_run)
}
puts "\n🩹 Saneamiento completado"
puts " Reintentos: #{resultados[:reintentos][:reintentados]}"
puts " Limpieza: #{resultados[:limpieza][:eliminados]}"
resultados
end
# Notificar fallo persistente (tras 3 reintentos fallidos)
#
# @param dron [Hash] Datos del dron con fallo persistente
def notificar_fallo_persistente(dron)
@logger = ADN::Logger.new
@logger.error("🚨 FALLO PERSISTENTE: #{dron['dron_id']}")
@logger.error(" Comando: #{dron['cmd']}")
@logger.error(" Reintentos: #{dron['reintentos']}")
# Registrar en dron_avances como incidente crítico
sql = <<-SQL
INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado)
VALUES ($1, -99, $2, 'critical')
RETURNING *
SQL
descripcion = "FALLO PERSISTENTE tras #{dron['reintentos']} reintentos: #{dron['cmd']&.slice(0, 100)}"
BitacorasDB::BitacoraDB.ejecutar(sql, [dron['dron_id'], descripcion])
# TODO: Integrar con dron_mensajero para notificar por Slack/email
# Dron::Mensajero.notificar(
# tipo: 'critical',
# mensaje: "Dron #{dron['dron_id']} falló persistentemente tras #{dron['reintentos']} reintentos",
# contexto: dron
# )
end
end
end
end
+286
View File
@@ -0,0 +1,286 @@
# frozen_string_literal: true
# Dron::Vigilante — Módulo atómico para monitoreo de flota
#
# Responsabilidad única: Escanear flota y detectar anomalías
# - Lee dron_logs para obtener estado de la flota
# - Detecta zombies (heartbeat > 5min)
# - Registra avances en dron_avances
# - Consolida resumen en events (visible en Bitácora Web)
# - Alerta sobre anomalías
#
# Uso:
# require_relative 'dron/vigilante'
# Dron::Vigilante.escanear_flota
require_relative '../../db/core/dron_db'
require_relative '../../core/constants'
require_relative '../../core/logger'
module Dron
class Vigilante
# Tiempo máximo sin heartbeat antes de considerar zombie (5 minutos)
ZOMBIE_TIMEOUT = 300
# Umbral para alerta de anomalías (>5 fallos en 1 hora)
ANOMALY_THRESHOLD = 5
class << self
# Escanear toda la flota y reportar estado
#
# @return [Hash] Resumen de la flota
def escanear_flota
@logger = ADN::Logger.new
@logger.info("👁️ Dron Vigilante: Escaneando flota...")
resumen = ADN::DB::DronDB.resumen_flota
# Mostrar resumen
puts "\n🛸 Flota de Drones"
puts "" * 60
puts " Total: #{resumen[:total]}"
puts " 🟢 Running: #{resumen[:running] || 0}"
puts " ⏳ Pending: #{resumen[:pending] || 0}"
puts " ✅ Completed: #{resumen[:completed] || 0}"
puts " ❌ Failed: #{resumen[:failed] || 0}"
puts " 🧟 Zombies: #{resumen[:zombie] || 0}"
puts "" * 60
{
exitoso: true,
resumen: resumen,
timestamp: Time.now
}
rescue StandardError => e
@logger.error("👁️ Error escaneando flota: #{e.message}")
{
exitoso: false,
error: e.message,
timestamp: Time.now
}
end
# Detectar drones zombies (heartbeat > 5min)
#
# @return [Array<Hash>] Lista de drones zombies detectados
def detectar_zombies
@logger = ADN::Logger.new
zombies = ADN::DB::DronDB.detectar_zombies
if zombies.empty?
@logger.info("👁️ No hay drones zombies")
return []
end
@logger.warn("👁️ Detectados #{zombies.count} drones zombies:")
zombies.each do |zombie|
minutos = zombie['minutos_sin_heartbeat'].to_f.round(1)
@logger.warn(" 🧟 #{zombie['dron_id']} - #{minutos}min sin heartbeat")
@logger.warn(" Tipo: #{zombie['tipo']}")
@logger.warn(" Cmd: #{zombie['cmd']&.slice(0, 50)}...")
# Marcar como zombie en DB
ADN::DB::DronDB.marcar_zombie(zombie['dron_id'])
# Registrar avance del incidente
registrar_avance_incidente(zombie['dron_id'], "Dron detectado como zombie (#{minutos}min sin heartbeat)")
end
zombies
end
# Verificar salud de la flota (health check activo)
#
# @return [Hash] Diagnóstico de salud
def salud
@logger = ADN::Logger.new
@logger.info("👁️ Dron Vigilante: Health check...")
diagnosticos = {
zombies: [],
fallos_recientes: [],
anomalias: [],
saludable: true
}
# 1. Detectar zombies
zombies = detectar_zombies
diagnosticos[:zombies] = zombies.map { |z| z['dron_id'] }
# 2. Verificar fallos recientes (últimos 60 min)
fallos_recientes = verificar_fallos_recientes
diagnosticos[:fallos_recientes] = fallos_recientes
# 3. Detectar anomalías (>5 fallos en 1h)
if fallos_recientes.count >= ANOMALY_THRESHOLD
diagnosticos[:anomalias] << "Demasiados fallos en la última hora: #{fallos_recientes.count}"
diagnosticos[:saludable] = false
@logger.error("🚨 ANOMALÍA DETECTADA: #{diagnosticos[:anomalias].first}")
end
# 4. Verificar drones running muy antiguos (>1 hora)
running_antiguos = verificar_running_antiguos
unless running_antiguos.empty?
diagnosticos[:anomalias] << "Drones running demasiado antiguos: #{running_antiguos.count}"
diagnosticos[:saludable] = false
end
# Resumen
puts "\n🏥 Salud de la Flota"
puts "" * 60
puts " Estado: #{diagnosticos[:saludable] ? '✅ Saludable' : '🚨 Problemas detectados'}"
puts " Zombies: #{diagnosticos[:zombies].count}"
puts " Fallos recientes: #{diagnosticos[:fallos_recientes].count}"
puts " Anomalías: #{diagnosticos[:anomalias].count}"
unless diagnosticos[:anomalias].empty?
puts "\n Detalle de anomalías:"
diagnosticos[:anomalias].each { |a| puts " ⚠️ #{a}" }
end
puts "" * 60
diagnosticos
end
# Consolidar estado de un flujo y actualizar evento resumen
#
# @param flujo_id [String] ID del flujo a consolidar
# @return [String, nil] Estado consolidado
def consolidar_flujo(flujo_id)
@logger = ADN::Logger.new
estado = ADN::DB::DronDB.consolidar_estado_flujo(flujo_id)
return nil unless estado
drones = ADN::DB::DronDB.obtener_por_flujo(flujo_id)
count = drones.count
# Actualizar evento resumen en bitácora (visible en web)
actualizar_evento_resumen(flujo_id, estado, count)
@logger.info("👁️ Flujo #{flujo_id} consolidado: #{estado} (#{count} drones)")
estado
end
# Obtener dashboard de la flota (versión compacta)
#
# @return [String] Dashboard formateado
def dashboard
resumen = ADN::DB::DronDB.resumen_flota
activos = ADN::DB::DronDB.obtener_activos
output = []
output << "\n🛸 Dashboard de Drones"
output << "" * 70
output << " Total: #{resumen[:total]} | 🟢 #{resumen[:running] || 0} | ⏳ #{resumen[:pending] || 0} | ✅ #{resumen[:completed] || 0} | ❌ #{resumen[:failed] || 0} | 🧟 #{resumen[:zombie] || 0}"
output << "" * 70
unless activos.empty?
output << "\n Activos:"
output << " #{"ID".ljust(25)} | #{"Tipo".ljust(15)} | #{"Inicio".ljust(10)} | Cmd"
output << " " + "" * 70
activos.each do |dron|
id_corto = dron['dron_id'].slice(0, 24)
tipo = dron['tipo'].to_s.slice(0, 14)
inicio = dron['started_at'].strftime('%H:%M:%S')
cmd = dron['cmd']&.slice(0, 30) || ''
output << " #{id_corto.ljust(25)} | #{tipo.ljust(15)} | #{inicio.ljust(10)} | #{cmd}"
end
end
output.join("\n")
end
private
# Verificar fallos en la última hora
#
# @return [Array<Hash>] Lista de drones fallidos recientemente
def verificar_fallos_recientes
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE estado = 'failed'
AND completed_at > NOW() - INTERVAL '60 minutes'
ORDER BY completed_at DESC
SQL
require_relative '../../db/core/bitacora_db'
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
end
# Verificar drones running demasiado antiguos (>1 hora)
#
# @return [Array<Hash>] Lista de drones antiguos
def verificar_running_antiguos
sql = <<-SQL
SELECT * FROM bitacoras.dron_logs
WHERE estado = 'running'
AND started_at < NOW() - INTERVAL '1 hour'
ORDER BY started_at ASC
SQL
require_relative '../../db/core/bitacora_db'
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a }
end
# Registrar avance de incidente en dron_avances
#
# @param dron_id [String] ID del dron
# @param descripcion [String] Descripción del incidente
def registrar_avance_incidente(dron_id, descripcion)
require_relative '../../db/core/bitacora_db'
sql = <<-SQL
INSERT INTO bitacoras.dron_avances (dron_id, paso, descripcion, estado)
VALUES ($1, -1, $2, 'failed')
RETURNING *
SQL
BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id, descripcion]).to_a }
end
# Actualizar evento resumen en bitácora (visible en web)
#
# @param flujo_id [String] ID del flujo
# @param estado [String] Estado consolidado
# @param count [Integer] Cantidad de drones
def actualizar_evento_resumen(flujo_id, estado, count)
require_relative '../../db/core/bitacora_db'
estado_icono = case estado
when 'completed' then '✅'
when 'failed' then '❌'
else '⏳'
end
descripcion = "🛸 Flujo #{flujo_id}: #{count} drones - #{estado_icono}"
# Buscar evento existente por flujo_id en metadata
sql = <<-SQL
SELECT id FROM bitacoras.events
WHERE metadata->>'flujo_id' = $1
SQL
resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [flujo_id]).to_a }
if resultado.empty?
# Crear nuevo evento resumen
BitacorasDB::BitacoraDB.crear_evento(
nodo_id: 1,
descripcion: descripcion,
estado: estado_icono,
metadata: { flujo_id: flujo_id, drones_count: count }.to_json
)
else
# Actualizar evento existente
evento_id = resultado.first['id']
BitacorasDB::BitacoraDB.actualizar_evento(evento_id, descripcion: descripcion, estado: estado_icono)
end
rescue StandardError => e
@logger.warn("⚠️ No se pudo actualizar evento resumen: #{e.message}")
end
end
end
end