Files
dtic-DIIAA/adn/tools/bkps/lib/proc_proxmox.rb
T
Ricardo Monla 1053f4de98 🐝 Fase 2: Orquestación Proxmox 1-dron-por-VM
- proc_proxmox.rb: listar() + ejecutar_uno() atómico
- Misma interfaz que proc_xen.rb para orquestador genérico
2026-04-09 12:19:30 -03:00

216 lines
7.4 KiB
Ruby

# 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