- sync_rclone.rb: listar() + ejecutar_uno() implementados - Soporta origen local (Dir.children) y remoto (rclone lsf) - Orquestador: nota dinámica (Sync vs Comprimir según tipo) - Validado dry-run: T4 (2 dirs), T5 (17 dirs), T6 (2 dirs) - Legacy ejecutar() preservado para compatibilidad
978 lines
36 KiB
Ruby
978 lines
36 KiB
Ruby
#!/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 <subcomando>
|
||
# ==========================================================
|
||
|
||
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 <nodo>"
|
||
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 <T1|C1|ID> [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 <nodo>"
|
||
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 <nodo> [--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 <T1|ID> [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 <T1|C1|ID> 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 <nodo> 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
|