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
@@ -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
|
||||
Reference in New Issue
Block a user