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:
co-authored by
Claude Opus 4.6
parent
b5e89144e3
commit
3543e62263
+13
-485
@@ -2,508 +2,36 @@
|
|||||||
# frozen_string_literal: true
|
# 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:
|
# Interfaz CLI unificada para la flota de drones atómicos:
|
||||||
# - Lanzar tareas en background con auto-bitácora
|
# - ejecutor: lanza comandos en background con auto-bitácora
|
||||||
# - Vigilar procesos existentes
|
# - vigilante: monitorea flota, detecta zombies, consolida estados
|
||||||
# - Orquestar flota: detectar drones caídos, estancados
|
# - sanador: re-intenta fallidos, limpia antiguos
|
||||||
# - Reportar salud del sistema en tiempo real
|
|
||||||
#
|
#
|
||||||
# Principio: Sistema Vivo — los drones operan, reportan
|
# Arquitectura: DB-First, módulos atómicos, composición tipo "Menos es Más"
|
||||||
# y se auto-gestionan sin intervención humana.
|
|
||||||
#
|
#
|
||||||
# Uso:
|
# Uso:
|
||||||
# ./adn/tools/run dron lanzar --evento AUTO -- <comando>
|
# ./adn/tools/run dron lanzar --evento AUTO -- <comando>
|
||||||
# ./adn/tools/run dron flota # Dashboard de la flota
|
# ./adn/tools/run dron flota # Dashboard de la flota
|
||||||
# ./adn/tools/run dron salud # Health check activo
|
# ./adn/tools/run dron salud # Health check activo
|
||||||
# ./adn/tools/run dron estado # Estado detallado
|
# ./adn/tools/run dron estado <ID> # Estado detallado
|
||||||
# ./adn/tools/run dron limpiar # Limpiar completados
|
# ./adn/tools/run dron sanear # Saneamiento completo
|
||||||
# ==========================================================
|
# ==========================================================
|
||||||
|
|
||||||
require 'optparse'
|
require_relative 'dron/dispatcher'
|
||||||
require 'fileutils'
|
require_relative '../core/logger'
|
||||||
require 'time'
|
require_relative '../core/constants'
|
||||||
require 'json'
|
|
||||||
|
|
||||||
module ADN
|
module ADN
|
||||||
class SubcomandoDron
|
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)
|
def initialize(args, logger = nil)
|
||||||
@args = args
|
@args = args
|
||||||
@logger = logger
|
@logger = logger || ADN::Logger.instance
|
||||||
FileUtils.mkdir_p(DRON_DIR)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def ejecutar
|
def ejecutar
|
||||||
if @args.empty? || @args.first == 'help' || @args.first == '--help'
|
Dron::Dispatcher.ejecutar(@args.dup)
|
||||||
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 -- <comando>"
|
|
||||||
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 -- <cmd> 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
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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<Hash>] 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<Hash>] 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<Hash>] 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<Hash>] 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<Hash>] 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<Hash>] 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
|
||||||
@@ -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."
|
||||||
@@ -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 $$;
|
||||||
Reference in New Issue
Block a user