# frozen_string_literal: true # Dron::Ejecutor — Módulo atómico para ejecución de comandos # # Responsabilidad única: Ejecutar un comando y reportar resultado require_relative 'base' require_relative '../../db/core/dron_db' module Dron class Ejecutor DEFAULT_TIMEOUT = 3600 HEARTBEAT_INTERVAL = 30 class << self def lanzar(cmd:, nota: nil, evento_id: nil, timeout: DEFAULT_TIMEOUT, flujo_id: nil, nodo: nil) Base.logger.info("🛠️ Dron Ejecutor iniciado: #{Process.pid}") dron_id = ADN::DB::DronDB.generar_dron_id ADN::DB::DronDB.registrar_inicio(tipo: 'ejecutor', cmd: cmd, flujo_id: flujo_id, metadata: { nota: nota, evento_id: evento_id, timeout: timeout, nodo: nodo }) evento_id = registrar_bitacora(nota, evento_id, 'inicio', nodo) if nota resultado = ejecutar_comando(cmd, dron_id, timeout) ADN::DB::DronDB.registrar_fin(dron_id, resultado[:exit_code], resultado[:output]) registrar_bitacora(nota, evento_id, 'fin', resultado, nodo) if evento_id Base.logger.info("🛠️ Dron Ejecutor completado: #{dron_id} - #{resultado[:exit_code] == 0 ? '✅' : '❌'}") { dron_id: dron_id, exit_code: resultado[:exit_code], duration: resultado[:duration], evento_id: evento_id } rescue StandardError => e Base.logger.error("🛠️ Dron Ejecutor falló: #{e.message}") { dron_id: nil, exit_code: 1, output: e.message, duration: 0, error: e.message } end private def ejecutar_comando(cmd, dron_id, timeout) start_time = Time.now output = [] exit_code = nil heartbeat_thread = Thread.new do loop do sleep(HEARTBEAT_INTERVAL) ADN::DB::DronDB.actualizar_heartbeat(dron_id) end end begin require 'open3' Open3.popen3(cmd) do |stdin, stdout, stderr, wait_thr| # Leer stdout y stderr en threads separados out_thread = Thread.new { output << stdout.read } err_thread = Thread.new { output << stderr.read } out_thread.join err_thread.join exit_code = wait_thr.value.exitstatus end rescue Timeout::Error exit_code = 124 output << "\n[TIMEOUT] #{timeout}s excedidos" rescue StandardError => e exit_code = 1 output << "\n[ERROR] #{e.message}" ensure heartbeat_thread.exit end { exit_code: exit_code || 1, output: output.join("\n").strip, duration: (Time.now - start_time).round(2) } end def registrar_bitacora(nota, evento_id, tipo, resultado = nil, nodo = nil) return nil unless nota require_relative '../../db/core/bitacora_db' # Detectar nodo automáticamente si no se especifica nodo_id = detectar_nodo_id(nodo) if tipo == 'inicio' descripcion = "🛸 dron: #{nota}" BitacorasDB::BitacoraDB.with_connection do |db| evento = db.crear_evento(nodo_id: nodo_id, descripcion: descripcion, inicio: Time.now.strftime('%H:%M'), estado: '⏳') evento['id'] # Retornar ID para actualizar después end else estado = resultado[:exit_code] == 0 ? '✅' : '❌' descripcion = "🛸 dron: #{nota} — #{estado} (#{Base.formato_duracion(resultado[:duration])})" BitacorasDB::BitacoraDB.with_connection do |db| db.actualizar_evento(evento_id, descripcion: descripcion, fin: Time.now.strftime('%H:%M')) end evento_id # Retornar mismo ID end rescue StandardError => e Base.logger.warn("⚠️ Error en bitácora: #{e.message}") nil end def detectar_nodo_id(nodo) return nodo if nodo.is_a?(Integer) # Si no se especifica nodo, usar el hostname para detectar require 'socket' hostname = Socket.gethostname.downcase # Buscar nodo por nombre en la tabla de nodos require_relative '../../db/core/bitacora_db' sql = 'SELECT id FROM nodos WHERE LOWER(nombre) = $1 LIMIT 1' resultado = nil BitacorasDB::BitacoraDB.with_connection do |db| resultado = db.execute(sql, [hostname]) end if resultado&.any? resultado.first['id'] else # Fallback: buscar por prefijo (ej: srv-ns8 coincide con srv-ns8.frlr.utn.edu.ar) sql = 'SELECT id FROM nodos WHERE LOWER(nombre) LIKE $1 LIMIT 1' resultado = nil BitacorasDB::BitacoraDB.with_connection do |db| resultado = db.execute(sql, ["#{hostname}%"]) end resultado&.any? ? resultado.first['id'] : 1 end rescue StandardError => e Base.logger.warn("⚠️ Error detectando nodo: #{e.message}, usando default (1)") 1 end end end end