#!/usr/bin/env ruby # frozen_string_literal: true # ========================================================== # adn/tools/bkps/bkps.rb — CLI BKPs para ecosistema ADN # ---------------------------------------------------------- # Migrado desde dtic-BKPs v6.0 (A03.P002) # Uso: ./adn/tools/run bkps # ========================================================== require 'optparse' require 'shellwords' require 'json' require 'date' require 'time' require_relative 'lib/config' require_relative 'lib/proc_proxmox' require_relative 'lib/proc_xen' require_relative 'lib/sync_rclone' require_relative 'lib/menu' module ADN class SubcomandoBkps BKPS_DIR = File.dirname(File.expand_path(__FILE__)) CONFIG_PATH = File.join(BKPS_DIR, 'bkps.yml') TIPOS = { 'proxmox' => -> (log) { BKPs::Procesador::Proxmox.new(log) }, 'xva' => -> (log) { BKPs::Procesador::Xen.new(log) }, 'rclone' => -> (log) { BKPs::Procesador::Rclone.new(log) }, }.freeze def initialize(args, logger = nil) @args = args @logger = logger || ADN::Logger.new @config = BKPs::Config.new(CONFIG_PATH) end def ejecutar if @args.empty? || @args.first == 'help' || @args.first == '--help' mostrar_ayuda return end comando = @args.shift case comando when 'list' cmd_list when 'run' cmd_run(@args) when 'orquestar' cmd_orquestar(@args) when 'menu' cmd_menu when 'status' cmd_status when 'estados' cmd_estados when 'sanear' cmd_sanear(@args) when 'backup' cmd_backup(@args) when 'buscar' cmd_buscar(@args) else puts "#{Color::RED}✗ Subcomando desconocido en bkps: #{comando}#{Color::RESET}" mostrar_ayuda exit 1 end end private # ─── Subcomandos ───────────────────────────────────────────── def cmd_buscar(args) nodo = args.shift unless nodo puts "#{Color::RED}✗ Falta nombre de nodo#{Color::RESET}" puts "Uso: ./adn/tools/run bkps buscar " return end retencion = @config.raw_config['retencion'] || {} tiers = retencion['tiers'] || [] candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb') vmid_map = cargar_mapa_vmid vmid_inverso = vmid_map.invert # nodo => vmid # Normalizar búsqueda (case insensitive) patron = nodo.downcase total = 0 puts "\n#{Color::BOLD}#{Color::CYAN}🔍 Backups de '#{nodo}' en todos los tiers#{Color::RESET}\n" tiers.each_with_index do |tier, idx| tier_num = idx + 1 resultados = [] case tier['tipo'] when 'proxmox' system("ruby #{candados_path} authorize > /dev/null 2>&1") storage = tier['storage'] hosts = tier['hosts'] || [] ruta_dump = "/mnt/pve/#{storage}/dump" # Buscar VMID del nodo vmid = vmid_inverso[nodo] || vmid_inverso.find { |k, _| k.downcase.include?(patron) }&.last if vmid hosts.each do |h| cmd_ls = "ls -lh #{ruta_dump}/vzdump-*#{vmid}* 2>/dev/null" res = `ruby #{candados_path} run admindasu SSHPASS 'sshpass -e ssh -o StrictHostKeyChecking=no root@#{h['ip']} "#{cmd_ls}"' 2>&1` unless res.include?('No such file') || res.strip.empty? || res.include?('Connection') res.split("\n").each do |linea| next if linea.empty? || linea.include?('OPTIONS') || linea.include?('total') partes = linea.split(/\s+/) next if partes.length < 9 size = partes[4] fecha = "#{partes[5]} #{partes[6]} #{partes[7]}" nombre = File.basename(partes[8..-1].join(' ')) resultados << { nombre: nombre, size: size, fecha: fecha } end break end end end system("ruby #{candados_path} cerrar > /dev/null 2>&1") when 'local' ruta_base = tier['ruta'] # Buscar directorio del nodo (case insensitive) Dir.glob("#{ruta_base}/*").each do |dir| dir_name = File.basename(dir) next unless dir_name.downcase.include?(patron) Dir.glob("#{dir}/*.{tar.gz,tar.zst,vma.zst}").sort_by { |f| File.mtime(f) }.each do |archivo| nombre = File.basename(archivo) size = format_size(File.size(archivo)) fecha = File.mtime(archivo).strftime('%Y-%m-%d %H:%M') resultados << { nombre: nombre, size: size, fecha: fecha } end end when 'rclone' remote = tier['remote'] # Buscar directorio del nodo output = `rclone lsl '#{remote}' --max-depth 2 2>&1` output.split("\n").each do |linea| partes = linea.strip.split(/\s+/, 4) next if partes.length < 4 ruta_rel = partes[3] next unless ruta_rel.include?('/') dir_name = ruta_rel.split('/').first next unless dir_name.downcase.include?(patron) nombre = File.basename(ruta_rel) next unless nombre.match?(/\.(tar\.gz|tar\.zst|vma\.zst)$/) size_bytes = partes[0].to_i fecha = "#{partes[1]} #{partes[2].split('.').first}" resultados << { nombre: nombre, size: format_size(size_bytes), fecha: fecha } end end # Mostrar puts "#{Color::BOLD}Tier #{tier_num}: #{tier['nombre']}#{Color::RESET}" if resultados.empty? puts " #{Color::DIM}(sin backups)#{Color::RESET}" else resultados.each do |r| puts " #{Color::GREEN}✔#{Color::RESET} #{r[:fecha]} #{r[:size].rjust(8)} #{r[:nombre]}" end total += resultados.length end puts "" end puts "#{Color::BOLD}Total: #{total} archivo(s) en #{tiers.length} tiers#{Color::RESET}" end def format_size(bytes) return '0B' if bytes == 0 units = ['B', 'KB', 'MB', 'GB', 'TB'] exp = (Math.log(bytes) / Math.log(1024)).to_i exp = units.length - 1 if exp >= units.length "%.1f%s" % [bytes.to_f / (1024 ** exp), units[exp]] end def cmd_list puts "#{Color::BOLD}Tareas:#{Color::RESET}" @config.tareas.each_with_index do |t, i| puts " T#{i+1} #{t[:id]} #{t[:texto]} [#{t[:tipo]}] #{t[:origen]} → #{t[:destino]}" end puts "\n#{Color::BOLD}Comandos:#{Color::RESET}" @config.comandos.each_with_index do |c, i| puts " C#{i+1} #{c[:id]} #{c[:texto]} → #{c[:tareas]&.join(', ')}" end end def cmd_run(args) options = {} OptionParser.new do |opts| opts.banner = "Uso: ./adn/tools/run bkps run [opciones]" opts.on("--batch", "Modo batch (sin interacción, para cron)") { options[:batch] = true } opts.on("--dry-run", "Simulación sin ejecutar") { options[:dry_run] = true } opts.on("--archivo NOMBRE", "Procesar solo este archivo (1-dron-por-archivo)") { |a| options[:archivo] = a } end.parse!(args) ref = args.shift unless ref puts "#{Color::RED}✗ Falta referencia (T1, C1, o ID de tarea/comando)#{Color::RESET}" return end case ref.upcase when /^T(\d+)$/ idx = $1.to_i - 1 tarea = @config.tareas[idx] if tarea ejecutar_tarea(tarea, options) else puts "#{Color::RED}✗ Tarea T#{idx+1} no encontrada#{Color::RESET}" end when /^C(\d+)$/ idx = $1.to_i - 1 comando = @config.comandos[idx] if comando ejecutar_comando(comando, options) else puts "#{Color::RED}✗ Comando C#{idx+1} no encontrado#{Color::RESET}" end else # Buscar por ID textual tarea = @config.find_tarea(ref) if tarea ejecutar_tarea(tarea, options) else comando = @config.comandos.find { |c| c[:id].to_s == ref } if comando ejecutar_comando(comando, options) else puts "#{Color::RED}✗ '#{ref}' no es una tarea ni comando válido#{Color::RESET}" end end end end def cmd_menu # Auto-screen para sesiones desatendidas auto_screen! unless ENV['TERM']&.start_with?('screen') || ENV['STY'] BKPs::Menu.new(@config, self, @logger).ejecutar end def cmd_status puts "#{Color::BOLD}#{Color::CYAN}dtic-BKPs — Estado del Sistema#{Color::RESET}\n\n" puts "#{Color::BOLD}Configuración:#{Color::RESET} #{CONFIG_PATH}" puts "#{Color::BOLD}Auto-avanzar:#{Color::RESET} #{@config.auto_avanzar}" puts "#{Color::BOLD}Tareas:#{Color::RESET} #{@config.tareas.length}" puts "#{Color::BOLD}Comandos:#{Color::RESET} #{@config.comandos.length}" puts "" # Verificar herramientas tools = { 'rclone' => 'rclone version', 'pv' => 'pv --version', 'tar' => 'tar --version' } puts "#{Color::BOLD}Herramientas:#{Color::RESET}" tools.each do |name, cmd| disponible = system("#{cmd} > /dev/null 2>&1") icono = disponible ? "#{Color::GREEN}✔#{Color::RESET}" : "#{Color::RED}✖#{Color::RESET}" puts " #{icono} #{name}" end # Reporte de espacio en disco monitoreo = @config.raw_config['monitoreo'] if monitoreo && monitoreo['rutas_espacio'] puts "\n#{Color::BOLD}📊 Espacio en Disco#{Color::RESET}" puts "─" * 70 printf " %-20s %8s %8s %8s %6s\n", 'Ruta', 'Total', 'Usado', 'Libre', 'Uso%' puts " #{"─" * 64}" monitoreo['rutas_espacio'].each do |r| ruta = r['ruta'] if File.exist?(ruta) df = `df -h '#{ruta}' 2>/dev/null`.lines.last&.split if df && df.length >= 5 printf " %-20s %8s %8s %8s %6s\n", r['nombre'], df[1], df[2], df[3], df[4] end else printf " %-20s %8s\n", r['nombre'], "#{Color::RED}✖ no montado#{Color::RESET}" end end end # Retención configurada retencion = @config.raw_config['retencion'] if retencion puts "\n#{Color::BOLD}🗑️ Retención#{Color::RESET}" puts " Días default: #{retencion['dias_default']}" puts " Storage: #{retencion['storage']}" end end # ─── Subcomandos asimilados de backup.rb (F4) ──────────────── def cmd_estados @logger.info("Consultando estado de backups en cluster Proxmox") hosts = [ { nombre: 'srv-pmox1', ip: '10.0.10.201' }, { nombre: 'srv-dasu', ip: '100.116.210.36' }, ] puts "\n#{Color::BOLD}🔍 Estado de Backups en Tiempo Real (Proxmox)#{Color::RESET}" puts "─" * 60 candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb') system("ruby #{candados_path} authorize > /dev/null 2>&1") hosts.each do |h| print "📡 #{h[:nombre]} (#{h[:ip]})... " cmd = "/usr/bin/pvesh get /cluster/tasks --output-format json" cmd_seguro = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{h[:ip]} '#{cmd}'" clave = (h[:nombre] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu') output = `ruby #{candados_path} run #{clave} SSHPASS '#{cmd_seguro}' 2>&1` if output.include?('[') begin json_str = output[output.index('[')..output.rindex(']')] tasks = JSON.parse(json_str) activas = tasks.select { |t| t['type'] == 'vzdump' && t['endtime'].nil? } puts "#{Color::GREEN}OK#{Color::RESET}" if activas.empty? puts " #{Color::DIM}Sin tareas de backup activas.#{Color::RESET}" else activas.each do |t| puts " ▶ #{Color::CYAN}#{t['id']}#{Color::RESET} [#{t['status'] || 'ejecutando'}] (Inicio: #{Time.at(t['starttime']).strftime('%H:%M:%S')})" end end # Últimos 5 vzdump completados recientes = tasks.select { |t| t['type'] == 'vzdump' && t['endtime'] } .sort_by { |t| -t['endtime'] }.first(5) unless recientes.empty? puts " #{Color::DIM}Últimos completados:#{Color::RESET}" recientes.each do |t| estado = t['status'] == 'OK' ? "#{Color::GREEN}✔#{Color::RESET}" : "#{Color::RED}✖#{Color::RESET}" puts " #{estado} #{Time.at(t['starttime']).strftime('%H:%M')}→#{Time.at(t['endtime']).strftime('%H:%M')} #{t['id']}" end end rescue => e puts "#{Color::YELLOW}JSON ERROR: #{e.message}#{Color::RESET}" end else puts "#{Color::RED}ERROR#{Color::RESET}" puts " #{Color::DIM}No se pudo conectar o sin tareas.#{Color::RESET}" end end system("ruby #{candados_path} cerrar > /dev/null 2>&1") end def cmd_sanear(args) retencion = @config.raw_config['retencion'] || {} options = { dias: retencion['dias_default'] || 6 } OptionParser.new do |opts| opts.banner = "Uso: ./adn/tools/run bkps sanear [opciones]" opts.on("--batch", "Modo batch (sin confirmación)") { options[:batch] = true } opts.on("--dias N", Integer, "Días de retención (default: #{options[:dias]})") { |d| options[:dias] = d } opts.on("--tier N", Integer, "Solo tier N (1=proxmox, 2=local, 3=nube)") { |t| options[:tier] = t } opts.on("--dry-run", "Solo listar, no borrar") { options[:dry_run] = true } end.parse!(args) tiers = retencion['tiers'] || [] if tiers.empty? @logger.error("No hay tiers configurados en retencion.tiers") return end candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb') vmid_map = cargar_mapa_vmid total_eliminados = 0 total_protegidos = 0 tiers.each_with_index do |tier, idx| tier_num = idx + 1 next if options[:tier] && options[:tier] != tier_num puts "\n#{Color::BOLD}#{Color::CYAN}═══ Tier #{tier_num}: #{tier['nombre']} ═══#{Color::RESET}" case tier['tipo'] when 'proxmox' elim, prot = sanear_tier_proxmox(tier, options, candados_path, vmid_map) when 'local' elim, prot = sanear_tier_local(tier, options) when 'rclone' elim, prot = sanear_tier_rclone(tier, options) else @logger.error("Tipo de tier desconocido: #{tier['tipo']}") next end total_eliminados += (elim || 0) total_protegidos += (prot || 0) end puts "\n#{Color::BOLD}═══ Resumen ═══#{Color::RESET}" puts " Eliminados: #{total_eliminados} sets" puts " 🛡️ Protegidos: #{total_protegidos} nodo(s)" end # ─── Tier 1: Proxmox (SSH) ───────────────────────────────── def sanear_tier_proxmox(tier, options, candados_path, vmid_map) dias = options[:dias] storage = tier['storage'] hosts = tier['hosts'] || [] ruta_dump = "/mnt/pve/#{storage}/dump" candado_clave = tier['candado'] || 'srv-dasu:root' system("ruby #{candados_path} authorize > /dev/null 2>&1") output_ls = "" hosts.each do |h| cmd_ls = "ls -1 --full-time #{ruta_dump}/vzdump-*" res = `ruby #{candados_path} run #{candado_clave} SSHPASS 'sshpass -e ssh -o StrictHostKeyChecking=no root@#{h['ip']} "#{cmd_ls}"' 2>&1` if $?.success? || res.include?('No such file') output_ls = res break end end if output_ls.empty? || output_ls.include?('No such file') puts "#{Color::GREEN}✓ Sin backups en #{ruta_dump}#{Color::RESET}" system("ruby #{candados_path} cerrar > /dev/null 2>&1") return [0, 0] end # Parsear archivos vzdump todos = {} por_nodo = {} limite = Time.now - (dias * 86400) output_ls.split("\n").each do |linea| next if linea.empty? || linea.include?('OPTIONS') partes = linea.split(/\s+/) next if partes.length < 9 fecha_str = "#{partes[5]} #{partes[6]}" ruta = partes[8..-1].join(' ') nombre = File.basename(ruta) fp = nombre.sub(/\.(log|vma\.zst|tar\.zst|vma\.zst\.notes|tar\.zst\.notes)$/, '') begin mtime = Time.parse(fecha_str) vmid = fp.match(/vzdump-(?:qemu|lxc)-(\d+)-/)&.[](1) nodo = vmid_map[vmid] || "VM-#{vmid}" todos[fp] ||= { fecha: mtime, nodo: nodo, archivos: [] } todos[fp][:archivos] << ruta por_nodo[nodo] ||= [] por_nodo[nodo] << fp unless por_nodo[nodo].include?(fp) rescue; next; end end elim, prot = aplicar_retencion(todos, por_nodo, limite, options) # Borrar via SSH if elim > 0 && !options[:dry_run] candidatos = calcular_candidatos(todos, por_nodo, limite) candidatos.each_value do |info| info[:archivos].each do |ruta| system("ruby #{candados_path} run #{candado_clave} SSHPASS 'sshpass -e ssh -o StrictHostKeyChecking=no root@#{hosts.first['ip']} \"rm -f '#{ruta}'\"' > /dev/null 2>&1") end end end system("ruby #{candados_path} cerrar > /dev/null 2>&1") [elim, prot] end # ─── Tier 2: Local (filesystem) ──────────────────────────── def sanear_tier_local(tier, options) dias = options[:dias] ruta_base = tier['ruta'] unless File.directory?(ruta_base) puts "#{Color::RED}✗ Ruta no encontrada: #{ruta_base}#{Color::RESET}" return [0, 0] end todos = {} por_nodo = {} limite = Time.now - (dias * 86400) Dir.glob("#{ruta_base}/*").select { |d| File.directory?(d) }.each do |dir_nodo| nodo = File.basename(dir_nodo) Dir.glob("#{dir_nodo}/*.{tar.gz,tar.zst,vma.zst}").each do |archivo| nombre = File.basename(archivo) mtime = File.mtime(archivo) todos[nombre] ||= { fecha: mtime, nodo: nodo, archivos: [] } todos[nombre][:archivos] << archivo por_nodo[nodo] ||= [] por_nodo[nodo] << nombre unless por_nodo[nodo].include?(nombre) end end elim, prot = aplicar_retencion(todos, por_nodo, limite, options) # Borrar archivos locales if elim > 0 && !options[:dry_run] candidatos = calcular_candidatos(todos, por_nodo, limite) candidatos.each_value do |info| info[:archivos].each { |f| File.delete(f) rescue nil } end end [elim, prot] end # ─── Tier 3: Nube (rclone) ───────────────────────────────── def sanear_tier_rclone(tier, options) dias = options[:dias] remote = tier['remote'] output = `rclone lsl '#{remote}' --max-depth 2 2>&1` if output.include?('ERROR') || output.strip.empty? puts "#{Color::GREEN}✓ Sin backups en #{remote}#{Color::RESET}" return [0, 0] end todos = {} por_nodo = {} limite = Time.now - (dias * 86400) output.split("\n").each do |linea| # rclone lsl format: SIZE DATE TIME PATH partes = linea.strip.split(/\s+/, 4) next if partes.length < 4 fecha_str = "#{partes[1]} #{partes[2]}" ruta_rel = partes[3] next unless ruta_rel.include?('/') nodo = ruta_rel.split('/').first nombre = File.basename(ruta_rel) next unless nombre.match?(/\.(tar\.gz|tar\.zst|vma\.zst)$/) begin mtime = Time.parse(fecha_str) ruta_full = "#{remote}/#{ruta_rel}" todos[nombre] ||= { fecha: mtime, nodo: nodo, archivos: [] } todos[nombre][:archivos] << ruta_full por_nodo[nodo] ||= [] por_nodo[nodo] << nombre unless por_nodo[nodo].include?(nombre) rescue; next; end end elim, prot = aplicar_retencion(todos, por_nodo, limite, options) # Borrar via rclone if elim > 0 && !options[:dry_run] candidatos = calcular_candidatos(todos, por_nodo, limite) candidatos.each_value do |info| info[:archivos].each do |ruta| system("rclone deletefile '#{ruta}' > /dev/null 2>&1") end end end [elim, prot] end # ─── Lógica compartida de retención ──────────────────────── def aplicar_retencion(todos, por_nodo, limite, options) protegidos_fps = {} por_nodo.each do |nodo, nombres| ordenados = nombres.sort_by { |n| -(todos[n][:fecha].to_i) } protegidos_fps[ordenados.first] = true end candidatos = calcular_candidatos(todos, por_nodo, limite) protegidos = protegidos_fps.size # Mostrar protegidos protegidos_fps.each do |nombre, _| info = todos[nombre] edad = ((Time.now - info[:fecha]) / 86400).to_i @logger.info("🛡️ #{info[:nodo]} (#{edad}d) — protegido") end if candidatos.empty? puts "#{Color::GREEN}✓ Nada que limpiar.#{Color::RESET}" puts "#{Color::CYAN}🛡️ #{protegidos} nodo(s) protegido(s)#{Color::RESET}" if protegidos > 0 return [0, protegidos] end lista = candidatos.values.sort_by { |b| b[:fecha] } puts "\n#{Color::BOLD}🗑️ Candidatos a limpieza#{Color::RESET} (> #{((Time.now - limite) / 86400).to_i}d)" puts "─" * 70 lista.each_with_index do |b, i| edad = ((Time.now - b[:fecha]) / 86400).to_i puts " #{(i+1).to_s.rjust(2)} #{Color::CYAN}#{b[:nodo]}#{Color::RESET} #{b[:fecha].strftime('%Y-%m-%d')} (#{edad}d, #{b[:archivos].length} arch.)" end puts "─" * 70 total_arch = candidatos.values.sum { |b| b[:archivos].length } puts "Total: #{lista.length} sets (#{total_arch} archivos)" puts "#{Color::CYAN}🛡️ #{protegidos} nodo(s) protegido(s)#{Color::RESET}" if protegidos > 0 if options[:dry_run] puts "\n#{Color::YELLOW}[DRY-RUN] No se borró nada.#{Color::RESET}" return [0, protegidos] end if options[:batch] puts "\n#{Color::YELLOW}[BATCH] Borrando automáticamente sin pedir confirmación.#{Color::RESET}" else print "\n¿Borrar todos? (S/N): " resp = STDIN.gets&.chomp&.strip&.downcase unless resp == 's' puts "Cancelado." return [0, protegidos] end end lista.each_with_index do |b, i| puts "[#{i+1}/#{lista.length}] Limpiando #{b[:nodo]}..." end @logger.info("✔ #{lista.length} sets eliminados.") [lista.length, protegidos] end def calcular_candidatos(todos, por_nodo, limite) protegidos_fps = {} por_nodo.each do |nodo, nombres| ordenados = nombres.sort_by { |n| -(todos[n][:fecha].to_i) } protegidos_fps[ordenados.first] = true end candidatos = {} todos.each do |nombre, info| next if protegidos_fps[nombre] next unless info[:fecha] < limite candidatos[nombre] = info end candidatos end def cmd_backup(args) options = { modo: 'stop', storage: 'zfsDISCO1', compress: 'zstd' } OptionParser.new do |opts| opts.banner = "Uso: ./adn/tools/run bkps backup " opts.on("--mode MODO", "Modo vzdump (stop|snapshot|suspend)") { |m| options[:modo] = m } opts.on("--storage S", "Storage Proxmox") { |s| options[:storage] = s } end.parse!(args) nodo = args.shift unless nodo puts "#{Color::RED}✗ Falta nombre de nodo#{Color::RESET}" puts "Uso: ./adn/tools/run bkps backup [--mode stop] [--storage local]" return end datos = obtener_contexto_nodo(nodo) unless datos @logger.error("No se pudo determinar contexto para #{nodo}") return end @logger.info("Backup de #{nodo}: VM #{datos[:vmid]} en #{datos[:host_name]} (#{datos[:host_ip]})") candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb') system("ruby #{candados_path} authorize > /dev/null 2>&1") cmd_vzdump = "vzdump #{datos[:vmid]} --mode #{options[:modo]} --storage #{options[:storage]} --compress #{options[:compress]}" clave = (datos[:host_name] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu') cmd_ssh = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{datos[:host_ip]} \"#{cmd_vzdump}\"" @logger.info("Ejecutando vzdump remoto...") IO.popen("ruby #{candados_path} run #{clave} SSHPASS '#{cmd_ssh}' 2>&1") do |pf| pf.each_line { |line| puts " #{Color::DIM}#{line.strip}#{Color::RESET}" } end exito = $?.success? # Detectar errores de vzdump en el output capturado system("ruby #{candados_path} cerrar > /dev/null 2>&1") if exito @logger.info("✔ Backup de #{nodo} completado exitosamente.") else @logger.error("Fallo en backup de #{nodo}.") exit 1 end end # ─── Helpers ───────────────────────────────────────────────── def obtener_contexto_nodo(nombre) ruta = File.join(ADN::NODOS_DIR, "#{nombre}.md") return nil unless File.exist?(ruta) contenido = File.read(ruta) # VMID: soporta formato lista (- **VMID**: `107`) y tabla (| **VMID** | `116` |) vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1) || contenido.match(/\|\s*\*\*VMID\*\*\s*\|\s*`?(\d+)`?\s*\|/i)&.[](1) # Host: soporta formato lista y tabla host_name = contenido.match(/(?:Padre\/Host|Host Anfitrión|Anfitrión|Host)\*\*?:?\s*`([^`]+)`/i)&.[](1) || contenido.match(/\|\s*\*\*Host\*\*\s*\|\s*`([^`]+)`\s*\|/i)&.[](1) return nil unless vmid && host_name ruta_host = File.join(ADN::NODOS_DIR, "#{host_name}.md") host_ip = nil if File.exist?(ruta_host) c = File.read(ruta_host) host_ip = c.match(/\|\s*IP[^\|]*\|\s*`([^`]+)`\s*\|/)&.[](1) || c.match(/IP\*\*:?\s*`([^`]+)`/)&.[](1) || c.match(/IP:\s*([\d\.]+)/)&.[](1) end return nil unless host_ip host_ip = host_ip.split('/')[0] if host_ip.include?('/') { vmid: vmid, host_name: host_name, host_ip: host_ip } end def cargar_mapa_vmid mapa = {} Dir.glob(File.join(ADN::NODOS_DIR, '*.md')).each do |f| begin contenido = File.read(f) nombre = File.basename(f, '.md') vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1) mapa[vmid.to_s] = nombre if vmid rescue; next; end end mapa end # ─── Dispatcher ────────────────────────────────────────────── public def ejecutar_tarea(tarea, options = {}) tipo = tarea[:tipo].to_s factory = TIPOS[tipo] unless factory @logger.error("Tipo desconocido: #{tipo}") return false end if options[:dry_run] puts "#{Color::YELLOW}[DRY-RUN] Ejecutaría: #{tarea[:texto]} (#{tipo})#{Color::RESET}" return true end t_inicio = Time.now @logger.info("▶ #{tarea[:texto]} (#{tipo})") procesador = factory.call(@logger) # Modo atómico: procesar un solo archivo resultado = if options[:archivo] && procesador.respond_to?(:ejecutar_uno) @logger.info("🎯 Modo atómico: #{options[:archivo]}") procesador.ejecutar_uno(tarea, options[:archivo]) else procesador.ejecutar(tarea) end duracion = (Time.now - t_inicio).to_i mins = duracion / 60 segs = duracion % 60 @logger.info("✔ #{tarea[:texto]} — #{mins}m#{segs}s") resultado end # ─── Orquestador: 1 dron por archivo ────────────────────────── def cmd_orquestar(args) options = { on_error: 'abort', retries: 2 } OptionParser.new do |opts| opts.banner = "Uso: ./adn/tools/run bkps orquestar [opciones]" opts.on("--timeout N", Integer, "Timeout por dron en segundos (default: 7200)") { |t| options[:timeout] = t } opts.on("--dry-run", "Solo listar archivos sin lanzar drones") { options[:dry_run] = true } opts.on("--on-error MODE", %w[abort continue retry], "Qué hacer si un dron falla (abort|continue|retry, default: abort)") { |m| options[:on_error] = m } opts.on("--retries N", Integer, "Número de reintentos si --on-error retry (default: 2)") { |n| options[:retries] = n } end.parse!(args) ref = args.shift unless ref puts "#{Color::RED}✗ Falta referencia de tarea (T1, T2, o ID)#{Color::RESET}" return end # Resolver tarea tarea = case ref.upcase when /^T(\d+)$/ then @config.tareas[$1.to_i - 1] else @config.find_tarea(ref) end unless tarea puts "#{Color::RED}✗ Tarea '#{ref}' no encontrada#{Color::RESET}" return end tipo = tarea[:tipo].to_s factory = TIPOS[tipo] unless factory puts "#{Color::RED}✗ Tipo '#{tipo}' no soporta orquestación#{Color::RESET}" return end procesador = factory.call(@logger) unless procesador.respond_to?(:listar) puts "#{Color::RED}✗ Procesador '#{tipo}' no soporta listar (orquestación no disponible)#{Color::RESET}" return end # Escanear archivos pendientes archivos = procesador.listar(tarea) pendientes = archivos.select { |a| a[:pendiente] } puts "\n#{Color::BOLD}#{Color::CYAN}🐝 Orquestador AtomicDrones — #{tarea[:texto]}#{Color::RESET}" puts "═" * 60 puts " Total archivos: #{archivos.length}" puts " Pendientes: #{pendientes.length}" puts " Ya procesados: #{archivos.length - pendientes.length}" puts " On-error: #{options[:on_error]}#{options[:on_error] == 'retry' ? " (max #{options[:retries]})" : ''}" puts "" if pendientes.empty? puts "#{Color::GREEN}✔ Todos los archivos ya están procesados.#{Color::RESET}" return end adn_run = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'run') timeout = options[:timeout] || 7200 # Tracking de resultados resultados = [] abortado = false pendientes.each_with_index do |arch, i| tamaño_gb = (arch[:tamaño].to_f / 1024**3).round(1) puts " #{Color::BOLD}[#{i+1}/#{pendientes.length}]#{Color::RESET} #{arch[:vm]} — #{arch[:archivo]} (#{tamaño_gb}GB)" if options[:dry_run] puts " #{Color::YELLOW}[DRY-RUN] Lanzaría dron para: #{arch[:archivo]}#{Color::RESET}" resultados << { vm: arch[:vm], estado: :skipped } next end # Lanzar dron atómico con reintentos verbo = tipo == 'rclone' ? '☁️ Sync' : 'Comprimir' nota = "#{verbo} #{arch[:vm]} (#{tamaño_gb}GB)" cmd_dron = "#{adn_run} dron lanzar" \ " --nota \"#{nota}\"" \ " --timeout #{timeout}" \ " -- #{adn_run} bkps run #{ref} --batch --archivo #{Shellwords.escape(arch[:archivo])}" max_intentos = options[:on_error] == 'retry' ? options[:retries] + 1 : 1 exito = false max_intentos.times do |intento| etiqueta = max_intentos > 1 ? " (intento #{intento + 1}/#{max_intentos})" : "" puts " 🛸 Lanzando dron#{etiqueta}..." exito = system(cmd_dron) break if exito if intento < max_intentos - 1 puts " #{Color::YELLOW}⟳ Reintentando...#{Color::RESET}" end end if exito puts " #{Color::GREEN}✔ Dron completado#{Color::RESET}" resultados << { vm: arch[:vm], estado: :ok } else resultados << { vm: arch[:vm], estado: :failed } case options[:on_error] when 'abort' puts " #{Color::RED}✖ Dron falló — deteniendo orquestación#{Color::RESET}" abortado = true break when 'continue' puts " #{Color::YELLOW}⚠ Dron falló — continuando con el siguiente#{Color::RESET}" when 'retry' puts " #{Color::RED}✖ Dron falló después de #{max_intentos} intentos — deteniendo#{Color::RESET}" abortado = true break end end puts "" end # Resumen final ok = resultados.count { |r| r[:estado] == :ok } failed = resultados.count { |r| r[:estado] == :failed } puts "\n#{Color::BOLD}🐝 Resumen de Orquestación#{Color::RESET}" puts "═" * 40 puts " ✅ Exitosos: #{ok}" puts " ❌ Fallidos: #{failed}" if failed > 0 puts " ⏭️ Saltados: #{pendientes.length - ok - failed}" if abortado puts "#{Color::BOLD}🐝 Orquestación #{abortado ? 'abortada' : 'finalizada'}.#{Color::RESET}" exit 1 if failed > 0 end def ejecutar_comando(comando, options = {}) t_inicio = Time.now @logger.info("=== #{comando[:texto]} ===") tareas_ids = comando[:tareas] || [] fallos = 0 tareas_ids.each_with_index do |id, i| tarea = @config.find_tarea(id) if tarea @logger.info("Paso #{i + 1}/#{tareas_ids.length}: #{tarea[:texto]}") resultado = ejecutar_tarea(tarea, options) fallos += 1 unless resultado else @logger.error("Tarea '#{id}' no encontrada en comando '#{comando[:id]}'.") fallos += 1 end end duracion = (Time.now - t_inicio).to_i mins = duracion / 60 segs = duracion % 60 if fallos == 0 @logger.info("✔ '#{comando[:texto]}' completado exitosamente (#{mins}m#{segs}s)") else @logger.error("✖ '#{comando[:texto]}' completado con #{fallos} fallo(s) (#{mins}m#{segs}s)") exit 1 if options[:batch] end # Post-sanear automático si el comando lo tiene configurado if comando[:post_sanear] && fallos == 0 retencion = @config.raw_config['retencion'] || {} dias = retencion['dias_default'] || 6 @logger.info("🧹 Post-sanear automático (retención: #{dias} días)") cmd_sanear(["--dias", dias.to_s, "--dry-run"]) end end private def auto_screen! puts "\n#{Color::CYAN}ℹ Relanzando en sesión screen para permitir desatención...#{Color::RESET}" cmd = ([$PROGRAM_NAME] + ['bkps', 'menu']).map { |a| Shellwords.escape(a) }.join(' ') nombre = "bkps_#{Time.now.strftime('%H%M%S')}" puts "Para reconectarte: #{Color::YELLOW}screen -r #{nombre}#{Color::RESET}" sleep 1 system('screen', '-dmS', nombre, 'bash', '-c', "#{cmd}; echo ''; read -p 'Enter para cerrar...'") exit 0 end def mostrar_ayuda puts <<~HELP #{Color::BOLD}#{Color::CYAN}Herramienta ADN — Gestión de Backups (dtic-BKPs)#{Color::RESET} #{Color::YELLOW}Uso:#{Color::RESET} ./adn/tools/run bkps list Listar tareas y comandos ./adn/tools/run bkps run Ejecutar tarea o comando ./adn/tools/run bkps run C4 --batch Modo batch (sin interacción) ./adn/tools/run bkps run T1 --dry-run Simulación sin ejecutar ./adn/tools/run bkps menu Menú interactivo (auto-screen) ./adn/tools/run bkps status Estado del sistema ./adn/tools/run bkps estados Estado Proxmox en tiempo real ./adn/tools/run bkps backup Backup remoto individual (vzdump) ./adn/tools/run bkps sanear --dias 6 Limpiar backups antiguos ./adn/tools/run bkps help Esta ayuda #{Color::YELLOW}Tareas configuradas:#{Color::RESET} HELP @config.tareas.each_with_index do |t, i| puts " #{Color::GREEN}T#{i+1}#{Color::RESET} #{t[:texto]} #{Color::DIM}(#{t[:tipo]})#{Color::RESET}" end puts "\n #{Color::YELLOW}Comandos configurados:#{Color::RESET}" @config.comandos.each_with_index do |c, i| puts " #{Color::GREEN}C#{i+1}#{Color::RESET} #{c[:texto]}" end end end end