Files
dtic-DIIAA/adn/tools/bkps/bkps.rb
T
Ricardo MonlaandClaude Opus 4.6 1802c25194 refactor(ambitos): Migración completa dtic-DASUTEN (A04) y dtic-BKPs (A03) + herramientas ADN
Migración dtic-DASUTEN a estándar A04:
- A04_dtic-DASUTEN.md: Manifiesto creado
- A04.P001 a A04.P005: Planes renombrados de P2601.*
- _hist/_hist_P2601_dasuten.md: Legacy archivado

Migración dtic-BKPs a estándar A03:
- A03_dtic-BKPs.md: Manifiesto creado
- A03.P001, A03.P002: Planes migrados
- docs/ambito/dtic-BKPs/: Nueva estructura

Herramientas ADN nuevas:
- adn/tools/bkps/: BKPs migrado a tools ADN
- adn/tools/cli/dron.rb: Vigía de tareas largas
- adn/tools/cli/copiloto.rb: Asistente CLI
- adn/tools/cli/novato.rb: CLI de aprendizaje

Limpieza:
- dtic-BKPs/ → _hist/dtic-BKPs/ (legacy archivado)
- adn/tools/cli/backup.rb → _hist/ (migrado a bkps.rb)

Otros:
- nodos/pcv-dasu1.md, srvv-data.md, srvv-nginx-rm.md: Actualizados
- adn/tools/core/conciliador.rb: Mejoras
- adn/tools/w-zombi/data/cmd.json: Actualizado

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:23:53 -03:00

826 lines
30 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 '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 }
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("--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"
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 admindasu SSHPASS 'sshpass -e ssh -o StrictHostKeyChecking=no root@#{h['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')
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]
todos.select { |fp, _| !protegido?(fp, por_nodo, todos) && todos[fp][:fecha] < limite }.each_value do |info|
info[:archivos].each do |ruta|
system("ruby #{candados_path} run admindasu 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
print "\n¿Borrar todos? (S/N): "
resp = STDIN.gets&.chomp&.strip&.downcase
unless resp == 's'
puts "Cancelado."
return [0, protegidos]
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)
resultado = procesador.ejecutar(tarea)
duracion = (Time.now - t_inicio).to_i
mins = duracion / 60
segs = duracion % 60
@logger.info("✔ #{tarea[:texto]}#{mins}m#{segs}s")
resultado
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