#!/usr/bin/env ruby # frozen_string_literal: true # ========================================================== # adn/tools/cli/dron.rb — Sistema de Drones Autónomos # ---------------------------------------------------------- # Flota de drones que operan de forma autónoma: # - Lanzar tareas en background con auto-bitácora # - Vigilar procesos existentes # - Orquestar flota: detectar drones caídos, estancados # - Reportar salud del sistema en tiempo real # # Principio: Sistema Vivo — los drones operan, reportan # y se auto-gestionan sin intervención humana. # # Uso: # ./adn/tools/run dron lanzar --evento AUTO -- # ./adn/tools/run dron flota # Dashboard de la flota # ./adn/tools/run dron salud # Health check activo # ./adn/tools/run dron estado # Estado detallado # ./adn/tools/run dron limpiar # Limpiar completados # ========================================================== require 'optparse' require 'fileutils' require 'time' require 'json' module ADN 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) @args = args @logger = logger FileUtils.mkdir_p(DRON_DIR) end def ejecutar if @args.empty? || @args.first == 'help' || @args.first == '--help' 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 -- " 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 -- 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