# frozen_string_literal: true require 'optparse' require 'fileutils' require 'date' require_relative '../core/configurador' module ADN class SubcomandoBackup def initialize(args, logger) @args = args @logger = logger # Cargar valores predeterminados desde config.yml defaults = Configurador.obtener('backup.defaults') || {} @opciones = { modo: defaults['modo'] || 'stop', storage: defaults['storage'] || 'local', compress: defaults['compress'] || 'gzip', retencion: defaults['retencion_dias'] || 6 } end def ejecutar parser = OptionParser.new do |opts| opts.banner = "Uso: ./tools/adn/run backup [OPCIONES]" opts.separator "" opts.separator "Ejecuta un backup remoto o consulta el estado actual." opts.separator "" opts.separator "Acciones:" opts.separator " Ejecutar backup del nodo especificado" opts.separator " estados Ver estado actual de los backups en Proxmox" opts.separator " sanear Limpiar backups antiguos según retención" opts.separator " predeterminar Configurar valores por defecto" opts.separator "" opts.separator "Opciones:" opts.on("--mode MODO", "Modo de backup (stop, snapshot, suspend. Default: #{@opciones[:modo]})") do |m| @opciones[:modo] = m end opts.on("--storage STORAGE", "Nombre del storage en Proxmox (Default: #{@opciones[:storage]})") do |s| @opciones[:storage] = s end opts.on("-h", "--help", "Mostrar esta ayuda") do puts opts exit end end begin parser.parse!(@args) rescue OptionParser::InvalidOption => e @logger.error("Opción inválida: #{e.message}") puts parser exit 1 end if @args.empty? @logger.error("Se requiere nombre de nodo o acción") puts parser exit 1 end case @args[0] when 'predeterminar' comando_predeterminar(@args[1..-1]) when 'estados', 'estado' comando_estados when 'sanear' comando_sanear else ejecutar_backup(@args[0]) end end private def ejecutar_backup(nodo) @logger.info("Iniciando flujo ADN-Backup para nodo: #{nodo}") # 1. Obtener contexto del nodo datos = obtener_contexto_nodo(nodo) unless datos @logger.error("No se pudo determinar el contexto técnico para #{nodo}") exit 1 end @logger.info("Contexto validado: VM #{datos[:vmid]} en #{datos[:host_name]} (#{datos[:host_ip]})") # 2. Registrar inicio en bitácora (Bitácora-First) registrar_inicio_bitacora(nodo, datos) # 3. Ejecución segura vía ns8-candados @logger.info("Solicitando autorización y ejecutando backup remoto...") comando_vzdump = "vzdump #{datos[:vmid]} --mode #{@opciones[:modo]} --storage #{@opciones[:storage]} --compress #{@opciones[:compress]}" cmd_seguro = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{datos[:host_ip]} \"#{comando_vzdump}\"" # Usamos el binario de ruby y la ruta absoluta para ns8-candados candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb') # Ejecutar con ns8-candados run # Ejecutar con ns8-candados autorizando primero system("ruby #{candados_path} authorize > /dev/null 2>&1") task_id = nil full_output = "" # Usar popen para leer la salida en tiempo real @logger.info("Conectando y monitoreando salida de Proxmox...") IO.popen("ruby #{candados_path} run admindasu SSHPASS '#{cmd_seguro}' 2>&1") do |pf| pf.each_line do |line| # Imprimir flujo para el usuario puts " #{Color::DIM}#{line.strip}#{Color::RESET}" full_output += line # Si detectamos el Task ID al inicio, lo registramos de inmediato if task_id.nil? && match = line.match(/started backup task '([^']+)'/) task_id = match[1] @logger.info("Task ID detectado: #{task_id}. Registrando en bitácora...") registrar_task_id_bitacora(nodo, task_id) end end end success = $?.success? # 4. Finalizar y cerrar system("ruby #{candados_path} cerrar > /dev/null 2>&1") if success @logger.exito("Backup de #{nodo} completado exitosamente.") registrar_fin_bitacora(nodo, true, task_id) else @logger.error("La ejecución del backup falló.") registrar_fin_bitacora(nodo, false, task_id) exit 1 end end def obtener_contexto_nodo(nombre_nodo) ruta_nodo = File.join(ADN::NODOS_DIR, "#{nombre_nodo}.md") unless File.exist?(ruta_nodo) @logger.error("No existe el archivo de nodo: #{ruta_nodo}") @logger.sugerencia("./tools/adn/run db nodo:crear --nombre #{nombre_nodo} --tipo servidor --ip 0.0.0.0 --desc \"Servidor a configurar\"") return nil end contenido = File.read(ruta_nodo) vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1) host_name = contenido.match(/(?:Padre\/Host|Host Anfitrión|Anfitrión|Host)\*\*:?\s*`([^`]+)`/i)&.[](1) unless vmid && host_name @logger.error("Contexto incompleto en #{nombre_nodo}.md: VMID=#{vmid}, Host=#{host_name}") @logger.sugerencia([ "Asegurate de que #{nombre_nodo}.md tenga las siguientes líneas:", "- **Padre/Host**: `srv-proxmox-ejemplo`", "- **VMID**: `1xx`" ]) return nil end # Buscar IP del host ruta_host = File.join(ADN::NODOS_DIR, "#{host_name}.md") host_ip = nil if File.exist?(ruta_host) c_host = File.read(ruta_host) host_ip ||= c_host.match(/\|\s*IP[^\|]*\|\s*`([^`]+)`\s*\|/)&.[](1) host_ip ||= c_host.match(/IP\*\*:?\s*`([^`]+)`/)&.[](1) host_ip ||= c_host.match(/IP:\s*([\d\.]+)/)&.[](1) end unless host_ip @logger.error("No se encontró IP para el host #{host_name}") @logger.sugerencia([ "Verificá que el archivo #{host_name}.md exista.", "Si no existe, crealo con: ./tools/adn/run db nodo:crear --nombre #{host_name} --tipo servidor --ip DIRECCION_IP", "Asegurate de que incluya: **IP**: `DIRECCION_IP`" ]) return nil end host_ip = host_ip.split('/')[0] if host_ip.include?('/') { vmid: vmid, host_name: host_name, host_ip: host_ip } end def registrar_inicio_bitacora(nodo, datos) require_relative '../../bitacoras-db/core/bitacora_db' BitacorasDB::BitacoraDB.with_connection do |db| # Asegurar bitácora bitacora = db.find_bitacora_by_fecha(Date.today) bitacora ||= db.create_bitacora(fecha: Date.today) # Buscar nodo en DB nodo_db = db.find_nodo_by_nombre(nodo) hora = Time.now.strftime('%H:%M') entrada = db.create_entrada( inicio: hora, descripcion: "Inicio de backup remoto vía SSH para VM #{datos[:vmid]} en #{datos[:host_name]}.", estado: '⏳', modo: 'R', bitacora_id: bitacora['id'], nodo_id: nodo_db ? nodo_db['id'] : nil ) # Guardar ID para actualizar después @backup_entrada_id = entrada['id'].to_i @backup_bitacora_id = bitacora['id'].to_i end rescue => e @logger.error("Error registrando inicio en DB: #{e.message}") end def registrar_task_id_bitacora(nodo, task_id) return unless @backup_entrada_id require_relative '../../bitacoras-db/core/bitacora_db' BitacorasDB::BitacoraDB.with_connection do |db| db.update_entrada(@backup_entrada_id, { descripcion: "[task:#{task_id[0..7]}] Backup remoto en ejecución." }) end rescue => e @logger.error("Error registrando task_id en DB: #{e.message}") end def registrar_fin_bitacora(nodo, exito, task_id = nil) return unless @backup_entrada_id require_relative '../../bitacoras-db/core/bitacora_db' require_relative '../../bitacoras-db/exporters/md_exporter' hora_fin = Time.now.strftime('%H:%M') estado = exito ? '✅' : '⚠️' desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.' desc = "[task:#{task_id[0..7]}] #{desc}" if task_id BitacorasDB::BitacoraDB.with_connection do |db| db.update_entrada(@backup_entrada_id, { fin: hora_fin, descripcion: desc, estado: estado }) end rescue => e @logger.error("Error registrando fin en DB: #{e.message}") end def comando_predeterminar(args) if args.length < 2 puts "#{Color::YELLOW}Uso: adn backup predeterminar #{Color::RESET}" puts "Ej: adn backup predeterminar modo snapshot" exit 1 end param = args[0].downcase valor = args[1] param = 'modo' if param == 'mode' param = 'retencion_dias' if param == 'edad' unless ['modo', 'storage', 'compress', 'retencion_dias'].include?(param) @logger.error("Parámetro no predeterminable: #{param}") exit 1 end Configurador.establecer("backup.defaults.#{param}", valor) @logger.exito("Valor predeterminado establecido: backup.#{param} = #{valor}") puts "#{Color::GREEN}✓ Se ha predeterminado el #{param} a '#{valor}' para futuros backups.#{Color::RESET}" end def comando_estados @logger.info("Consultando estado de backups en cluster Proxmox") # Lista de hosts a consultar hosts = [ { nombre: 'srv-pmox1', ip: '10.0.10.201' }, { nombre: 'srv-dasu', ip: '10.0.10.205' } ] puts "\n#{Color::BOLD}🔍 Estado de Backups en Tiempo Real (Proxmox)#{Color::RESET}" puts "─" * 60 candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb') # Asegurar autorización general para el lote system("ruby #{candados_path} authorize > /dev/null 2>&1") all_tasks_accumulated = [] hosts.each do |h| print "📡 Conectando a #{h[:nombre]} (#{h[:ip]})... " # Intentamos obtener tareas cmd = "/usr/bin/pvesh get /cluster/tasks --output-format json" cmd_seguro = "ssh -o StrictHostKeyChecking=no root@#{h[:ip]} '#{cmd}'" # Ejecutamos vía candados run output = `ruby #{candados_path} run admindasu SSHPASS '#{cmd_seguro}' 2>&1` if output.include?('[') begin # Extraer el JSON json_str = output[output.index('[')..output.rindex(']')] tasks_host = JSON.parse(json_str) all_tasks_accumulated.concat(tasks_host) # Filtrar tareas vzdump activas de ESTE host para mostrar tasks_activas = tasks_host.select { |t| t['type'] == 'vzdump' && t['endtime'].nil? } puts "#{Color::GREEN}OK#{Color::RESET}" if tasks_activas.empty? puts " #{Color::DIM}Sin tareas de backup activas en este host.#{Color::RESET}" else tasks_activas.each do |t| msg = t['status'] || 'ejecutando' print " ▶ #{Color::CYAN}#{t['id']}#{Color::RESET} " puts "[#{msg}] (Iniciado: #{Time.at(t['starttime']).strftime('%H:%M:%S')})" end end rescue => e puts "#{Color::YELLOW}JSON ERROR#{Color::RESET}" puts " #{Color::DIM}Error parseando respuesta: #{e.message}#{Color::RESET}" end else puts "#{Color::RED}ERROR#{Color::RESET}" if output.include?('Permission denied') puts " #{Color::DIM}Error de permisos/candado puesto.#{Color::RESET}" else puts " #{Color::DIM}No se detectaron tareas activas o el host no respondió.#{Color::RESET}" end end end system("ruby #{candados_path} cerrar > /dev/null 2>&1") # 2. Sincronización Automática: Auto-reconciliación y Migración fecha_hoy = Date.today.iso8601 archivo_md = File.join(ADN::BITACORAS_DIR, "#{fecha_hoy}.md") if File.exist?(archivo_md) print "#{Color::DIM}🔄 Reconciliando tareas pendientes... #{Color::RESET}" auto_reconciliar_bitacora(all_tasks_accumulated) puts "#{Color::DIM}OK#{Color::RESET}" print "#{Color::DIM}🔄 Sincronizando bitácora digital... #{Color::RESET}" # Ejecutamos la migración de forma silenciosa cmd_migrar = "#{File.join(ADN::PROJECT_ROOT, 'tools/adn/run')} db migrar:md #{fecha_hoy} --sobrescribir > /dev/null 2>&1" system(cmd_migrar) puts "#{Color::DIM}OK#{Color::RESET}" end # 3. Historial de la DB (Sincronizado) puts "\n#{Color::BOLD}📅 Historial de hoy (Sincronizado)#{Color::RESET}" puts "─" * 60 db_tool = "./tools/adn/run" # Buscamos por "backup" o "BKP" system("#{db_tool} db evento:listar --desde '#{fecha_hoy}' --buscar 'ackup'") puts "\n#{Color::DIM}Para historial completo use: ./tools/adn/run db evento:listar --desde '#{fecha_hoy}'#{Color::RESET}" end def comando_sanear dias = @opciones[:retencion].to_i storage = @opciones[:storage] @logger.info("Iniciando saneamiento de backups (Retención: #{dias} días en '#{storage}')") # 1. Obtener lista de VMs directamente de Proxmox para nombres precisos host = { nombre: 'srv-pmox1', ip: '10.0.10.201' } candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb') system("ruby #{candados_path} authorize > /dev/null 2>&1") @logger.info("Consultando inventario de VMs en Proxmox...") cmd_vms = "/usr/bin/pvesh get /cluster/resources --type vm --output-format json" output_vms = `ruby #{candados_path} run admindasu SSHPASS 'ssh -o StrictHostKeyChecking=no root@#{host[:ip]} "#{cmd_vms}"' 2>/dev/null` vmid_map = cargar_mapa_vmid_nodos # Cargar local primero begin if output_vms.include?('[') json_vms = output_vms[output_vms.index('[')..output_vms.rindex(']')] JSON.parse(json_vms).each do |vm| id = vm['vmid'].to_s # Priorizar nombre de Proxmox si el local no existe o es genérico vmid_map[id] = vm['name'] if vm['name'] && (!vmid_map[id] || vmid_map[id] == id) end end rescue => e @logger.advertencia("No se pudo completar el mapa de VMs desde Proxmox: #{e.message}") end # 2. Obtener lista de archivos en el storage # Intentar con varios hosts del cluster por si uno está caído ruta_dump = "/mnt/pve/#{storage}/dump" @logger.info("Listando archivos en #{storage}...") output_ls = "" [ '10.0.10.201', '10.0.10.202', '10.0.10.203' ].each do |ip| cmd_ls = "ls -1 --full-time #{ruta_dump}/vzdump-*" res = `ruby #{candados_path} run admindasu SSHPASS 'ssh -o StrictHostKeyChecking=no root@#{ip} "#{cmd_ls}"' 2>&1` unless res.include?('No such file') || res.include?('Connection refused') output_ls = res break end end if output_ls.empty? || output_ls.include?('No such file') @logger.error("No se encontró la ruta de backups en ningún host: #{ruta_dump}") system("ruby #{candados_path} cerrar > /dev/null 2>&1") exit 1 end # 3. Filtrar archivos por antigüedad ahora = Time.now limite = ahora - (dias * 24 * 60 * 60) backups_agrupados = {} output_ls.split("\n").each do |linea| next if linea.include?('OPTIONS]') || linea.empty? partes = linea.split(/\s+/) next if partes.length < 9 fecha_str = "#{partes[5]} #{partes[6]}" ruta_archivo = partes[8..-1].join(' ') nombre = File.basename(ruta_archivo) fingerprint = nombre.sub(/\.(log|vma\.zst|tar\.zst|vma\.zst\.notes|tar\.zst\.notes)$/, '') begin mtime = Time.parse(fecha_str) if mtime < limite vmid = fingerprint.match(/vzdump-(?:qemu|lxc)-(\d+)-/)&.[](1) nodo_humano = vmid ? vmid_map[vmid] : "ID:#{vmid}" backups_agrupados[fingerprint] ||= { fingerprint: fingerprint, fecha: mtime, vmid: vmid, nodo: nodo_humano, archivos: [] } backups_agrupados[fingerprint][:archivos] << { ruta: ruta_archivo, nombre: nombre } end rescue next end end if backups_agrupados.empty? puts "#{Color::GREEN}✓ No se encontraron backups mayores a #{dias} días en #{storage}.#{Color::RESET}" system("ruby #{candados_path} cerrar > /dev/null 2>&1") return end # Convertir a array ordenado por fecha lista_final = backups_agrupados.values.sort_by { |b| b[:fecha] } # 4. Mostrar lista con nuevo formato solicitado puts "\n#{Color::BOLD}🗑️ Propuesta de Saneamiento de Backups#{Color::RESET} (Retención: #{dias} días)" puts "─" * 90 puts "#{Color::BOLD}ID Nombre_VMID_Fecha Componentes detectados#{Color::RESET}" puts "─" * 90 lista_final.each_with_index do |b, idx| num = (idx + 1).to_s.rjust(2, '0') fecha_corta = b[:fecha].strftime('%Y-%m-%d') # Formato solicitado: XX - VMNombre_VMid_Fecha entrada_label = "#{num} - #{Color::BOLD}#{Color::CYAN}#{b[:nodo]}_#{b[:vmid]}_#{fecha_corta}#{Color::RESET}".ljust(60) tipos = b[:archivos].map { |a| a[:nombre].split('.').last }.uniq.join(", ") total_archivos = "(#{b[:archivos].length} arch.)" puts "#{entrada_label} #{Color::DIM}#{tipos.ljust(15)} #{total_archivos}#{Color::RESET}" end puts "─" * 90 puts "Total detectado: #{lista_final.length} backups completos (#{backups_agrupados.values.sum { |b| b[:archivos].length }} archivos en total)." puts "\n#{Color::BOLD}Acciones:#{Color::RESET}" puts " • Escriba #{Color::BOLD}'S'#{Color::RESET} para borrar TODOS los backups listados." puts " • Escriba #{Color::BOLD}excluir 1, 3-5#{Color::RESET} para conservar backups específicos." puts " • Escriba #{Color::BOLD}'N'#{Color::RESET} para cancelar." print "\n👉 #{Color::BOLD}¿Qué deseas hacer?#{Color::RESET}: " input = STDIN.gets.chomp.strip.downcase if input == 'n' || input == '' puts "#{Color::YELLOW}Operación cancelada.#{Color::RESET}" system("ruby #{candados_path} cerrar > /dev/null 2>&1") exit end # Procesar exclusiones seleccionados = lista_final.dup if input.start_with?('excluir') exclusiones = [] patron = input.sub('excluir', '').strip patron.split(/[\s,]+/).each do |p| next if p.empty? if p.include?('-') inicio, fin = p.split('-').map(&:to_i) exclusiones.concat((inicio..fin).to_a) else exclusiones << p.to_i end end seleccionados = lista_final.reject.with_index { |_, idx| exclusiones.include?(idx + 1) } if seleccionados.empty? puts "#{Color::YELLOW}Has excluido todos los backups. Nada que hacer.#{Color::RESET}" system("ruby #{candados_path} cerrar > /dev/null 2>&1") exit end puts "#{Color::CYAN}ℹ Has excluido #{lista_final.length - seleccionados.length} backups. Procediendo con #{seleccionados.length}...#{Color::RESET}" elsif input != 's' puts "#{Color::RED}Opción no válida. Cancelando.#{Color::RESET}" system("ruby #{candados_path} cerrar > /dev/null 2>&1") exit end # 5. Borrado final total_a_borrar = seleccionados.sum { |b| b[:archivos].length } puts "\n#{Color::BOLD}🗑️ Borrando #{total_a_borrar} archivos asociados a #{seleccionados.length} backups...#{Color::RESET}" seleccionados.each_with_index do |b, i| puts "\n #{Color::BOLD}[#{i+1}/#{seleccionados.length}] Procesando #{b[:nodo]}...#{Color::RESET}" b[:archivos].each do |a| print " Eliminando #{a[:nombre]}... " del_cmd = "rm -f '#{a[:ruta]}'" del_seguro = "ssh -o StrictHostKeyChecking=no root@#{host[:ip]} '#{del_cmd}'" system("ruby #{candados_path} run admindasu SSHPASS '#{del_seguro}' > /dev/null 2>&1") puts "#{Color::GREEN}OK#{Color::RESET}" end end @logger.exito("Saneamiento completado. Se limpiaron #{seleccionados.length} sets de backup.") system("ruby #{candados_path} cerrar > /dev/null 2>&1") end def cargar_mapa_vmid_nodos mapa = {} Dir.glob(File.join(ADN::NODOS_DIR, "*.md")).each do |f| next if File.directory?(f) begin contenido = File.read(f) # Buscar nombre en h1 o metadatos 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 def auto_reconciliar_bitacora(all_tasks) fecha = Date.today.iso8601 ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md") return unless File.exist?(ruta) contenido = File.read(ruta) vmid_map = cargar_mapa_vmid_nodos # Encontrar todas las filas que tienen un reloj de arena lineas = contenido.lines nodo_actual = nil lineas.each do |linea| if linea.start_with?('### srvv-') nodo_actual = linea.sub('### ', '').strip next end # Si es una fila de tabla en progreso (⏳) if nodo_actual && linea.include?('|') && linea.include?('⏳') match_hora = linea.match(/\|\s*(\d{2}:\d{2})\s*\|/) next unless match_hora hora_inicio_str = match_hora[1] vmid = vmid_map.key(nodo_actual) next unless vmid # Buscar última tarea vzdump de este VMID que ya terminó hora_inicio_t = Time.parse("#{fecha} #{hora_inicio_str}") rescue nil tarea = all_tasks.select { |t| vmid_task = (t['vmid'] || t['id']).to_s t['type'] == 'vzdump' && vmid_task == vmid && !t['endtime'].nil? && (hora_inicio_t ? Time.at(t['starttime']) >= (hora_inicio_t - 3600) : true) }.max_by { |t| t['endtime'] } if tarea exito = (tarea['status'] == 'OK') hora_fin = Time.at(tarea['endtime']).strftime('%H:%M') @logger.info("Auto-cerrando backup de #{nodo_actual} (Task ID: #{tarea['id']})") cerrar_entrada_especifica(nodo_actual, hora_inicio_str, exito, tarea['id'], hora_fin) end end end end def cerrar_entrada_especifica(nodo, hora_ini, exito, task_id, hora_fin) fecha = Date.today.iso8601 ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md") return unless File.exist?(ruta) estado_ico = exito ? '✅' : '⚠️' desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.' desc = "[task:#{task_id[0..7]}] #{desc}" if task_id linea_nueva = "| #{hora_ini} | #{hora_fin} | #{desc} | R | #{estado_ico} |" contenido = File.read(ruta) # Buscamos la fila específica que coincida con el nodo Y la hora de inicio # Capturamos el bloque del nodo para evitar errores si otros nodos tienen la misma hora regex_seccion = /(### #{Regexp.escape(nodo)}.*?)(?=^###\s|