# 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