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>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
018e45e046
commit
1802c25194
@@ -0,0 +1,658 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
require_relative 'backup/configurador'
|
||||
require_relative '../core/colores'
|
||||
require_relative '../core/help_formatter'
|
||||
|
||||
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
|
||||
# Handle help flags
|
||||
if @args.empty? || @args[0] == 'help' || @args[0] == '-h' || @args[0] == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run backup <nodo|estados> [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Ejecuta un backup remoto o consulta el estado actual."
|
||||
opts.separator ""
|
||||
opts.separator "Acciones:"
|
||||
opts.separator " <nodo> 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 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}\""
|
||||
|
||||
# Ruta absoluta a candados
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb')
|
||||
|
||||
# Ejecutar con 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...")
|
||||
|
||||
clave = (datos[:host_name] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
IO.popen("ruby #{candados_path} run #{clave} 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("./adn/tools/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: ./adn/tools/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 '../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 '../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 '../db/core/bitacora_db'
|
||||
require_relative '../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 <parametro> <valor>#{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: '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')
|
||||
# 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 = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{h[:ip]} '#{cmd}'"
|
||||
|
||||
# Ejecutamos vía candados run usando la clave adecuada
|
||||
clave = (h[:nombre] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
output = `ruby #{candados_path} run #{clave} 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, 'adn/tools/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 = "./adn/tools/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: ./adn/tools/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, 'adn', 'tools', 'candados', '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"
|
||||
clave = (host[:nombre] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
output_vms = `ruby #{candados_path} run #{clave} 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 'sshpass -e 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 = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{host[:ip]} '#{del_cmd}'"
|
||||
clave = (b[:nodo] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
system("ruby #{candados_path} run #{clave} 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
|
||||
|
||||
private
|
||||
|
||||
def mostrar_ayuda
|
||||
ayuda = ADN::HelpFormatter.generar_help(
|
||||
titulo: "Backup ADN",
|
||||
descripcion: "Ejecuta un backup remoto o consulta el estado actual.",
|
||||
uso: "./adn/tools/run backup <nodo|estados> [OPCIONES]",
|
||||
opciones: [
|
||||
{ names: ['<nodo>'], desc: "Ejecutar backup del nodo especificado" },
|
||||
{ names: ['estados'], desc: "Ver estado actual de los backups en Proxmox" },
|
||||
{ names: ['sanear'], desc: "Limpiar backups antiguos según retención" },
|
||||
{ names: ['predeterminar'], desc: "Configurar valores por defecto" },
|
||||
{ names: ['--mode MODO'], desc: "Modo de backup (stop, snapshot, suspend)" },
|
||||
{ names: ['--storage STORAGE'], desc: "Nombre del storage en Proxmox" },
|
||||
{ names: ['-h', '--help'], desc: "Mostrar esta ayuda" }
|
||||
],
|
||||
ejemplos: [
|
||||
{ cmd: "./adn/tools/run backup srv-dasu", desc: "Ejecutar backup del nodo srv-dasu" },
|
||||
{ cmd: "./adn/tools/run backup estados", desc: "Ver estado de backups en Proxmox" },
|
||||
{ cmd: "./adn/tools/run backup sanear", desc: "Limpiar backups antiguos" }
|
||||
]
|
||||
)
|
||||
|
||||
puts ayuda
|
||||
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)
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,654 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
require_relative 'backup/configurador'
|
||||
require_relative '../core/colores'
|
||||
require_relative '../core/help_formatter'
|
||||
|
||||
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
|
||||
# Handle help flags
|
||||
if @args.empty? || @args[0] == 'help' || @args[0] == '-h' || @args[0] == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run backup <nodo|estados> [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Ejecuta un backup remoto o consulta el estado actual."
|
||||
opts.separator ""
|
||||
opts.separator "Acciones:"
|
||||
opts.separator " <nodo> 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 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}\""
|
||||
|
||||
# Ruta absoluta a candados
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb')
|
||||
|
||||
# Ejecutar con 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...")
|
||||
|
||||
clave = (datos[:host_name] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
IO.popen("ruby #{candados_path} run #{clave} 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("./adn/tools/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: ./adn/tools/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 '../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 '../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 '../db/core/bitacora_db'
|
||||
require_relative '../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 <parametro> <valor>#{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: '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', 'seguridad', '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 = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{h[:ip]} '#{cmd}'"
|
||||
|
||||
# Ejecutamos vía candados run usando la clave adecuada
|
||||
clave = (h[:nombre] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
output = `ruby #{candados_path} run #{clave} 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, 'adn/tools/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 = "./adn/tools/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: ./adn/tools/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, 'adn', 'tools', 'seguridad', '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"
|
||||
clave = (host[:nombre] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
output_vms = `ruby #{candados_path} run #{clave} 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 'sshpass -e 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 = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{host[:ip]} '#{del_cmd}'"
|
||||
clave = (b[:nodo] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
system("ruby #{candados_path} run #{clave} 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
|
||||
|
||||
private
|
||||
|
||||
def mostrar_ayuda
|
||||
ayuda = ADN::HelpFormatter.generar_help(
|
||||
titulo: "Backup ADN",
|
||||
descripcion: "Ejecuta un backup remoto o consulta el estado actual.",
|
||||
uso: "./adn/tools/run backup <nodo|estados> [OPCIONES]",
|
||||
opciones: [
|
||||
{ names: ['<nodo>'], desc: "Ejecutar backup del nodo especificado" },
|
||||
{ names: ['estados'], desc: "Ver estado actual de los backups en Proxmox" },
|
||||
{ names: ['sanear'], desc: "Limpiar backups antiguos según retención" },
|
||||
{ names: ['predeterminar'], desc: "Configurar valores por defecto" },
|
||||
{ names: ['--mode MODO'], desc: "Modo de backup (stop, snapshot, suspend)" },
|
||||
{ names: ['--storage STORAGE'], desc: "Nombre del storage en Proxmox" },
|
||||
{ names: ['-h', '--help'], desc: "Mostrar esta ayuda" }
|
||||
],
|
||||
ejemplos: [
|
||||
{ cmd: "./adn/tools/run backup srv-dasu", desc: "Ejecutar backup del nodo srv-dasu" },
|
||||
{ cmd: "./adn/tools/run backup estados", desc: "Ver estado de backups en Proxmox" },
|
||||
{ cmd: "./adn/tools/run backup sanear", desc: "Limpiar backups antiguos" }
|
||||
]
|
||||
)
|
||||
|
||||
puts ayuda
|
||||
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)
|
||||
@@ -0,0 +1,50 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'yaml'
|
||||
require 'fileutils'
|
||||
|
||||
module ADN
|
||||
class Configurador
|
||||
CONFIG_PATH = File.join(__dir__, '..', 'config', 'config.yml')
|
||||
|
||||
def self.leer
|
||||
return {} unless File.exist?(CONFIG_PATH)
|
||||
YAML.load_file(CONFIG_PATH) || {}
|
||||
rescue StandardError => e
|
||||
warn "Error leyendo configuración: #{e.message}"
|
||||
{}
|
||||
end
|
||||
|
||||
def self.guardar(config)
|
||||
FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
|
||||
File.write(CONFIG_PATH, config.to_yaml)
|
||||
true
|
||||
rescue StandardError => e
|
||||
warn "Error guardando configuración: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
def self.obtener(ruta_punto, default = nil)
|
||||
config = leer
|
||||
partes = ruta_punto.split('.')
|
||||
valor = config
|
||||
partes.each do |p|
|
||||
valor = valor[p] if valor.is_a?(Hash)
|
||||
return default if valor.nil?
|
||||
end
|
||||
valor
|
||||
end
|
||||
|
||||
def self.establecer(ruta_punto, nuevo_valor)
|
||||
config = leer
|
||||
partes = ruta_punto.split('.')
|
||||
temp = config
|
||||
partes[0...-1].each do |p|
|
||||
temp[p] ||= {}
|
||||
temp = temp[p]
|
||||
end
|
||||
temp[partes.last] = nuevo_valor
|
||||
guardar(config)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user