# frozen_string_literal: true # ========================================================== # adn/tools/bkps/lib/proc_proxmox.rb — Procesador Proxmox # Migrado desde dtic-BKPs v6.0 → ADN::BKPs # # Fase 2: Soporta orquestación 1-dron-por-VM # listar(tarea) → lista sets de backup pendientes # ejecutar_uno(tarea, arch) → procesa UN solo set (.log) # ejecutar(tarea) → procesa TODOS (legacy) # ========================================================== require 'tmpdir' require 'shellwords' require 'open3' require 'fileutils' module ADN module BKPs module Procesador class Proxmox def initialize(log) @log = log @pv = system('which pv > /dev/null 2>&1') end # Scanner: devuelve lista de sets de backup pendientes def listar(tarea) dir_tmp = montar(tarea[:origen], tarea[:id]) return [] unless dir_tmp begin logs = Dir.glob(File.join(dir_tmp, '*.log')) logs.map do |ruta_log| base = File.basename(ruta_log, '.log') fuentes = Dir.glob(File.join(dir_tmp, "#{base}*")) nombre_vm = extraer_nombre_vm(fuentes, ruta_log, base, tarea[:id]) nombre_tar = nombre_vm ? "#{nombre_vm}_#{File.mtime(ruta_log).strftime('%Y%m%d_%H%M%S')}.tar.gz" : nil dir_dest = nombre_vm ? File.join(tarea[:destino], nombre_vm) : nil ruta_tar = dir_dest ? File.join(dir_dest, nombre_tar) : nil tamaño = fuentes.sum { |f| File.size(f) rescue 0 } { archivo: "#{base}.log", ruta: ruta_log, vm: nombre_vm || "VM-desconocida", destino: ruta_tar, tamaño: tamaño, pendiente: ruta_tar ? (!File.exist?(ruta_tar) || tarea[:sobrescribir]) : false } end ensure desmontar(dir_tmp, tarea[:id]) end end # Atómico: procesa UN solo set de backup (.log base name) def ejecutar_uno(tarea, nombre_archivo) base = File.basename(nombre_archivo, '.log') dir_tmp = montar(tarea[:origen], tarea[:id]) return false unless dir_tmp begin ruta_log = File.join(dir_tmp, "#{base}.log") unless File.exist?(ruta_log) @log.error("Set de backup no encontrado: #{base}.log") return false end fuentes = Dir.glob(File.join(dir_tmp, "#{base}*")) nombre_vm = extraer_nombre_vm(fuentes, ruta_log, base, tarea[:id]) return false unless nombre_vm procesar_set(tarea, dir_tmp, ruta_log, base, fuentes, nombre_vm) ensure desmontar(dir_tmp, tarea[:id]) end end # Legacy: procesa TODOS los sets (compatibilidad) def ejecutar(tarea) @log.info("--- #{tarea[:texto]} ---") @log.info("#{tarea[:id]}: Origen remoto: #{tarea[:origen]}") dir_tmp = montar(tarea[:origen], tarea[:id]) return false unless dir_tmp procesados = 0 begin logs = Dir.glob(File.join(dir_tmp, '*.log')) if logs.empty? @log.info("#{tarea[:id]}: Sin archivos .log en origen.") return true end @log.info("Encontrados #{logs.length} set(s) de backup Proxmox.") logs.each do |ruta_log| base = File.basename(ruta_log, '.log') fuentes = Dir.glob(File.join(dir_tmp, "#{base}*")) nombre_vm = extraer_nombre_vm(fuentes, ruta_log, base, tarea[:id]) next unless nombre_vm procesados += 1 if procesar_set(tarea, dir_tmp, ruta_log, base, fuentes, nombre_vm) end @log.info("✔ #{procesados} sets Proxmox procesados.") true ensure desmontar(dir_tmp, tarea[:id]) end end private def procesar_set(tarea, dir_tmp, ruta_log, base, fuentes, nombre_vm) nombre_tar = "#{nombre_vm}_#{File.mtime(ruta_log).strftime('%Y%m%d_%H%M%S')}.tar.gz" dir_dest = File.join(tarea[:destino], nombre_vm) FileUtils.mkdir_p(dir_dest) ruta_tar = File.join(dir_dest, nombre_tar) if File.exist?(ruta_tar) if tarea[:sobrescribir] @log.info("#{File.basename(ruta_tar)} existe. Sobrescribiendo.") else @log.info("#{File.basename(ruta_tar)} existe. Saltando.") return true end end @log.info("Comprimiendo #{base} (VM: #{nombre_vm})") comprimir(dir_tmp, fuentes, ruta_tar) @log.info("✔ Compresión OK: #{File.basename(ruta_tar)}") if tarea[:eliminar_origen] fuentes.each { |f| FileUtils.rm_f(f) } @log.info("Fuentes eliminadas de origen.") end true rescue => e @log.error("#{tarea[:id]}: Error procesando '#{base}': #{e.message}") FileUtils.rm_f(ruta_tar) if ruta_tar && File.exist?(ruta_tar) false end def extraer_nombre_vm(fuentes, ruta_log, base, tarea_id) notes = fuentes.find { |f| f.end_with?('.notes') } nombre = notes ? File.read(notes).strip : nil if nombre.nil? || nombre.empty? File.foreach(ruta_log) do |line| if line =~ /INFO: VM Name:\s*(.+)$/ nombre = $1.strip break end end rescue nil end if nombre.nil? || nombre.empty? match = base.match(/vzdump-(?:qemu|lxc)-(\d+)-/) nombre = match ? "VM-#{match[1]}" : nil end @log.error("#{tarea_id}: Sin nombre de VM para '#{base}'. Saltando.") unless nombre nombre end def comprimir(dir_origen, fuentes, ruta_salida) nombres = fuentes.map { |f| File.basename(f) } if @pv cmd_tar = ['tar', '-cf', '-', '-C', dir_origen] + nombres cmd_pv = ['pv', '-s', fuentes.sum { |f| File.size(f) rescue 0 }.to_s] cmd_gz = ['gzip'] File.open(ruta_salida, 'wb') do |f| Open3.pipeline(cmd_tar, cmd_pv, cmd_gz, out: f).each_with_index do |s, i| raise "Falló #{%w[tar pv gzip][i]} (#{s.exitstatus})" unless s.success? end end else args = nombres.map { |f| Shellwords.escape(f) }.join(' ') cmd = "tar -czf #{Shellwords.escape(ruta_salida)} -C #{Shellwords.escape(dir_origen)} #{args}" system(cmd) or raise "tar falló (#{$?.exitstatus})" end end def montar(remoto, tarea_id) dir = Dir.mktmpdir("bkps_#{tarea_id}_") @log.info("Montando #{remoto} en #{dir}") pid = spawn("rclone mount #{Shellwords.escape(remoto)} #{dir} --daemon --vfs-cache-mode writes") Process.detach(pid) sleep 2 if Dir.exist?(dir) && !(Dir.entries(dir) - %w[. ..]).empty? dir else @log.error("Error montando #{remoto}") nil end end def desmontar(dir, tarea_id) return unless dir && Dir.exist?(dir) @log.info("Desmontando #{dir}") system("fusermount -u #{dir} 2>/dev/null || umount #{dir} 2>/dev/null") FileUtils.remove_entry(dir) rescue nil end end end end end