diff --git a/adn/tools/cli/dron.rb b/adn/tools/cli/dron.rb index 1f2fe1b1..a4d839ad 100644 --- a/adn/tools/cli/dron.rb +++ b/adn/tools/cli/dron.rb @@ -2,508 +2,36 @@ # frozen_string_literal: true # ========================================================== -# adn/tools/cli/dron.rb — Sistema de Drones Autónomos +# adn/tools/cli/dron.rb — Sistema de Drones Atómicos (AtomicDrones) # ---------------------------------------------------------- -# Flota de drones que operan de forma autónoma: -# - Lanzar tareas en background con auto-bitácora -# - Vigilar procesos existentes -# - Orquestar flota: detectar drones caídos, estancados -# - Reportar salud del sistema en tiempo real +# Interfaz CLI unificada para la flota de drones atómicos: +# - ejecutor: lanza comandos en background con auto-bitácora +# - vigilante: monitorea flota, detecta zombies, consolida estados +# - sanador: re-intenta fallidos, limpia antiguos # -# Principio: Sistema Vivo — los drones operan, reportan -# y se auto-gestionan sin intervención humana. +# Arquitectura: DB-First, módulos atómicos, composición tipo "Menos es Más" # # Uso: # ./adn/tools/run dron lanzar --evento AUTO -- # ./adn/tools/run dron flota # Dashboard de la flota # ./adn/tools/run dron salud # Health check activo -# ./adn/tools/run dron estado # Estado detallado -# ./adn/tools/run dron limpiar # Limpiar completados +# ./adn/tools/run dron estado # Estado detallado +# ./adn/tools/run dron sanear # Saneamiento completo # ========================================================== -require 'optparse' -require 'fileutils' -require 'time' -require 'json' +require_relative 'dron/dispatcher' +require_relative '../core/logger' +require_relative '../core/constants' module ADN class SubcomandoDron - DRON_DIR = File.join(ADN::PROJECT_ROOT, 'tmp', 'dron') - ADN_RUN = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'run') - STALE_MINUTES = 120 # Dron sin actividad > 2h = posible problema - def initialize(args, logger = nil) @args = args - @logger = logger - FileUtils.mkdir_p(DRON_DIR) + @logger = logger || ADN::Logger.instance end def ejecutar - if @args.empty? || @args.first == 'help' || @args.first == '--help' - mostrar_ayuda - return - end - - accion = @args.shift - case accion - when 'lanzar' - cmd_lanzar(@args) - when 'vigilar' - cmd_vigilar(@args) - when 'estado' - cmd_estado - when 'flota' - cmd_flota - when 'salud' - cmd_salud - when 'limpiar' - cmd_limpiar - else - puts "#{Color::RED}✗ Acción desconocida: #{accion}#{Color::RESET}" - mostrar_ayuda - exit 1 - end - end - - private - - # ─── lanzar: ejecutar comando en background y vigilar ──────── - def cmd_lanzar(args) - evento_id = nil - nota = nil - - # Separar opciones del dron del comando a ejecutar - separador = args.index('--') - unless separador - puts "#{Color::RED}✗ Falta separador '--' antes del comando a ejecutar#{Color::RESET}" - puts "Uso: ./adn/tools/run dron lanzar --evento 1379 -- " - return - end - - dron_args = args[0...separador] - comando_args = args[(separador + 1)..-1] - - OptionParser.new do |opts| - opts.on("--evento ID", "ID del evento (o AUTO para crear uno)") { |e| evento_id = e } - opts.on("--nota TEXTO", "Nota adicional para el cierre") { |n| nota = n } - end.parse!(dron_args) - - # AUTO: crear evento de bitácora automáticamente - if evento_id == 'AUTO' - # Primero generamos el tarea_id para incluirlo en la descripción del evento - tarea_id = "dron_#{Time.now.strftime('%H%M%S')}_#{$$}" - cmd_crear = "#{ADN_RUN} db evento:crear --ambito dtic-BKPs --nodo srv-ns8" - cmd_crear += " --descripcion \"🛸 #{tarea_id}: #{(nota || comando_args.join(' ')).gsub('"', '\\"')}\"" - cmd_crear += " --inicio #{Time.now.strftime('%H:%M')} --modo R --ia" - output = `#{cmd_crear} 2>&1` - # El evento recién creado aparece como último en la lista de pendientes - # Output: "⏳ [33mID 1413 [0m 17:12:00 srv-ns8 ..." - all_ids = output.scan(/ID\s+(\d+)/).flatten.map(&:to_i) - if output.include?('Entrada creada exitosamente') || output.include?('✅ Entrada creada') - if all_ids.any? - # Tomamos el ID más alto (el último creado) - evento_id = all_ids.max - @logger&.info("Dron: Evento AUTO ##{evento_id} creado para #{tarea_id}") - else - @logger&.warn("Dron: Evento creado pero no se pudo extraer ID del output") - evento_id = nil - end - else - @logger&.error("Dron: Error al crear evento AUTO: #{output}") - evento_id = nil - end - elsif evento_id - evento_id = evento_id.to_i - end - - if comando_args.empty? - puts "#{Color::RED}✗ Falta el comando a ejecutar después de '--'#{Color::RESET}" - return - end - - comando_str = comando_args.join(' ') - tarea_id ||= "dron_#{Time.now.strftime('%H%M%S')}_#{$$}" - log_file = File.join(DRON_DIR, "#{tarea_id}.log") - meta_file = File.join(DRON_DIR, "#{tarea_id}.json") - - # Guardar metadata - meta = { - tarea_id: tarea_id, - evento_id: evento_id, - comando: comando_str, - nota: nota, - inicio: Time.now.iso8601, - pid: nil, - estado: 'lanzando' - } - File.write(meta_file, JSON.pretty_generate(meta)) - - # Lanzar el proceso vigía en background - nota_safe = (nota || "").gsub(%q{"}, %q{\"}) - cmd_safe = comando_str.gsub(%q{"}, %q{\"}) - inicio_epoch = Time.now.to_i - vigia_script = <<~BASH - #!/bin/bash - # Dron vigia — auto-generado - LOGFILE="#{log_file}" - METAFILE="#{meta_file}" - EVENTO_ID="#{evento_id}" - ADN_RUN="#{ADN_RUN}" - TAREA_ID="#{tarea_id}" - INICIO_EPOCH=#{inicio_epoch} - - actualizar_evento() { - if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then - "$ADN_RUN" db evento:actualizar "$EVENTO_ID" --descripcion "$1" >> "$LOGFILE" 2>&1 - fi - } - - HORA_INICIO=$(date +%H:%M:%S) - DIARIO="Dron: $TAREA_ID --- -$HORA_INICIO - Inicio de vuelo -Mision: #{nota_safe.empty? ? cmd_safe : nota_safe} -Ejecutando: #{cmd_safe}" - - echo "[$HORA_INICIO] Dron $TAREA_ID: Inicio de vuelo" >> "$LOGFILE" - actualizar_evento "$DIARIO" - - #{comando_str} >> "$LOGFILE" 2>&1 - EXIT_CODE=$? - HORA_FIN=$(date +%H:%M:%S) - DURACION=$(( ($(date +%s) - $INICIO_EPOCH) / 60 )) - echo "[$HORA_FIN] Dron $TAREA_ID: Termino con codigo $EXIT_CODE" >> "$LOGFILE" - - if [ "$EXIT_CODE" -eq 0 ]; then - DIARIO="$DIARIO -OK $HORA_FIN - Completado (${DURACION}min) -Aterrizaje limpio" - else - DIARIO="$DIARIO -FALLO $HORA_FIN - Codigo $EXIT_CODE (${DURACION}min) -Aterrizaje de emergencia" - fi - - ruby -rjson -e ' - f = ARGV[0] - m = JSON.parse(File.read(f)) - m["fin"] = Time.now.iso8601 - m["exit_code"] = ARGV[1].to_i - m["estado"] = ARGV[1].to_i == 0 ? "completado" : "fallido" - File.write(f, JSON.pretty_generate(m)) - ' "$METAFILE" "$EXIT_CODE" - - if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then - HORA_FIN_HM=$(date +%H:%M) - "$ADN_RUN" db evento:actualizar "$EVENTO_ID" --fin "$HORA_FIN_HM" --descripcion "$DIARIO" >> "$LOGFILE" 2>&1 - echo "[$HORA_FIN] Dron $TAREA_ID: Evento $EVENTO_ID cerrado" >> "$LOGFILE" - fi - BASH - - vigia_path = File.join(DRON_DIR, "#{tarea_id}.sh") - File.write(vigia_path, vigia_script) - FileUtils.chmod(0755, vigia_path) - - pid = spawn("nohup bash #{vigia_path} &", [:out, :err] => '/dev/null') - Process.detach(pid) - - # Actualizar meta con PID - meta[:pid] = pid - meta[:estado] = 'vigilando' - File.write(meta_file, JSON.pretty_generate(meta)) - - puts "#{Color::GREEN}✔ Dron lanzado#{Color::RESET}" - puts " Tarea: #{tarea_id}" - puts " PID: #{pid}" - puts " Evento: #{evento_id || '(sin evento)'}" - puts " Log: #{log_file}" - puts " Comando: #{comando_str}" - puts "" - puts "#{Color::DIM}El dron cerrará el evento ##{evento_id} cuando termine.#{Color::RESET}" if evento_id - puts "#{Color::DIM}Consultar: ./adn/tools/run dron estado#{Color::RESET}" - end - - # ─── vigilar: adjuntarse a un PID existente ────────────────── - def cmd_vigilar(args) - evento_id = nil - pid = nil - - OptionParser.new do |opts| - opts.on("--evento ID", Integer, "ID del evento") { |e| evento_id = e } - opts.on("--pid PID", Integer, "PID del proceso a vigilar") { |p| pid = p } - end.parse!(args) - - unless pid - puts "#{Color::RED}✗ Falta --pid#{Color::RESET}" - return - end - - tarea_id = "vigil_#{pid}_#{Time.now.strftime('%H%M%S')}" - log_file = File.join(DRON_DIR, "#{tarea_id}.log") - - # Lanzar vigía para PID existente - vigia_cmd = <<~CMD - nohup bash -c ' - tail --pid=#{pid} -f /dev/null 2>/dev/null - HORA_FIN=$(date +%%H:%%M) - echo "[$(date)] Dron: PID #{pid} terminó" >> #{log_file} - #{evento_id ? "\"#{ADN_RUN}\" db evento:actualizar #{evento_id} --fin $HORA_FIN >> #{log_file} 2>&1" : ""} - echo "[$(date)] Dron: Evento #{evento_id} cerrado a las $HORA_FIN" >> #{log_file} - ' > /dev/null 2>&1 & - CMD - - system(vigia_cmd) - - meta = { - tarea_id: tarea_id, - evento_id: evento_id, - pid_vigilado: pid, - inicio: Time.now.iso8601, - estado: 'vigilando' - } - File.write(File.join(DRON_DIR, "#{tarea_id}.json"), JSON.pretty_generate(meta)) - - puts "#{Color::GREEN}✔ Dron vigilando PID #{pid}#{Color::RESET}" - puts " Evento: #{evento_id || '(sin evento)'}" - puts " Log: #{log_file}" - end - - # ─── estado: mostrar tareas del dron ─────────────────────── - def cmd_estado - metas = Dir.glob(File.join(DRON_DIR, '*.json')).sort - if metas.empty? - puts "#{Color::DIM}Sin tareas de dron activas.#{Color::RESET}" - return - end - - puts "#{Color::BOLD}📋 Tareas del Dron#{Color::RESET}\n\n" - metas.each do |f| - m = JSON.parse(File.read(f)) - icono = case m['estado'] - when 'completado' then "#{Color::GREEN}✔#{Color::RESET}" - when 'fallido' then "#{Color::RED}✖#{Color::RESET}" - when 'vigilando' then "#{Color::YELLOW}⏳#{Color::RESET}" - else "#{Color::DIM}?#{Color::RESET}" - end - - pid_info = m['pid'] || m['pid_vigilado'] - vivo = pid_info && system("kill -0 #{pid_info} 2>/dev/null") - - puts " #{icono} #{m['tarea_id']}" - puts " Evento: #{m['evento_id'] || '-'} PID: #{pid_info || '-'}#{vivo ? " #{Color::GREEN}(vivo)#{Color::RESET}" : ''}" - puts " #{m['comando'] || "Vigilando PID #{m['pid_vigilado']}"}" - puts " Inicio: #{m['inicio']}#{m['fin'] ? " Fin: #{m['fin']}" : ''}" - puts "" - end - end - - # ─── flota: dashboard compacto de la flota ───────────────────── - def cmd_flota - drones = cargar_todos - - if drones.empty? - puts "#{Color::DIM}🛸 Flota vacía — sin drones activos.#{Color::RESET}" - return - end - - activos = drones.select { |d| d['estado'] == 'vigilando' } - completados = drones.select { |d| d['estado'] == 'completado' } - fallidos = drones.select { |d| d['estado'] == 'fallido' } - - puts "\n#{Color::BOLD}#{Color::CYAN}🛸 Flota de Drones#{Color::RESET}\n" - puts "─" * 70 - - # Activos - if activos.any? - puts "#{Color::YELLOW}⏳ En vuelo (#{activos.length})#{Color::RESET}" - activos.each do |d| - pid = d['pid'] || d['pid_vigilado'] - vivo = pid && system("kill -0 #{pid} 2>/dev/null") - elapsed = tiempo_transcurrido(d['inicio']) - comando_corto = (d['comando'] || d['nota'] || '?')[0..55] - estado_pid = vivo ? "#{Color::GREEN}●#{Color::RESET}" : "#{Color::RED}●#{Color::RESET}" - puts " #{estado_pid} #{d['tarea_id']} #{elapsed.rjust(8)} #{comando_corto}" - end - puts "" - end - - # Completados - if completados.any? - puts "#{Color::GREEN}✔ Completados (#{completados.length})#{Color::RESET}" - completados.last(5).each do |d| - duracion = calcular_duracion(d['inicio'], d['fin']) - comando_corto = (d['nota'] || d['comando'] || '?')[0..55] - puts " ✔ #{d['tarea_id']} #{duracion.rjust(8)} #{comando_corto}" - end - puts "" - end - - # Fallidos - if fallidos.any? - puts "#{Color::RED}✖ Fallidos (#{fallidos.length})#{Color::RESET}" - fallidos.each do |d| - comando_corto = (d['nota'] || d['comando'] || '?')[0..55] - puts " ✖ #{d['tarea_id']} exit=#{d['exit_code']} #{comando_corto}" - end - puts "" - end - - puts "─" * 70 - puts "#{Color::BOLD}Total: #{drones.length} drones | " \ - "#{Color::YELLOW}⏳#{activos.length}#{Color::RESET} | " \ - "#{Color::GREEN}✔#{completados.length}#{Color::RESET} | " \ - "#{Color::RED}✖#{fallidos.length}#{Color::RESET}" - end - - # ─── salud: health check activo ──────────────────────────────── - def cmd_salud - drones = cargar_todos - problemas = [] - - if drones.empty? - puts "#{Color::GREEN}✔ Sin drones — nada que verificar.#{Color::RESET}" - return - end - - puts "\n#{Color::BOLD}🏥 Diagnóstico de Salud#{Color::RESET}\n" - puts "─" * 70 - - drones.each do |d| - next unless d['estado'] == 'vigilando' - - pid = d['pid'] || d['pid_vigilado'] - vivo = pid && system("kill -0 #{pid} 2>/dev/null") - elapsed_min = minutos_transcurridos(d['inicio']) - - # Detectar zombie: meta dice vigilando pero PID muerto - if !vivo - problemas << { dron: d['tarea_id'], tipo: '💀 Zombie', detalle: "PID #{pid} muerto pero estado=vigilando", meta: d } - puts " #{Color::RED}💀 ZOMBIE#{Color::RESET} #{d['tarea_id']} — PID #{pid} muerto" - - # Auto-reparar: actualizar metadata - meta_path = File.join(DRON_DIR, "#{d['tarea_id']}.json") - if File.exist?(meta_path) - d['estado'] = 'fallido' - d['fin'] = Time.now.iso8601 - d['exit_code'] = -1 - d['nota_salud'] = 'Auto-detectado como zombie por dron salud' - File.write(meta_path, JSON.pretty_generate(d)) - puts " #{Color::DIM}→ Auto-reparado: marcado como fallido#{Color::RESET}" - end - end - - # Detectar estancado: lleva mucho tiempo - if vivo && elapsed_min > STALE_MINUTES - problemas << { dron: d['tarea_id'], tipo: '🐌 Estancado', detalle: "#{elapsed_min}min sin finalizar" } - puts " #{Color::YELLOW}🐌 ESTANCADO#{Color::RESET} #{d['tarea_id']} — #{elapsed_min}min activo" - - # Verificar si el log sigue creciendo - log_path = File.join(DRON_DIR, "#{d['tarea_id']}.log") - if File.exist?(log_path) - log_age = ((Time.now - File.mtime(log_path)) / 60).to_i - if log_age > 30 - puts " #{Color::DIM}→ Log sin actualizar hace #{log_age}min#{Color::RESET}" - else - puts " #{Color::DIM}→ Log activo (última escritura hace #{log_age}min)#{Color::RESET}" - end - end - end - - # Sano - if vivo && elapsed_min <= STALE_MINUTES - puts " #{Color::GREEN}💚 SANO#{Color::RESET} #{d['tarea_id']} — #{elapsed_min}min, PID #{pid} activo" - end - end - - # Eventos huérfanos: drones con evento_id pero sin PID vivo - eventos_pendientes = drones.select { |d| d['estado'] == 'fallido' && d['evento_id'] } - if eventos_pendientes.any? - puts "\n#{Color::YELLOW}⚠ Eventos con drones fallidos:#{Color::RESET}" - eventos_pendientes.each do |d| - puts " Evento ##{d['evento_id']} — #{d['tarea_id']} (exit=#{d['exit_code']})" - end - end - - puts "" - puts "─" * 70 - if problemas.empty? - puts "#{Color::GREEN}#{Color::BOLD}✔ Flota saludable — sin problemas detectados.#{Color::RESET}" - else - puts "#{Color::RED}#{Color::BOLD}⚠ #{problemas.length} problema(s) detectado(s).#{Color::RESET}" - end - end - - # ─── limpiar: borrar tareas completadas ────────────────────── - def cmd_limpiar - Dir.glob(File.join(DRON_DIR, '*.json')).each do |f| - m = JSON.parse(File.read(f)) - if ['completado', 'fallido'].include?(m['estado']) - base = File.basename(f, '.json') - ['.json', '.log', '.sh'].each do |ext| - FileUtils.rm_f(File.join(DRON_DIR, "#{base}#{ext}")) - end - end - end - puts "#{Color::GREEN}✔ Tareas completadas limpiadas.#{Color::RESET}" - end - - # ─── Helpers ───────────────────────────────────────────────── - def cargar_todos - Dir.glob(File.join(DRON_DIR, '*.json')).sort.map do |f| - JSON.parse(File.read(f)) rescue nil - end.compact - end - - def tiempo_transcurrido(inicio_str) - return '?' unless inicio_str - mins = ((Time.now - Time.parse(inicio_str)) / 60).to_i - if mins < 60 - "#{mins}min" - else - "#{mins / 60}h#{mins % 60}m" - end - end - - def calcular_duracion(inicio_str, fin_str) - return '?' unless inicio_str && fin_str - mins = ((Time.parse(fin_str) - Time.parse(inicio_str)) / 60).to_i - if mins < 60 - "#{mins}min" - else - "#{mins / 60}h#{mins % 60}m" - end - end - - def minutos_transcurridos(inicio_str) - return 0 unless inicio_str - ((Time.now - Time.parse(inicio_str)) / 60).to_i - end - - def mostrar_ayuda - puts <<~HELP - #{Color::BOLD}#{Color::CYAN}🛸 Dron — Sistema de Drones Autónomos#{Color::RESET} - - Flota de drones que operan de forma autónoma: ejecutan tareas - en background, registran en bitácora, detectan problemas y - se auto-gestionan. Sistema vivo, no estático. - - #{Color::YELLOW}Comandos:#{Color::RESET} - dron lanzar --evento ID -- Lanzar dron de trabajo - dron flota Dashboard compacto de la flota - dron salud Health check (detecta zombies/estancados) - dron estado Estado detallado de cada dron - dron vigilar --evento ID --pid PID Vigilar PID existente - dron limpiar Limpiar completados/fallidos - - #{Color::YELLOW}Evento AUTO:#{Color::RESET} - --evento AUTO crea un evento de bitácora automáticamente. - --nota "texto" define la descripción del evento. - - #{Color::YELLOW}Ejemplos:#{Color::RESET} - ./adn/tools/run dron lanzar --evento AUTO --nota "Backup srvv-sitio" -- ./adn/tools/run bkps backup srvv-sitio - ./adn/tools/run dron flota - ./adn/tools/run dron salud - HELP + Dron::Dispatcher.ejecutar(@args.dup) end end end diff --git a/adn/tools/cli/dron/bitacora.rb b/adn/tools/cli/dron/bitacora.rb new file mode 100644 index 00000000..5261e21a --- /dev/null +++ b/adn/tools/cli/dron/bitacora.rb @@ -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 diff --git a/adn/tools/cli/dron/dispatcher.rb b/adn/tools/cli/dron/dispatcher.rb new file mode 100644 index 00000000..8d119c3f --- /dev/null +++ b/adn/tools/cli/dron/dispatcher.rb @@ -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 [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 #{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\" -- " + 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 " + 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 diff --git a/adn/tools/cli/dron/ejecutor.rb b/adn/tools/cli/dron/ejecutor.rb new file mode 100644 index 00000000..aca210c8 --- /dev/null +++ b/adn/tools/cli/dron/ejecutor.rb @@ -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 diff --git a/adn/tools/cli/dron/sanador.rb b/adn/tools/cli/dron/sanador.rb new file mode 100644 index 00000000..17fd9b74 --- /dev/null +++ b/adn/tools/cli/dron/sanador.rb @@ -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 diff --git a/adn/tools/cli/dron/vigilante.rb b/adn/tools/cli/dron/vigilante.rb new file mode 100644 index 00000000..8a86b7c0 --- /dev/null +++ b/adn/tools/cli/dron/vigilante.rb @@ -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] 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] 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] 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 diff --git a/adn/tools/db/core/dron_db.rb b/adn/tools/db/core/dron_db.rb new file mode 100644 index 00000000..8840e798 --- /dev/null +++ b/adn/tools/db/core/dron_db.rb @@ -0,0 +1,346 @@ +# frozen_string_literal: true + +require_relative 'bitacora_db' +require 'securerandom' + +module ADN + module DB + # DronDB - Acceso a datos para el sistema de AtomicDrones + # Gestiona registro y consulta de drones en tablas internas + class DronDB + # Estados posibles de un dron + ESTADOS = { + pending: 'pending', + running: 'running', + completed: 'completed', + failed: 'failed', + zombie: 'zombie' + }.freeze + + # Tipos de drones + TIPOS = { + ejecutor: 'ejecutor', + vigilante: 'vigilante', + sanador: 'sanador', + planificador: 'planificador', + mensajero: 'mensajero', + orquestador: 'orquestador' + }.freeze + + # Tiempo máximo sin heartbeat antes de considerar zombie (5 minutos) + ZOMBIE_TIMEOUT = 300 + + class << self + # Generar ID único para un dron + # Formato: dron_HHMMSS_PID_RANDOM + def generar_dron_id + timestamp = Time.now.strftime('%H%M%S') + pid = Process.pid + random = SecureRandom.hex(3) + "dron_#{timestamp}_#{pid}_#{random}" + end + + # Registrar inicio de un dron + # @param tipo [String] Tipo de dron (ejecutor, vigilante, etc.) + # @param cmd [String] Comando a ejecutar + # @param flujo_id [String, nil] ID de orquestación (si aplica) + # @param metadata [Hash] Metadata adicional + # @return [Hash] Datos del dron registrado + def registrar_inicio(tipo:, cmd:, flujo_id: nil, metadata: {}) + sql = <<-SQL + INSERT INTO bitacoras.dron_logs (dron_id, tipo, estado, cmd, flujo_id, metadata) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + SQL + + dron_id = generar_dron_id + params = [dron_id, tipo, ESTADOS[:running], cmd, flujo_id, metadata.to_json] + + resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, params) } + + # Actualizar heartbeat inicial + actualizar_heartbeat(dron_id) + + { + dron_id: dron_id, + estado: ESTADOS[:running], + started_at: resultado['started_at'], + mensaje: "Dron #{dron_id} registrado como #{tipo}" + } + end + + # Registrar finalización de un dron + # @param dron_id [String] ID del dron + # @param exit_code [Integer] Código de salida + # @param output [String] Salida del comando + # @return [Hash] Resultado de la operación + def registrar_fin(dron_id, exit_code, output = nil) + estado = exit_code == 0 ? ESTADOS[:completed] : ESTADOS[:failed] + + sql = <<-SQL + UPDATE bitacoras.dron_logs + SET estado = $1, + exit_code = $2, + output = $3, + completed_at = NOW(), + updated_at = NOW() + WHERE dron_id = $4 + RETURNING * + SQL + + resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [estado, exit_code, output, dron_id]) } + + # Actualizar métricas diarias + actualizar_metricas(dron_id, estado) + + { + dron_id: dron_id, + estado: estado, + exit_code: exit_code, + completed_at: resultado['completed_at'] + } + end + + # Actualizar heartbeat de un dron + # @param dron_id [String] ID del dron + def actualizar_heartbeat(dron_id) + sql = <<-SQL + UPDATE bitacoras.dron_logs + SET heartbeat = NOW(), updated_at = NOW() + WHERE dron_id = $1 + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) } + end + + # Obtener información de un dron + # @param dron_id [String] ID del dron + # @return [Hash, nil] Datos del dron o nil si no existe + def obtener_dron(dron_id) + sql = 'SELECT * FROM bitacoras.dron_logs WHERE dron_id = $1' + resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) } + resultado.first + end + + # Obtener drones por estado + # @param estado [String] Estado a filtrar + # @return [Array] Lista de drones + def obtener_por_estado(estado) + sql = 'SELECT * FROM bitacoras.dron_logs WHERE estado = $1 ORDER BY started_at DESC' + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [estado]) } + end + + # Obtener todos los drones activos (running o pending) + # @return [Array] Lista de drones activos + def obtener_activos + sql = <<-SQL + SELECT * FROM bitacoras.dron_logs + WHERE estado IN ('running', 'pending') + ORDER BY started_at DESC + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a } + end + + # Detectar drones zombies (heartbeat > 5min) + # @return [Array] Lista de drones zombies + def detectar_zombies + sql = <<-SQL + SELECT * FROM bitacoras.vw_dron_zombies + ORDER BY heartbeat ASC + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a } + end + + # Marcar dron como zombie + # @param dron_id [String] ID del dron + def marcar_zombie(dron_id) + sql = <<-SQL + UPDATE bitacoras.dron_logs + SET estado = $1, updated_at = NOW() + WHERE dron_id = $2 + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [ESTADOS[:zombie], dron_id]) } + end + + # Incrementar contador de reintentos + # @param dron_id [String] ID del dron + # @return [Integer] Nuevo contador de reintentos + def incrementar_reintentos(dron_id) + sql = <<-SQL + UPDATE bitacoras.dron_logs + SET reintentos = reintentos + 1, updated_at = NOW() + WHERE dron_id = $1 + RETURNING reintentos + SQL + + resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) } + resultado.first['reintentos'].to_i + end + + # Registrar avance de un paso en flujo multi-paso + # @param dron_id [String] ID del dron + # @param paso [Integer] Número de paso + # @param descripcion [String] Descripción del paso + # @param estado [String] Estado del paso + # @param output [String, nil] Salida del paso + # @param flujo_id [String, nil] ID de orquestación + def registrar_avance(dron_id, paso:, descripcion:, estado:, output: nil, flujo_id: nil) + sql = <<-SQL + INSERT INTO bitacoras.dron_avances (dron_id, flujo_id, paso, descripcion, estado, output) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id, flujo_id, paso, descripcion, estado, output]) } + end + + # Obtener avances de un dron + # @param dron_id [String] ID del dron + # @return [Array] Lista de avances + def obtener_avances(dron_id) + sql = <<-SQL + SELECT * FROM bitacoras.dron_avances + WHERE dron_id = $1 + ORDER BY paso ASC + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [dron_id]) } + end + + # Obtener avances de un flujo + # @param flujo_id [String] ID del flujo + # @return [Array] Lista de avances + def obtener_avances_flujo(flujo_id) + sql = <<-SQL + SELECT * FROM bitacoras.dron_avances + WHERE flujo_id = $1 + ORDER BY paso ASC + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [flujo_id]) } + end + + # Obtener métricas por fecha y tipo + # @param fecha [Date] Fecha de las métricas + # @param tipo [String] Tipo de dron + # @return [Hash, nil] Métricas o nil si no existen + def obtener_metricas(fecha:, tipo:) + sql = 'SELECT * FROM bitacoras.dron_metricas WHERE fecha = $1 AND dron_tipo = $2' + resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [fecha, tipo]) } + resultado.first + end + + # Actualizar métricas diarias (llamado automáticamente al finalizar un dron) + # @param dron_id [String] ID del dron + # @param estado [String] Estado final del dron + def actualizar_metricas(dron_id, estado) + dron = obtener_dron(dron_id) + return unless dron + + fecha = dron['started_at'].to_date + tipo = dron['tipo'] + + # Calcular duración + started = dron['started_at'] + completed = dron['completed_at'] || Time.now + duracion = completed - started + + # Upsert de métricas + sql = <<-SQL + INSERT INTO bitacoras.dron_metricas (fecha, dron_tipo, total_ejecuciones, completados, fallidos, zombies, tiempo_promedio, reintentos_total) + VALUES ($1, $2, 1, $3, $4, $5, $6, $7) + ON CONFLICT (fecha, dron_tipo) DO UPDATE SET + total_ejecuciones = bitacoras.dron_metricas.total_ejecuciones + 1, + completados = bitacoras.dron_metricas.completados + $3, + fallidos = bitacoras.dron_metricas.fallidos + $4, + zombies = bitacoras.dron_metricas.zombies + $5, + tiempo_promedio = ( + (bitacoras.dron_metricas.tiempo_promedio * bitacoras.dron_metricas.total_ejecuciones + $6) / + (bitacoras.dron_metricas.total_ejecuciones + 1) + ), + reintentos_total = bitacoras.dron_metricas.reintentos_total + $7, + updated_at = NOW() + SQL + + es_completado = estado == ESTADOS[:completed] ? 1 : 0 + es_fallido = estado == ESTADOS[:failed] ? 1 : 0 + es_zombie = estado == ESTADOS[:zombie] ? 1 : 0 + reintentos = dron['reintentos'].to_i + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [fecha, tipo, es_completado, es_fallido, es_zombie, duracion, reintentos]) } + end + + # Obtener resumen de estado de la flota + # @return [Hash] Resumen de la flota + def resumen_flota + sql = <<-SQL + SELECT + estado, + COUNT(*) as cantidad + FROM bitacoras.dron_logs + GROUP BY estado + SQL + + resultados = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a } + resumen = { total: 0 } + + resultados.each do |fila| + resumen[fila['estado'].to_sym] = fila['cantidad'].to_i + resumen[:total] += fila['cantidad'].to_i + end + + resumen + end + + # Obtener drones por flujo + # @param flujo_id [String] ID del flujo + # @return [Array] Lista de drones del flujo + def obtener_por_flujo(flujo_id) + sql = <<-SQL + SELECT * FROM bitacoras.dron_logs + WHERE flujo_id = $1 + ORDER BY started_at ASC + SQL + + BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql, [flujo_id]) } + end + + # Consolidar estado de un flujo + # @param flujo_id [String] ID del flujo + # @return [String] Estado consolidado (running, completed, failed) + def consolidar_estado_flujo(flujo_id) + drones = obtener_por_flujo(flujo_id) + return nil if drones.empty? + + # Si todos completados → completed + # Si alguno failed → failed + # Si no → running + if drones.all? { |d| d['estado'] == ESTADOS[:completed] } + ESTADOS[:completed] + elsif drones.any? { |d| d['estado'] == ESTADOS[:failed] } + ESTADOS[:failed] + else + ESTADOS[:running] + end + end + + # Limpiar drones antiguos (más de N días) + # @param dias [Integer] Días de antigüedad + # @return [Integer] Cantidad de drones eliminados + def limpiar_antiguos(dias: 7) + sql = <<-SQL + DELETE FROM bitacoras.dron_logs + WHERE estado IN ('completed', 'failed', 'zombie') + AND completed_at < NOW() - INTERVAL '#{dias} days' + SQL + + resultado = BitacorasDB::BitacoraDB.with_connection { |db| db.execute(sql).to_a } + resultado.first['count'].to_i + end + end + end + end +end diff --git a/adn/tools/db/migrations/003_create_dron_tables.rb b/adn/tools/db/migrations/003_create_dron_tables.rb new file mode 100644 index 00000000..673c573d --- /dev/null +++ b/adn/tools/db/migrations/003_create_dron_tables.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +# Migration: 003 - Tablas para sistema de Drones (AtomicDrones) +# +# Usage: +# ruby adn/tools/db/migrations/003_create_dron_tables.rb +# +# Propósito: Crear tablas internas para registro detallado de drones +# - dron_logs: Logs detallados de cada dron +# - dron_avances: Avances paso-a-paso de flujos multi-paso +# - dron_metricas: Métricas agregadas diarias por tipo de dron + +require_relative '../core/bitacora_db' + +puts "🔧 Running migration 003: Create dron tables" + +BitacorasDB::BitacoraDB.with_connection do |db| + # ============================================================================ + # Tabla 1: dron_logs + # ============================================================================ + + puts "\n📝 Creando tabla bitacoras.dron_logs..." + + db.execute(<<~SQL) + CREATE TABLE IF NOT EXISTS bitacoras.dron_logs ( + id SERIAL PRIMARY KEY, + dron_id VARCHAR(50) NOT NULL, + flujo_id VARCHAR(50), + tipo VARCHAR(20) NOT NULL, + estado VARCHAR(20) NOT NULL, + cmd TEXT, + output TEXT, + exit_code INTEGER, + started_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + heartbeat TIMESTAMP DEFAULT NOW(), + reintentos INTEGER DEFAULT 0, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + SQL + puts "✅ Tabla bitacoras.dron_logs creada" + + # Índices para dron_logs + indices_dron_logs = [ + ["idx_dron_logs_estado", "CREATE INDEX IF NOT EXISTS idx_dron_logs_estado ON bitacoras.dron_logs(estado)"], + ["idx_dron_logs_heartbeat", "CREATE INDEX IF NOT EXISTS idx_dron_logs_heartbeat ON bitacoras.dron_logs(heartbeat)"], + ["idx_dron_logs_flujo", "CREATE INDEX IF NOT EXISTS idx_dron_logs_flujo ON bitacoras.dron_logs(flujo_id)"], + ["idx_dron_logs_tipo", "CREATE INDEX IF NOT EXISTS idx_dron_logs_tipo ON bitacoras.dron_logs(tipo)"], + ["idx_dron_logs_started_at", "CREATE INDEX IF NOT EXISTS idx_dron_logs_started_at ON bitacoras.dron_logs(started_at)"] + ] + + indices_dron_logs.each do |nombre, sql| + db.execute(sql) + puts "📈 Índice creado: #{nombre}" + end + + # ============================================================================ + # Tabla 2: dron_avances + # ============================================================================ + + puts "\n📝 Creando tabla bitacoras.dron_avances..." + + db.execute(<<~SQL) + CREATE TABLE IF NOT EXISTS bitacoras.dron_avances ( + id SERIAL PRIMARY KEY, + dron_id VARCHAR(50) NOT NULL, + flujo_id VARCHAR(50), + paso INTEGER NOT NULL, + descripcion TEXT NOT NULL, + estado VARCHAR(20) NOT NULL, + output TEXT, + started_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP DEFAULT NOW() + ) + SQL + puts "✅ Tabla bitacoras.dron_avances creada" + + # Índices para dron_avances + indices_dron_avances = [ + ["idx_dron_avances_dron", "CREATE INDEX IF NOT EXISTS idx_dron_avances_dron ON bitacoras.dron_avances(dron_id)"], + ["idx_dron_avances_flujo", "CREATE INDEX IF NOT EXISTS idx_dron_avances_flujo ON bitacoras.dron_avances(flujo_id)"], + ["idx_dron_avances_paso", "CREATE INDEX IF NOT EXISTS idx_dron_avances_paso ON bitacoras.dron_avances(paso)"] + ] + + indices_dron_avances.each do |nombre, sql| + db.execute(sql) + puts "📈 Índice creado: #{nombre}" + end + + # ============================================================================ + # Tabla 3: dron_metricas + # ============================================================================ + + puts "\n📝 Creando tabla bitacoras.dron_metricas..." + + db.execute(<<~SQL) + CREATE TABLE IF NOT EXISTS bitacoras.dron_metricas ( + id SERIAL PRIMARY KEY, + fecha DATE NOT NULL, + dron_tipo VARCHAR(20) NOT NULL, + total_ejecuciones INTEGER DEFAULT 0, + completados INTEGER DEFAULT 0, + fallidos INTEGER DEFAULT 0, + zombies INTEGER DEFAULT 0, + tiempo_promedio INTERVAL DEFAULT '0 seconds', + reintentos_total INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(fecha, dron_tipo) + ) + SQL + puts "✅ Tabla bitacoras.dron_metricas creada" + + # Índices para dron_metricas + db.execute("CREATE INDEX IF NOT EXISTS idx_dron_metricas_fecha ON bitacoras.dron_metricas(fecha)") + puts "📈 Índice creado: idx_dron_metricas_fecha" + + # ============================================================================ + # Trigger para actualizar updated_at + # ============================================================================ + + puts "\n📝 Creando trigger para actualizar updated_at..." + + db.execute(<<~SQL) + CREATE OR REPLACE FUNCTION bitacoras.update_updated_at_column() + RETURNS TRIGGER AS $$ + BEGIN + NEW.updated_at = NOW(); + RETURN NEW; + END; + $$ LANGUAGE plpgsql + SQL + puts "✅ Función update_updated_at_column creada" + + # Triggers para cada tabla + triggers = [ + ["update_dron_logs_updated_at", "dron_logs"], + ["update_dron_metricas_updated_at", "dron_metricas"] + ] + + triggers.each do |nombre_trigger, tabla| + db.execute("DROP TRIGGER IF EXISTS #{nombre_trigger} ON bitacoras.#{tabla}") + db.execute(<<~SQL) + CREATE TRIGGER #{nombre_trigger} + BEFORE UPDATE ON bitacoras.#{tabla} + FOR EACH ROW + EXECUTE FUNCTION bitacoras.update_updated_at_column() + SQL + puts "📈 Trigger creado: #{nombre_trigger} en #{tabla}" + end + + # ============================================================================ + # Vistas útiles + # ============================================================================ + + puts "\n📝 Creando vistas útiles..." + + # Vista: Drones activos por estado + db.execute(<<~SQL) + CREATE OR REPLACE VIEW bitacoras.vw_dron_estado AS + SELECT + estado, + tipo, + COUNT(*) as cantidad, + MIN(started_at) as mas_antiguo, + MAX(started_at) as mas_reciente + FROM bitacoras.dron_logs + WHERE estado IN ('running', 'pending') + GROUP BY estado, tipo + SQL + puts "✅ Vista creada: vw_dron_estado" + + # Vista: Drones zombies + db.execute(<<~SQL) + CREATE OR REPLACE VIEW bitacoras.vw_dron_zombies AS + SELECT + id, + dron_id, + tipo, + cmd, + started_at, + heartbeat, + EXTRACT(EPOCH FROM (NOW() - heartbeat))/60 as minutos_sin_heartbeat + FROM bitacoras.dron_logs + WHERE estado = 'running' + AND heartbeat < NOW() - INTERVAL '5 minutes' + SQL + puts "✅ Vista creada: vw_dron_zombies" + + # Vista: Métricas semanales + db.execute(<<~SQL) + CREATE OR REPLACE VIEW bitacoras.vw_dron_metricas_semanal AS + SELECT + fecha, + dron_tipo, + total_ejecuciones, + completados, + fallidos, + zombies, + tiempo_promedio, + reintentos_total, + ROUND(100.0 * completados / NULLIF(total_ejecuciones, 0), 2) as tasa_exito + FROM bitacoras.dron_metricas + WHERE fecha >= CURRENT_DATE - INTERVAL '7 days' + ORDER BY fecha DESC, dron_tipo + SQL + puts "✅ Vista creada: vw_dron_metricas_semanal" + + # ============================================================================ + # Comentarios de documentación + # ============================================================================ + + puts "\n📝 Agregando comentarios de documentación..." + + db.execute("COMMENT ON TABLE bitacoras.dron_logs IS 'Logs detallados de cada dron en la colonia (NO visible directamente en Bitácora Web)'") + db.execute("COMMENT ON TABLE bitacoras.dron_avances IS 'Avances paso-a-paso de flujos multi-paso (orquestación)'") + db.execute("COMMENT ON TABLE bitacoras.dron_metricas IS 'Métricas agregadas diarias por tipo de dron (para dashboard y estadísticas)'") + puts "✅ Comentarios agregados" +end + +puts "\n🎉 Migration 003 completed successfully!" +puts "\nResumen:" +puts " - bitacoras.dron_logs: Logs detallados" +puts " - bitacoras.dron_avances: Avances paso-a-paso" +puts " - bitacoras.dron_metricas: Métricas agregadas" +puts " - Vistas: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal" +puts "\n🏁 Migration finished." diff --git a/adn/tools/db/migrations/003_create_dron_tables.sql b/adn/tools/db/migrations/003_create_dron_tables.sql new file mode 100644 index 00000000..1ba16fe4 --- /dev/null +++ b/adn/tools/db/migrations/003_create_dron_tables.sql @@ -0,0 +1,177 @@ +-- Migración 003: Tablas para sistema de Drones (AtomicDrones) +-- Fecha: 2026-04-07 +-- Propósito: Crear tablas internas para registro detallado de drones + +-- ============================================================================ +-- Tabla 1: dron_logs - Logs detallados de cada dron +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS bitacoras.dron_logs ( + id SERIAL PRIMARY KEY, + dron_id VARCHAR(50) NOT NULL, -- ej: dron_083045_123 + flujo_id VARCHAR(50), -- ID de orquestación (si aplica) + tipo VARCHAR(20) NOT NULL, -- ejecutor|vigilante|sanador|planificador|mensajero|orquestador + estado VARCHAR(20) NOT NULL, -- pending|running|completed|failed|zombie + cmd TEXT, -- Comando ejecutado + output TEXT, -- Salida del comando + exit_code INTEGER, -- Código de retorno + started_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + heartbeat TIMESTAMP DEFAULT NOW(), -- Último heartbeat + reintentos INTEGER DEFAULT 0, + metadata JSONB DEFAULT '{}'::jsonb, -- Metadata adicional + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Índices para consultas eficientes +CREATE INDEX IF NOT EXISTS idx_dron_logs_estado ON bitacoras.dron_logs(estado); +CREATE INDEX IF NOT EXISTS idx_dron_logs_heartbeat ON bitacoras.dron_logs(heartbeat); +CREATE INDEX IF NOT EXISTS idx_dron_logs_flujo ON bitacoras.dron_logs(flujo_id); +CREATE INDEX IF NOT EXISTS idx_dron_logs_tipo ON bitacoras.dron_logs(tipo); +CREATE INDEX IF NOT EXISTS idx_dron_logs_started_at ON bitacoras.dron_logs(started_at); + +-- ============================================================================ +-- Tabla 2: dron_avances - Avances paso-a-paso (para flujos multi-paso) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS bitacoras.dron_avances ( + id SERIAL PRIMARY KEY, + dron_id VARCHAR(50) NOT NULL, + flujo_id VARCHAR(50), -- ID de orquestación + paso INTEGER NOT NULL, -- Número de paso en flujo + descripcion TEXT NOT NULL, -- Qué se está haciendo + estado VARCHAR(20) NOT NULL, -- pending|running|completed|failed + output TEXT, -- Salida del paso + started_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + metadata JSONB DEFAULT '{}'::jsonb, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Índices para consultas eficientes +CREATE INDEX IF NOT EXISTS idx_dron_avances_dron ON bitacoras.dron_avances(dron_id); +CREATE INDEX IF NOT EXISTS idx_dron_avances_flujo ON bitacoras.dron_avances(flujo_id); +CREATE INDEX IF NOT EXISTS idx_dron_avances_paso ON bitacoras.dron_avances(paso); + +-- ============================================================================ +-- Tabla 3: dron_metricas - Métricas de rendimiento (agregaciones diarias) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS bitacoras.dron_metricas ( + id SERIAL PRIMARY KEY, + fecha DATE NOT NULL, + dron_tipo VARCHAR(20) NOT NULL, + total_ejecuciones INTEGER DEFAULT 0, + completados INTEGER DEFAULT 0, + fallidos INTEGER DEFAULT 0, + zombies INTEGER DEFAULT 0, + tiempo_promedio INTERVAL DEFAULT '0 seconds', + reintentos_total INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(fecha, dron_tipo) +); + +-- Índice para consultas por fecha +CREATE INDEX IF NOT EXISTS idx_dron_metricas_fecha ON bitacoras.dron_metricas(fecha); + +-- ============================================================================ +-- Trigger: Actualizar updated_at automáticamente +-- ============================================================================ + +-- Función para actualizar updated_at +CREATE OR REPLACE FUNCTION bitacoras.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger para dron_logs +DROP TRIGGER IF EXISTS update_dron_logs_updated_at ON bitacoras.dron_logs; +CREATE TRIGGER update_dron_logs_updated_at + BEFORE UPDATE ON bitacoras.dron_logs + FOR EACH ROW + EXECUTE FUNCTION bitacoras.update_updated_at_column(); + +-- Trigger para dron_metricas +DROP TRIGGER IF EXISTS update_dron_metricas_updated_at ON bitacoras.dron_metricas; +CREATE TRIGGER update_dron_metricas_updated_at + BEFORE UPDATE ON bitacoras.dron_metricas + FOR EACH ROW + EXECUTE FUNCTION bitacoras.update_updated_at_column(); + +-- ============================================================================ +-- Comentario de documentación +-- ============================================================================ + +COMMENT ON TABLE bitacoras.dron_logs IS 'Logs detallados de cada dron en la colonia (NO visible directamente en Bitácora Web)'; +COMMENT ON TABLE bitacoras.dron_avances IS 'Avances paso-a-paso de flujos multi-paso (orquestación)'; +COMMENT ON TABLE bitacoras.dron_metricas IS 'Métricas agregadas diarias por tipo de dron (para dashboard y estadísticas)'; + +COMMENT ON COLUMN bitacoras.dron_logs.dron_id IS 'Identificador único del dron (ej: dron_083045_123)'; +COMMENT ON COLUMN bitacoras.dron_logs.flujo_id IS 'ID de orquestación para agrupar drones de un mismo flujo'; +COMMENT ON COLUMN bitacoras.dron_logs.tipo IS 'Tipo de dron: ejecutor, vigilante, sanador, planificador, mensajero, orquestador'; +COMMENT ON COLUMN bitacoras.dron_logs.estado IS 'Estado actual: pending, running, completed, failed, zombie'; +COMMENT ON COLUMN bitacoras.dron_logs.heartbeat IS 'Último heartbeat - si > 5min, dron se considera zombie'; + +-- ============================================================================ +-- Vistas útiles para consultas frecuentes +-- ============================================================================ + +-- Vista: Drones activos por estado +CREATE OR REPLACE VIEW bitacoras.vw_dron_estado AS +SELECT + estado, + tipo, + COUNT(*) as cantidad, + MIN(started_at) as mas_antiguo, + MAX(started_at) as mas_reciente +FROM bitacoras.dron_logs +WHERE estado IN ('running', 'pending') +GROUP BY estado, tipo; + +-- Vista: Drones zombies (heartbeat > 5min) +CREATE OR REPLACE VIEW bitacoras.vw_dron_zombies AS +SELECT + id, + dron_id, + tipo, + cmd, + started_at, + heartbeat, + EXTRACT(EPOCH FROM (NOW() - heartbeat))/60 as minutos_sin_heartbeat +FROM bitacoras.dron_logs +WHERE estado = 'running' + AND heartbeat < NOW() - INTERVAL '5 minutes'; + +-- Vista: Métricas de los últimos 7 días +CREATE OR REPLACE VIEW bitacoras.vw_dron_metricas_semanal AS +SELECT + fecha, + dron_tipo, + total_ejecuciones, + completados, + fallidos, + zombies, + tiempo_promedio, + reintentos_total, + ROUND(100.0 * completados / NULLIF(total_ejecuciones, 0), 2) as tasa_exito +FROM bitacoras.dron_metricas +WHERE fecha >= CURRENT_DATE - INTERVAL '7 days' +ORDER BY fecha DESC, dron_tipo; + +-- ============================================================================ +-- Mensaje de confirmación +-- ============================================================================ + +DO $$ +BEGIN + RAISE NOTICE '✅ Migración 003 completada: Tablas de Drones creadas exitosamente'; + RAISE NOTICE ' - bitacoras.dron_logs: Logs detallados'; + RAISE NOTICE ' - bitacoras.dron_avances: Avances paso-a-paso'; + RAISE NOTICE ' - bitacoras.dron_metricas: Métricas agregadas'; + RAISE NOTICE ' - Vistas: vw_dron_estado, vw_dron_zombies, vw_dron_metricas_semanal'; +END $$;