Files
dtic-DIIAA/tools/adn/cli/backup.rb
T

652 lines
25 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.
# frozen_string_literal: true
require 'optparse'
require 'fileutils'
require 'date'
require_relative '../core/configurador'
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
parser = OptionParser.new do |opts|
opts.banner = "Uso: ./tools/adn/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 ns8-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}\""
# Usamos el binario de ruby y la ruta absoluta para ns8-candados
candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
# Ejecutar con ns8-candados run
# Ejecutar con ns8-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...")
IO.popen("ruby #{candados_path} run admindasu 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("./tools/adn/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: ./tools/adn/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: '10.0.10.205' }
]
puts "\n#{Color::BOLD}🔍 Estado de Backups en Tiempo Real (Proxmox)#{Color::RESET}"
puts "─" * 60
candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-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 = "ssh -o StrictHostKeyChecking=no root@#{h[:ip]} '#{cmd}'"
# Ejecutamos vía candados run
output = `ruby #{candados_path} run admindasu 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, 'tools/adn/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 = "./tools/adn/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: ./tools/adn/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, 'tools', 'ns8-candados', 'ns8-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"
output_vms = `ruby #{candados_path} run admindasu 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 '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 = "ssh -o StrictHostKeyChecking=no root@#{host[:ip]} '#{del_cmd}'"
system("ruby #{candados_path} run admindasu 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
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)
estado_ico = exito ? '✅' : '⚠️'
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
desc = "[task:#{task_id[0..7]}] #{desc}" if task_id
linea_nueva = "| #{hora_ini} | #{hora_fin} | #{desc} | R | #{estado_ico} |"
contenido = File.read(ruta)
# Buscamos la fila específica que coincida con el nodo Y la hora de inicio
# Capturamos el bloque del nodo para evitar errores si otros nodos tienen la misma hora
regex_seccion = /(### #{Regexp.escape(nodo)}.*?)(?=^###\s|<!--|\z)/m
seccion = contenido.match(regex_seccion)&.[](0)
unless seccion
return
end
# Regex para la fila exacta que tenga la hora de inicio Y un reloj ⏳ en la última columna
regex_fila = /\|\s*#{Regexp.escape(hora_ini)}\s*\|\s*\|\s*[^|]*\|\s*[^|]*\|\s*[^|]*⏳[^|]*\|/
if seccion.match?(regex_fila)
@logger.exito("Reconciliado: #{nodo} (#{hora_ini} -> #{hora_fin})")
nueva_seccion = seccion.sub(regex_fila, linea_nueva)
# Si ya no quedan ⏳ en esta sección, actualizamos su icono #### ⏳ -> ✅
# Buscamos cualquier reloj de arena que quede en la sección
unless nueva_seccion.include?('⏳')
nueva_seccion.gsub!(/#### [⏳⏳] - BKP-ADN/, "#### #{estado_ico} - BKP-ADN")
end
nuevo_contenido = contenido.gsub(seccion, nueva_seccion)
File.write(ruta, nuevo_contenido)
end
end
end
end