Arquitectura antes: core/nodos_info.rb → usado por 4 CLIs core/ejecutor_remoto.rb → usado por proceso.rb core/triggers.rb → usado por triggers.rb core/eventos.rb → usado por triggers.rb core/configurador.rb → usado por backup.rb Arquitectura después: cli/nodos/info.rb ← submódulo de nodos cli/ssh/ejecutor.rb ← submódulo de ssh cli/triggers/motor.rb ← submódulo de triggers cli/triggers/eventos.rb ← submódulo de triggers cli/backup/configurador.rb ← submódulo de backup core/ queda limpio con solo infraestructura genuina: colores, constants, error_handler, logger, help_formatter, conciliador (transversal), validador (obsoleto) - 15 tests pasan sin regresiones - Evento 1078 (17:14→17:17)
208 lines
7.2 KiB
Ruby
208 lines
7.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'date'
|
|
require 'time'
|
|
require 'json'
|
|
require_relative '../cli/backup/configurador'
|
|
|
|
module ADN
|
|
class Conciliador
|
|
def initialize(logger)
|
|
@logger = logger
|
|
@candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb')
|
|
end
|
|
|
|
def ejecutar(silencioso: false)
|
|
@silencioso = silencioso
|
|
@logger.info("Iniciando saneamiento preventivo de pendientes...") unless @silencioso
|
|
|
|
# 1. Reconciliar backups de ayer y hoy
|
|
conciliar_backups_recientes
|
|
|
|
# 2. Traspasar pendientes no resueltos de ayer a hoy
|
|
traspasar_pendientes_ayer_a_hoy
|
|
end
|
|
|
|
private
|
|
|
|
def conciliar_backups_recientes
|
|
# Revisamos ayer y hoy
|
|
[Date.today - 1, Date.today].each do |fecha_obj|
|
|
fecha = fecha_obj.iso8601
|
|
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
|
|
next unless File.exist?(ruta)
|
|
|
|
# Si el archivo tiene relojes de arena (⏳)
|
|
contenido = File.read(ruta)
|
|
if contenido.include?('⏳')
|
|
@logger.info(" Revisando tareas pendientes del #{fecha}...") unless @silencioso
|
|
procesar_reconciliacion(fecha, ruta)
|
|
end
|
|
end
|
|
end
|
|
|
|
def procesar_reconciliacion(fecha, ruta)
|
|
# Obtener tareas de Proxmox
|
|
tasks = obtener_tareas_proxmox
|
|
return if tasks.empty?
|
|
|
|
vmid_map = cargar_mapa_vmid_nodos
|
|
contenido = File.read(ruta)
|
|
lineas = contenido.lines
|
|
nodo_actual = nil
|
|
cambio = false
|
|
|
|
lineas.each do |linea|
|
|
if linea.start_with?('### ') && !linea.include?('|')
|
|
nodo_actual = linea.sub('### ', '').strip
|
|
next
|
|
end
|
|
|
|
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 tarea coincidente
|
|
hora_inicio_t = Time.parse("#{fecha} #{hora_inicio_str}") rescue nil
|
|
tarea = tasks.select { |t|
|
|
vmid_task = (t['vmid'] || t['id']).to_s
|
|
t['type'] == 'vzdump' &&
|
|
vmid_task == vmid &&
|
|
(t['status'] == 'OK' || !t['endtime'].nil?) &&
|
|
(hora_inicio_t ? Time.at(t['starttime']) >= (hora_inicio_t - 3600) : true)
|
|
}.max_by { |t| t['endtime'] || 0 }
|
|
|
|
if tarea && tarea['endtime']
|
|
exito = (tarea['status'] == 'OK')
|
|
hora_fin = Time.at(tarea['endtime']).strftime('%H:%M')
|
|
task_id = tarea['id']
|
|
|
|
@logger.exito(" ✨ Tarea auto-resuelta: #{nodo_actual} (#{hora_inicio_str} -> #{hora_fin})") unless @silencioso
|
|
|
|
# Aplicar cambio en el contenido
|
|
estado_ico = exito ? '✅' : '⚠️'
|
|
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
|
|
desc = "[task:#{task_id[0..7]}] #{desc}"
|
|
|
|
linea_nueva = "| #{hora_inicio_str} | #{hora_fin} | #{desc} | R | #{estado_ico} |\n"
|
|
|
|
# Reemplazo cuidadoso
|
|
regex_fila = /^\|\s*#{Regexp.escape(hora_inicio_str)}\s*\|\s*\|\s*[^|]*\|\s*[^|]*\|\s*[^|]*⏳[^|]*\|/
|
|
|
|
# Buscar el bloque del nodo
|
|
regex_seccion = /(### #{Regexp.escape(nodo_actual)}.*?)(?=\n###\s|<!--|\z)/m
|
|
contenido.sub!(regex_seccion) do |seccion|
|
|
nueva_seccion = seccion.sub(regex_fila, linea_nueva)
|
|
# Si ya no quedan ⏳ en esta sección, actualizamos su icono principal
|
|
unless nueva_seccion.include?('⏳')
|
|
nueva_seccion.gsub!(/#### [⏳⏳] - BKP-ADN/, "#### #{estado_ico} - BKP-ADN")
|
|
end
|
|
nueva_seccion
|
|
end
|
|
cambio = true
|
|
end
|
|
end
|
|
end
|
|
|
|
if cambio
|
|
File.write(ruta, contenido)
|
|
# Sincronizar con DB
|
|
system("#{File.join(ADN::PROJECT_ROOT, 'adn/tools/run')} db migrar:md #{fecha} --sobrescribir > /dev/null 2>&1")
|
|
end
|
|
end
|
|
|
|
def traspasar_pendientes_ayer_a_hoy
|
|
ayer = (Date.today - 1).iso8601
|
|
hoy = Date.today.iso8601
|
|
ruta_ayer = File.join(ADN::BITACORAS_DIR, "#{ayer}.md")
|
|
ruta_hoy = File.join(ADN::BITACORAS_DIR, "#{hoy}.md")
|
|
|
|
return unless File.exist?(ruta_ayer) && File.exist?(ruta_hoy)
|
|
|
|
cont_ayer = File.read(ruta_ayer)
|
|
cont_hoy = File.read(ruta_hoy)
|
|
|
|
# 1. Extraer sección Pendientes de ayer
|
|
pendientes_ayer = extraer_tabla_seccion(cont_ayer, '### Pendientes')
|
|
return if pendientes_ayer.empty?
|
|
|
|
# 2. Filtrar los que ya están en hoy (evitar duplicados)
|
|
nuevos_pendientes = pendientes_ayer.reject do |p|
|
|
cont_hoy.include?(p[:detalle])
|
|
end
|
|
|
|
return if nuevos_pendientes.empty?
|
|
|
|
# 3. Insertar en hoy
|
|
filas_nuevas = nuevos_pendientes.map { |p| "| #{p[:id]} | #{p[:nodo]} | #{p[:detalle]} |" }.join("\n")
|
|
|
|
# Regex más flexible para encontrar la tabla de pendientes en hoy
|
|
regex_tabla = /(### Pendientes\s*\n\s*\| ID \| Nodo \| Detalle \|\s*\n\s*\| :--- \| :--- \| :--- \|)/
|
|
|
|
if cont_hoy.match?(regex_tabla)
|
|
@logger.exito(" 🚀 Traspasando #{nuevos_pendientes.length} pendientes de ayer a hoy...")
|
|
cont_hoy.sub!(regex_tabla) { "#{$1}\n#{filas_nuevas}" }
|
|
File.write(ruta_hoy, cont_hoy)
|
|
system("#{File.join(ADN::PROJECT_ROOT, 'adn/tools/run')} db migrar:md #{hoy} --sobrescribir > /dev/null 2>&1")
|
|
end
|
|
end
|
|
|
|
def extraer_tabla_seccion(contenido, encabezado)
|
|
# Busca el bloque que empieza con el encabezado y captura todas las líneas que empiezan con |
|
|
regex = /#{Regexp.escape(encabezado)}[^\n]*\n+((?:\|[^\n]*\n?)+)/m
|
|
match = contenido.match(regex)
|
|
return [] unless match
|
|
|
|
filas = match[1].strip.split("\n")
|
|
return [] if filas.length < 3 # Necesitamos al menos Cabecera, Separador y 1 Fila
|
|
|
|
# Saltamos las primeras dos líneas (Cabecera y Separador)
|
|
filas[2..-1].map do |f|
|
|
parts = f.split('|').map(&:strip).reject(&:empty?)
|
|
next if parts.length < 3
|
|
{ id: parts[0], nodo: parts[1], detalle: parts[2] }
|
|
end.compact
|
|
end
|
|
|
|
def obtener_tareas_proxmox
|
|
return @tasks if @tasks
|
|
|
|
system("ruby #{@candados_path} authorize > /dev/null 2>&1")
|
|
@tasks = []
|
|
|
|
[ '10.0.10.201', '10.0.10.202', '10.0.10.203' ].each do |ip|
|
|
cmd = "pvesh get /cluster/tasks --output-format json"
|
|
output = `ruby #{@candados_path} run admindasu SSHPASS 'ssh -o StrictHostKeyChecking=no root@#{ip} "#{cmd}"' 2>/dev/null`
|
|
|
|
if output.include?('[')
|
|
begin
|
|
json_str = output[output.index('[')..output.rindex(']')]
|
|
@tasks = JSON.parse(json_str)
|
|
break
|
|
rescue
|
|
next
|
|
end
|
|
end
|
|
end
|
|
|
|
system("ruby #{@candados_path} cerrar > /dev/null 2>&1")
|
|
@tasks
|
|
end
|
|
|
|
def cargar_mapa_vmid_nodos
|
|
mapa = {}
|
|
Dir.glob(File.join(ADN::NODOS_DIR, "*.md")).each do |f|
|
|
nombre = File.basename(f, ".md")
|
|
cont = File.read(f)
|
|
vmid = cont.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1)
|
|
mapa[vmid.to_s] = nombre if vmid
|
|
end
|
|
mapa
|
|
end
|
|
end
|
|
end
|