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>
234 lines
7.8 KiB
Ruby
234 lines
7.8 KiB
Ruby
# 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
|