# frozen_string_literal: true # ========================================================== # Dron::Ops — Operaciones de Infraestructura via AtomicDrones # ---------------------------------------------------------- # Responsabilidad: Orquestar tareas de mantenimiento de nodos # (update_os, etc.) usando 1 dron ejecutor por nodo. # # Reutiliza: # - NodosInfo (scanner de fichas .md) # - Dron::Ejecutor (ejecución atómica con auto-bitácora) # # Uso: # ./adn/tools/run dron ops update_os --target linux # ./adn/tools/run dron ops update_os --nodo srvv-dns # ./adn/tools/run dron ops update_os --target linux --dry-run # # Plan: A01.P011 — Drones de Operaciones de Infraestructura # ========================================================== require 'optparse' require 'shellwords' require_relative 'base' require_relative 'ejecutor' require_relative '../nodos/info' require_relative '../../core/colores' require_relative '../../core/constants' module Dron class Ops # Comandos SSH para actualización desatendida APT_ENV = 'DEBIAN_FRONTEND=noninteractive DEBIAN_PRIORITY=critical' APT_OPTS = "-o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold'" APT_CMD = "sudo #{APT_ENV} apt-get update 2>&1 && " \ "sudo #{APT_ENV} apt-get upgrade -y #{APT_OPTS} 2>&1 && " \ "sudo #{APT_ENV} apt-get autoremove -y 2>&1" class << self # ─── Punto de entrada CLI ────────────────────────────────── def ejecutar(args) accion = args.shift case accion when 'update_os' cmd_update_os(args) when 'ayuda', 'help', '--help', '-h', nil mostrar_ayuda else Base.logger.error("❌ Acción desconocida para ops: #{accion}") mostrar_ayuda exit 1 end end private # ─── update_os ───────────────────────────────────────────── def cmd_update_os(args) opciones = parsear_opciones(args) # Obtener nodos candidatos nodos = escanear_nodos(opciones) if nodos.empty? puts "#{Color::YELLOW}⚠ Sin nodos Linux encontrados para actualizar.#{Color::RESET}" return end mostrar_resumen(nodos, opciones) return if opciones[:dry_run] # Orquestar: 1 dron ejecutor por nodo resultados = orquestar_nodos(nodos, opciones) # Resumen final mostrar_resultados(resultados, nodos.length) exit 1 if resultados[:failed] > 0 && opciones[:on_error] == 'abort' end # ─── Scanner: reutiliza NodosInfo ────────────────────────── def escanear_nodos(opciones) nodos_dir = ADN::NODOS_DIR unless Dir.exist?(nodos_dir) Base.logger.error("Directorio de nodos no existe: #{nodos_dir}") return [] end candidatos = Dir.glob(File.join(nodos_dir, '*.md')) .reject { |f| File.basename(f).start_with?('_') } .map { |f| ADN::NodosInfo.extraer_metadata(File.basename(f, '.md')) } .compact # Filtrar por OS if opciones[:target] == 'linux' candidatos.select! { |n| n[:so] == :linux } end # Excluir nodos sin IP candidatos.reject! { |n| n[:ip].nil? || n[:ip].to_s.strip.empty? } # Excluir nodos offline candidatos.reject! { |n| n[:estado]&.to_s&.include?('🔴') } # Filtrar nodo específico if opciones[:nodo] candidatos.select! { |n| n[:nombre] == opciones[:nodo] } end # Filtrar por ring if opciones[:ring] candidatos.select! { |n| n[:ring] == opciones[:ring] } end candidatos end # ─── Orquestación: 1 dron por nodo ───────────────────────── def orquestar_nodos(nodos, opciones) resultados = { ok: 0, failed: 0, skipped: 0 } abortado = false nodos.each_with_index do |nodo, i| break if abortado puts " #{Color::BOLD}[#{i + 1}/#{nodos.length}]#{Color::RESET} " \ "#{Color::CYAN}#{nodo[:nombre]}#{Color::RESET} (#{nodo[:ip]})" # Construir comando SSH para este nodo usuario = nodo[:usuario] || 'root' puerto = nodo[:puerto_ssh] || 22 ssh_cmd = construir_ssh_cmd(nodo, usuario, puerto) # Lanzar dron ejecutor (auto-bitácora incluida) nota = "🐧 Actualizar #{nodo[:nombre]}" resultado = Dron::Ejecutor.lanzar( cmd: ssh_cmd, nota: nota, timeout: opciones[:timeout], nodo: nodo[:nombre] ) if resultado[:exit_code] == 0 puts " #{Color::GREEN}✔ Completado#{Color::RESET} (#{resultado[:duration]}s)" resultados[:ok] += 1 else resultados[:failed] += 1 case opciones[:on_error] when 'abort' puts " #{Color::RED}✖ Falló — deteniendo orquestación#{Color::RESET}" abortado = true resultados[:skipped] = nodos.length - i - 1 when 'continue' puts " #{Color::YELLOW}⚠ Falló — continuando con el siguiente#{Color::RESET}" end end puts "" end resultados end # ─── Construcción de comando SSH ─────────────────────────── def construir_ssh_cmd(nodo, usuario, puerto) ssh_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10" # Puerto no estándar ssh_opts += " -p #{puerto}" if puerto != 22 # Opciones especiales (legacy servers como XenServer) ssh_opts += " #{nodo[:ssh_opts]}" if nodo[:ssh_opts] # Clave SSH específica if nodo[:clave_ssh] clave = nodo[:clave_ssh].gsub('~', ENV['HOME'] || '/root') ssh_opts += " -i #{clave}" end remote_cmd = APT_CMD # Determinar método de autenticación if nodo[:clave_boveda] # AUTH POR PASSWORD: Usar candados run + sshpass # candados inyecta $PASS en el entorno, sshpass lo consume adn_run = File.join(ADN::PROJECT_ROOT, 'adn/tools/run') "#{adn_run} candados run #{nodo[:clave_boveda]} " \ "'sshpass -p $PASS ssh #{ssh_opts} #{usuario}@#{nodo[:ip]} \"#{remote_cmd}\"'" else # AUTH POR CLAVE SSH: Conexión directa "ssh #{ssh_opts} #{usuario}@#{nodo[:ip]} '#{remote_cmd}'" end end # ─── Presentación ───────────────────────────────────────── def mostrar_resumen(nodos, opciones) puts "" puts "#{Color::BOLD}#{Color::CYAN}🐧 Dron Ops — Actualización de OS Linux#{Color::RESET}" puts "═" * 60 puts " Nodos detectados: #{nodos.length}" puts " Target: #{opciones[:target]}" puts " Ring: #{opciones[:ring] || 'todos'}" if opciones[:ring] puts " On-error: #{opciones[:on_error]}" puts " Timeout por nodo: #{opciones[:timeout]}s" if opciones[:dry_run] puts " Modo: #{Color::YELLOW}DRY-RUN (sin ejecutar)#{Color::RESET}" end puts "" if opciones[:dry_run] nodos.each_with_index do |nodo, i| auth = nodo[:clave_boveda] ? "🔐 #{nodo[:clave_boveda]}" : "🔑 key" ring_tag = nodo[:ring] ? " [#{nodo[:ring]}]" : "" puts " #{Color::DIM}[#{i + 1}]#{Color::RESET} #{nodo[:nombre].ljust(18)} " \ "#{Color::DIM}#{nodo[:ip].ljust(16)} #{(nodo[:usuario] || 'root').ljust(8)} #{auth}#{ring_tag}#{Color::RESET}" end puts "" puts "#{Color::YELLOW}[DRY-RUN] No se lanzaron drones.#{Color::RESET}" end end def mostrar_resultados(resultados, total) puts "#{Color::BOLD}🐧 Resumen de Operaciones#{Color::RESET}" puts "═" * 40 puts " ✅ Exitosos: #{resultados[:ok]}" puts " ❌ Fallidos: #{resultados[:failed]}" if resultados[:failed] > 0 puts " ⏭️ Saltados: #{resultados[:skipped]}" if resultados[:skipped] > 0 puts "#{Color::BOLD}🐧 Operación #{resultados[:failed] > 0 ? 'con errores' : 'finalizada'}.#{Color::RESET}" end # ─── Parser de opciones ──────────────────────────────────── def parsear_opciones(args) opciones = { target: 'linux', nodo: nil, ring: nil, on_error: 'continue', timeout: 600, dry_run: false } OptionParser.new do |opts| opts.banner = "Uso: ./adn/tools/run dron ops update_os [opciones]" opts.on("--target OS", "OS a actualizar (linux)") { |t| opciones[:target] = t } opts.on("--nodo NOMBRE", "Actualizar solo este nodo") { |n| opciones[:nodo] = n } opts.on("--ring RING", %w[test standard core], "Solo nodos de este ring (test|standard|core)") { |r| opciones[:ring] = r } opts.on("--on-error MODE", %w[abort continue], "Qué hacer si un dron falla (abort|continue)") { |m| opciones[:on_error] = m } opts.on("--timeout N", Integer, "Timeout por nodo en segundos (default: 600)") { |t| opciones[:timeout] = t } opts.on("--dry-run", "Solo listar nodos sin ejecutar") { opciones[:dry_run] = true } end.parse!(args) opciones end def mostrar_ayuda puts "#{Color::BOLD}🐧 Dron Ops — Operaciones de Infraestructura#{Color::RESET}" puts "#{Color::DIM}#{'=' * 50}#{Color::RESET}" puts "" puts "#{Color::CYAN}Operaciones de mantenimiento sobre nodos vía AtomicDrones#{Color::RESET}" puts "" puts "Uso: #{Color::YELLOW}./adn/tools/run dron ops [opciones]#{Color::RESET}" puts "" puts "#{Color::BOLD}Acciones:#{Color::RESET}" puts " #{Color::GREEN}update_os#{Color::RESET} Actualizar sistema operativo de nodos" puts "" puts "#{Color::BOLD}Opciones:#{Color::RESET}" puts " #{Color::GREEN}--target OS#{Color::RESET} OS a actualizar: linux (default: linux)" puts " #{Color::GREEN}--nodo NOMBRE#{Color::RESET} Actualizar solo un nodo específico" puts " #{Color::GREEN}--on-error MODE#{Color::RESET} abort|continue (default: continue)" puts " #{Color::GREEN}--timeout N#{Color::RESET} Timeout por nodo en segundos (default: 600)" puts " #{Color::GREEN}--dry-run#{Color::RESET} Solo listar sin ejecutar" puts "" puts "#{Color::BOLD}Ejemplos:#{Color::RESET}" puts " #{Color::DIM}$ ./adn/tools/run dron ops update_os --target linux --dry-run#{Color::RESET}" puts " #{Color::CYAN}Listar nodos Linux sin actualizar#{Color::RESET}" puts " #{Color::DIM}$ ./adn/tools/run dron ops update_os --nodo srvv-dns#{Color::RESET}" puts " #{Color::CYAN}Actualizar solo srvv-dns#{Color::RESET}" puts " #{Color::DIM}$ ./adn/tools/run dron ops update_os --on-error continue#{Color::RESET}" puts " #{Color::CYAN}Actualizar toda la flota Linux continuando ante fallos#{Color::RESET}" puts "" end end end end