Feat(ADN): Implementado y ejecutado archivar:md para mover Markdowns obsoletos a histórico DB-First

This commit is contained in:
Ricardo Monla
2026-03-10 16:02:03 -03:00
parent 8aa1532396
commit 8d27a8958e
74 changed files with 3195 additions and 2073 deletions
+477 -65
View File
@@ -16,15 +16,22 @@ module ADN
@opciones = {
modo: defaults['modo'] || 'stop',
storage: defaults['storage'] || 'local',
compress: defaults['compress'] || 'gzip'
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> [OPCIONES]"
opts.banner = "Uso: ./tools/adn/run backup <nodo|estados> [OPCIONES]"
opts.separator ""
opts.separator "Ejecuta un backup remoto siguiendo el protocolo ADN"
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|
@@ -48,18 +55,21 @@ module ADN
end
if @args.empty?
@logger.error("Se requiere nombre de nodo")
@logger.error("Se requiere nombre de nodo o acción")
puts parser
exit 1
end
if @args[0] == 'predeterminar'
case @args[0]
when 'predeterminar'
comando_predeterminar(@args[1..-1])
return
when 'estados', 'estado'
comando_estados
when 'sanear'
comando_sanear
else
ejecutar_backup(@args[0])
end
nodo = @args[0]
ejecutar_backup(nodo)
end
private
@@ -89,21 +99,41 @@ module ADN
candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
# Ejecutar con ns8-candados run
# Nota: el comando 'authorize' debe haberse ejecutado antes o lo incluimos
puts "#{Color::DIM}🔓 Abriendo candado para sesión de backup...#{Color::RESET}"
# Ejecutar con ns8-candados autorizando primero
system("ruby #{candados_path} authorize > /dev/null 2>&1")
success = system("ruby #{candados_path} run admindasu SSHPASS '#{cmd_seguro}'")
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)
registrar_fin_bitacora(nodo, true, task_id)
else
@logger.error("La ejecución del backup falló.")
registrar_fin_bitacora(nodo, false)
registrar_fin_bitacora(nodo, false, task_id)
exit 1
end
end
@@ -116,10 +146,7 @@ module ADN
end
contenido = File.read(ruta_nodo)
# Buscamos VMID (puede decir "VM ID: 100" o "vmid: 100" o "- **VMID**: `100`")
vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1)
# Buscamos el host (especificamente el link o nombre entre backticks)
host_name = contenido.match(/(?:Padre\/Host|Host Anfitrión|Anfitrión|Host)\*\*:?\s*`([^`]+)`/i)&.[](1)
unless vmid && host_name
@@ -132,11 +159,8 @@ module ADN
host_ip = nil
if File.exist?(ruta_host)
c_host = File.read(ruta_host)
# IP en tabla: | IP | `10.0.10.1` |
host_ip ||= c_host.match(/\|\s*IP[^\|]*\|\s*`([^`]+)`\s*\|/)&.[](1)
# IP en lista: - **IP**: `10.0.10.1`
host_ip ||= c_host.match(/IP\*\*:?\s*`([^`]+)`/)&.[](1)
# IP simple: IP: 10.0.10.1
host_ip ||= c_host.match(/IP:\s*([\d\.]+)/)&.[](1)
end
@@ -145,63 +169,76 @@ module ADN
return nil
end
if host_ip && host_ip.include?('/')
host_ip = host_ip.split('/')[0]
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)
fecha = Date.today.iso8601
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
return unless File.exist?(ruta)
require_relative '../../bitacoras-db/core/bitacora_db'
hora = Time.now.strftime('%H:%M')
# Si ya existe la sección del nodo, añadimos la línea. Si no, creamos la sección.
contenido = File.read(ruta)
entrada = <<~ENTRADA
BitacorasDB::BitacoraDB.with_connection do |db|
# Asegurar bitácora
bitacora = db.find_bitacora_by_fecha(Date.today)
bitacora ||= db.create_bitacora(fecha: Date.today)
### #{nodo}
# Buscar nodo en DB
nodo_db = db.find_nodo_by_nombre(nodo)
#### ⏳ - BKP-ADN - Backup Autónomo (Protocolo ADN)
Ejecución de backup gestionada por `tools/adn.rb` para la VM #{datos[:vmid]} en el host #{datos[:host_name]}.
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
)
| I | F | Descripción | E |
| :--- | :--- | :--- | :--- |
| #{hora} | | Inicio de backup remoto vía SSH (Seguro). | [R] ⏳ |
ENTRADA
unless contenido.include?("### #{nodo}")
# Insertar antes del footer de la IA
nuevo_contenido = contenido.sub(/<!-- 🤖 PREMISAS/, "#{entrada}\n\n<!-- 🤖 PREMISAS")
File.write(ruta, nuevo_contenido)
# 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_fin_bitacora(nodo, exito)
fecha = Date.today.iso8601
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
return unless File.exist?(ruta)
def registrar_task_id_bitacora(nodo, task_id)
return unless @backup_entrada_id
hora = Time.now.strftime('%H:%M')
require_relative '../../bitacoras-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 '../../bitacoras-db/core/bitacora_db'
require_relative '../../bitacoras-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.'
contenido = File.read(ruta)
# Actualizar la línea de tiempo
linea_nueva = "| #{hora} (Fin) | | #{desc} | [R] #{estado} |"
# Buscamos la tarea BKP-ADN
nuevo_contenido = contenido.gsub(/#### ⏳ - BKP-ADN/, "#### #{estado} - BKP-ADN")
nuevo_contenido = nuevo_contenido.gsub(/\| \d{2}:\d{2} \| \| Inicio de backup remoto.*/, linea_nueva)
File.write(ruta, nuevo_contenido)
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}"
@@ -210,12 +247,11 @@ module ADN
end
param = args[0].downcase
valor = args[1].downcase
# Normalizar parámetro (ej: 'modo' -> 'modo', 'storage' -> 'storage')
valor = args[1]
param = 'modo' if param == 'mode'
param = 'retencion_dias' if param == 'edad'
unless ['modo', 'storage', 'compress'].include?(param)
unless ['modo', 'storage', 'compress', 'retencion_dias'].include?(param)
@logger.error("Parámetro no predeterminable: #{param}")
exit 1
end
@@ -224,5 +260,381 @@ module ADN
@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
+57
View File
@@ -86,6 +86,8 @@ module ADN
comando_estadisticas
when 'backup'
comando_backup(sub_args)
when 'archivar:md'
comando_archivar_md
else
@logger.error("Subcomando desconocido: #{subcomando}")
@@ -1007,6 +1009,60 @@ module ADN
# Comando: archivar:md - Mover bitácoras y proyectos MD importados/obsoletos a carpetas _hist
def comando_archivar_md
require 'fileutils'
puts "#{Color::CYAN}🧹 Iniciando limpieza y archivado de archivos Markdown...#{Color::RESET}"
BitacorasDB::BitacoraDB.with_connection do |db|
bitacoras_dir = defined?(ADN::BITACORAS_DIR) ? ADN::BITACORAS_DIR : File.expand_path('../../../docs/bitacoras', __dir__)
proyectos_dir = File.expand_path('../../../docs/proyectos', __dir__)
hist_bitacoras = File.join(bitacoras_dir, '_hist')
hist_proyectos = File.join(proyectos_dir, '_hist')
FileUtils.mkdir_p(hist_bitacoras)
FileUtils.mkdir_p(hist_proyectos)
# 1. Archivar Bitácoras Diarias
puts "\\n#{Color::BOLD}>> Verificando Bitácoras Diarias...#{Color::RESET}"
bitacoras_db = db.send(:execute, "SELECT fecha FROM bitacoras.bitacoras").map { |row| row['fecha'] }
Dir.glob(File.join(bitacoras_dir, '*.md')).each do |md_path|
next if File.basename(md_path) == 'README.md'
fecha_md = File.basename(md_path, '.md')
if bitacoras_db.include?(fecha_md)
dest = File.join(hist_bitacoras, File.basename(md_path))
FileUtils.mv(md_path, dest)
puts " #{Color::GREEN}✓ Archivada:#{Color::RESET} #{fecha_md}.md"
else
puts " #{Color::YELLOW}⚠ No en DB (se omite):#{Color::RESET} #{fecha_md}.md"
end
end
# 2. Archivar Proyectos
puts "\\n#{Color::BOLD}>> Verificando Proyectos MD...#{Color::RESET}"
proyectos_db = db.send(:execute, "SELECT codigo FROM bitacoras.proyectos").map { |row| row['codigo'] }
Dir.glob(File.join(proyectos_dir, '*.md')).each do |md_path|
next if File.basename(md_path) == 'README.md'
# Extraer el código PXXXX del principio del archivo, asumiendo formato PXXXX_Titulo.md o PXXXX.md
codigo_match = File.basename(md_path).match(/^(P\d+)/)
if codigo_match && proyectos_db.include?(codigo_match[1])
dest = File.join(hist_proyectos, File.basename(md_path))
FileUtils.mv(md_path, dest)
puts " #{Color::GREEN}✓ Archivado Proyecto:#{Color::RESET} #{File.basename(md_path)}"
else
puts " #{Color::YELLOW}⚠ Proyecto no registrado en DB o formato no válido:#{Color::RESET} #{File.basename(md_path)}"
end
end
puts "\\n#{Color::GREEN}✅ Limpieza Markdown finalizada. Entorno 100% DB-First fortalecido.#{Color::RESET}"
end
end
# ─── MÉTODOS AUXILIARES ──────────────────────────────────────────────
# Carga la configuración desde config.yml
@@ -1037,6 +1093,7 @@ module ADN
puts " #{Color::CYAN}nodo:crear [OPCIONES]#{Color::RESET} Crear nuevo nodo (servidor/VM/PC)"
puts " #{Color::CYAN}nodo:listar [OPCIONES]#{Color::RESET} Listar nodos del sistema"
puts " #{Color::CYAN}estadisticas#{Color::RESET} Mostrar estadísticas del sistema"
puts " #{Color::CYAN}archivar:md#{Color::RESET} Mueve los archivos .md migrados al archivo histórico"
puts ""
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
puts " ./tools/adn/run db salud:bd"
+2
View File
@@ -63,6 +63,8 @@ backup:
comprimir: true
defaults:
modo: stop
storage: zfsDISCO1
retencion_dias: '6'
evolucion:
propuestas_directorio: "../../docs/plan"
periodo_revision_horas: 48
+207
View File
@@ -0,0 +1,207 @@
# frozen_string_literal: true
require 'date'
require 'time'
require 'json'
require_relative 'configurador'
module ADN
class Conciliador
def initialize(logger)
@logger = logger
@candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-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, 'tools/adn/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, 'tools/adn/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
+2 -2
View File
@@ -116,8 +116,8 @@ ejecutar_validacion() {
local output
if output=$("${ADN_CLI}" validador --fix 2>&1); then
# El validador exitó con éxito
if echo "$output" | grep -q "Con errores: 0"; then
if echo "$output" | grep -q "Con advertencias: 0"; then
if echo "$output" | grep -qE "Con errores:.*0"; then
if echo "$output" | grep -qE "Con advertencias:.*0"; then
log_exito "Validación del ADN completada sin errores ni advertencias."
else
log_advertencia "Validación del ADN completada con advertencias (revisa arriba)."
+37 -1
View File
@@ -53,6 +53,9 @@ require_relative 'comando/backup'
require_relative 'comando/sync'
require_relative 'comando/triggers'
require_relative 'comando/conocimiento'
require_relative 'comando/cierre'
require_relative 'comando/inicio'
require_relative 'core/conciliador'
# ─── Colores para terminal ───────────────────────────────────────────────
module Color
@@ -707,6 +710,8 @@ class SubcomandoAyuda
#{Color::YELLOW}generar proyecto <código>#{Color::RESET} Generar manifiesto de proyecto
#{Color::YELLOW}backup <nodo> [--mode MODO]#{Color::RESET} Ejecutar backup seguro de un nodo
#{Color::YELLOW}sync [ruta|fecha]#{Color::RESET} Sincronizar con API dtic-BITACORAs
#{Color::YELLOW}inicio de jornada [presencial|remoto]#{Color::RESET} Inicio de jornada laboral
#{Color::YELLOW}cierre de jornada [presencial|remoto]#{Color::RESET} Cierre de jornada laboral
#{Color::YELLOW}triggers [opciones]#{Color::RESET} Gestión del motor de triggers
#{Color::YELLOW}ayuda [subcomando]#{Color::RESET} Mostrar esta ayuda o ayuda específica
@@ -794,6 +799,10 @@ class ADNCLI
end
subcomando = ARGV[0]
# Saneamiento preventivo de pendientes (con cooldown de 5 minutos para performance)
ejecutar_saneamiento_preventivo(subcomando)
args = ARGV[1..-1] || []
case subcomando
@@ -809,12 +818,16 @@ class ADNCLI
ADN::SubcomandoConocimiento.new(args, @logger).ejecutar
when 'db'
ADN::SubcomandoDB.new(args, @logger).ejecutar
when 'backup'
when 'backup', 'backups'
ADN::SubcomandoBackup.new(args, @logger).ejecutar
when 'sync'
ADN::SubcomandoSync.new(args, @logger).ejecutar
when 'triggers'
ADN::SubcomandoTriggers.new(args, @logger).ejecutar
when 'cierre'
ADN::SubcomandoCierre.new(args, @logger).ejecutar
when 'inicio'
ADN::SubcomandoInicio.new(args, @logger).ejecutar
when 'ayuda', 'help', '-h', '--help'
SubcomandoAyuda.new(args, @logger).ejecutar
when 'evolucion'
@@ -842,6 +855,29 @@ class ADNCLI
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
exit 1
end
private
def ejecutar_saneamiento_preventivo(subcomando)
# No sanear si es ayuda o db (para evitar bucles o lentitud en info rápida)
return if ['ayuda', 'help', 'db', 'salud', '-h', '--help'].include?(subcomando)
archivo_cooldown = "/tmp/adn_sanity_cooldown"
cooldown_segundo = 300 # 5 minutos
if File.exist?(archivo_cooldown)
last_run = File.mtime(archivo_cooldown)
return if (Time.now - last_run) < cooldown_segundo
end
# Ejecutar conciliación
begin
# Tocamos el archivo primero para evitar que otras instancias concurrentes lo lancen
FileUtils.touch(archivo_cooldown)
ADN::Conciliador.new(@logger).ejecutar(silencioso: true)
rescue => e
@logger.advertencia("No se pudo completar el saneamiento preventivo: #{e.message}")
end
end
end
# ─── Punto de entrada ───────────────────────────────────────────────────
+72
View File
@@ -0,0 +1,72 @@
# frozen_string_literal: true
# tools/bitacoras-db/cli/exportar_todo.rb
# ==========================================
# Exporta TODAS las bitácoras de la DB a archivos .md
# Uso: ruby tools/bitacoras-db/cli/exportar_todo.rb [--dir DIRECTORIO]
# — o vía enjambre —
# ./tools/adn/run db exportar:md --todo
require 'date'
require 'optparse'
require_relative '../core/bitacora_db'
require_relative '../exporters/md_exporter'
module BitacorasDB
module CLI
class ExportarTodo
def initialize(bitacoras_dir, logger = nil)
@bitacoras_dir = bitacoras_dir
@logger = logger || default_logger
@resultados = { ok: 0, error: 0 }
end
def ejecutar
@logger.info("Exportando todas las bitácoras de DB a MD...")
BitacorasDB::BitacoraDB.with_connection do |db|
bitacoras = db.list_bitacoras(limit: 500)
if bitacoras.empty?
@logger.info("No hay bitácoras en la base de datos.")
return
end
exporter = BitacorasDB::Exporters::MDExporter.new(db, @logger)
bitacoras.each do |b|
fecha = b['fecha'].to_s
fecha = fecha.split('T').first if fecha.include?('T')
archivo = File.join(@bitacoras_dir, "#{fecha}.md")
if exporter.exportar_bitacora(fecha, archivo)
@resultados[:ok] += 1
else
@resultados[:error] += 1
end
end
end
@logger.info("📊 Exportación masiva completada:")
@logger.info(" ✅ Exitosas: #{@resultados[:ok]}")
@logger.info(" ❌ Errores: #{@resultados[:error]}") if @resultados[:error] > 0
end
private
def default_logger
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
def exito(msg); puts "[OK] #{msg}"; end
end.new
end
end
end
end
# Ejecución directa
if __FILE__ == $0
bitacoras_dir = File.expand_path('../../../docs/bitacoras', __dir__)
BitacorasDB::CLI::ExportarTodo.new(bitacoras_dir).ejecutar
end
@@ -0,0 +1,121 @@
# frozen_string_literal: true
# tools/bitacoras-db/cli/verificar_migracion.rb
# ================================================
# Verifica que todas las bitácoras MD existentes estén correctamente
# migradas en la base de datos. Reporta faltantes y discrepancias.
#
# Uso:
# ruby tools/bitacoras-db/cli/verificar_migracion.rb
# — o vía enjambre —
# ./tools/adn/run db verificar
require 'date'
require_relative '../core/bitacora_db'
module BitacorasDB
module CLI
class VerificarMigracion
COLORES = {
reset: "\e[0m", bold: "\e[1m", red: "\e[31m",
green: "\e[32m", yellow: "\e[33m", cyan: "\e[36m", dim: "\e[2m"
}.freeze
def initialize(bitacoras_dir)
@bitacoras_dir = bitacoras_dir
@resultados = { ok: [], sin_entradas: [], no_migrada: [], sin_md: [] }
end
def ejecutar
puts "#{COLORES[:cyan]}🔍 Verificación de migración MD ↔ DB#{COLORES[:reset]}\n\n"
archivos_md = Dir.glob(File.join(@bitacoras_dir, '*.md')).sort
puts "#{COLORES[:dim]}Archivos MD encontrados: #{archivos_md.size}#{COLORES[:reset]}"
BitacorasDB::BitacoraDB.with_connection do |db|
# 1. Validar cada MD contra la DB
archivos_md.each do |archivo|
fecha = File.basename(archivo, '.md')
bitacora = db.find_bitacora_by_fecha(fecha)
unless bitacora
@resultados[:no_migrada] << fecha
next
end
entradas = db.execute(
"SELECT COUNT(*) as total FROM bitacoras.entradas WHERE bitacora_id = $1",
[bitacora['id']]
)
total = entradas.first['total'].to_i
if total > 0
@resultados[:ok] << { fecha: fecha, entradas: total }
else
@resultados[:sin_entradas] << fecha
end
end
# 2. Bitácoras en DB que no tienen MD
todas_db = db.list_bitacoras(limit: 500)
fechas_md = archivos_md.map { |a| File.basename(a, '.md') }
todas_db.each do |b|
f = b['fecha'].to_s
f = f.split('T').first if f.include?('T') # por si viene con timestamp
@resultados[:sin_md] << f unless fechas_md.include?(f)
end
end
mostrar_resultados
end
private
def mostrar_resultados
puts "\n#{COLORES[:bold]}📊 Resultado de verificación:#{COLORES[:reset]}\n"
# OK
puts "#{COLORES[:green]}✅ Migradas correctamente: #{@resultados[:ok].size}#{COLORES[:reset]}"
@resultados[:ok].each do |r|
puts " #{COLORES[:dim]}#{r[:fecha]} (#{r[:entradas]} entradas)#{COLORES[:reset]}"
end
# Sin entradas
if @resultados[:sin_entradas].any?
puts "\n#{COLORES[:yellow]}⚠ En DB pero sin entradas: #{@resultados[:sin_entradas].size}#{COLORES[:reset]}"
@resultados[:sin_entradas].each { |f| puts " #{f}" }
end
# No migradas
if @resultados[:no_migrada].any?
puts "\n#{COLORES[:red]}❌ No migradas (MD existe, DB no): #{@resultados[:no_migrada].size}#{COLORES[:reset]}"
@resultados[:no_migrada].each { |f| puts " #{f}" }
puts " #{COLORES[:dim]}Ejecuta: ./tools/adn/run db migrar:md FECHA#{COLORES[:reset]}"
end
# Sin MD
if @resultados[:sin_md].any?
puts "\n#{COLORES[:cyan]} En DB sin archivo MD: #{@resultados[:sin_md].size}#{COLORES[:reset]}"
@resultados[:sin_md].each { |f| puts " #{f}" }
puts " #{COLORES[:dim]}Ejecuta: ./tools/adn/run db exportar:md FECHA#{COLORES[:reset]}"
end
total = @resultados[:ok].size + @resultados[:sin_entradas].size
total_md = @resultados[:ok].size + @resultados[:sin_entradas].size + @resultados[:no_migrada].size
pct = total_md > 0 ? ((@resultados[:ok].size.to_f / total_md) * 100).round(1) : 0
puts "\n#{COLORES[:bold]}📈 Cobertura: #{pct}% (#{@resultados[:ok].size}/#{total_md})#{COLORES[:reset]}"
if @resultados[:no_migrada].empty? && @resultados[:sin_entradas].empty?
puts "#{COLORES[:green]}#{COLORES[:bold]}🎉 ¡Migración completa! Todas las bitácoras están en la DB.#{COLORES[:reset]}"
end
end
end
end
end
# Ejecución directa
if __FILE__ == $0
bitacoras_dir = File.expand_path('../../../docs/bitacoras', __dir__)
BitacorasDB::CLI::VerificarMigracion.new(bitacoras_dir).ejecutar
end
+130
View File
@@ -0,0 +1,130 @@
# frozen_string_literal: true
require 'date'
module BitacorasDB
module Exporters
class MDExporter
def initialize(db, logger = nil)
@db = db
@logger = logger || default_logger
end
def exportar_bitacora(fecha, ruta_destino)
@logger.info("Exportando bitácora #{fecha} a #{ruta_destino}...")
bitacora = @db.find_bitacora_by_fecha(fecha)
unless bitacora
@logger.error("No se encontró la bitácora para la fecha: #{fecha}")
return false
end
bitacora_id = bitacora['id']
# Obtener datos estructurados (incluyendo lo nuevo)
entradas = @db.execute("
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE e.bitacora_id = $1
ORDER BY n.nombre, e.inicio ASC
", [bitacora_id])
gestion = @db.execute("
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE g.bitacora_id = $1 OR g.activo = true
ORDER BY g.tipo, g.orden
", [bitacora_id]) rescue [] # Por si la tabla no existe aún en otros entornos
resumenes = @db.execute("
SELECT r.*, n.nombre as nodo_nombre
FROM bitacoras.resumen_nodos r
JOIN bitacoras.nodos n ON r.nodo_id = n.id
WHERE r.bitacora_id = $1
", [bitacora_id]) rescue []
# Generar contenido Markdown
contenido = generar_markdown(fecha, bitacora['resumen'], gestion, resumenes, entradas)
File.write(ruta_destino, contenido)
@logger.info("✅ Exportación completada con éxito.")
true
rescue => e
@logger.error("Error exportando bitácora: #{e.message}")
false
end
private
def generar_markdown(fecha, resumen_gen, gestion, resumenes, entradas)
y, m, d = fecha.to_s.split('-')
md = String.new("<!-- GENERADO AUTOMÁTICAMENTE por adn db exportar:md — No editar manualmente -->\n")
md << "# Bitácora de Operaciones - #{d}/#{m}/#{y}\n\n"
if gestion && gestion.ntuples > 0
md << "## 📋 Control de Gestión\n\n"
pendientes = gestion.select { |g| g['tipo'] == 'pendiente' }
if pendientes.any?
md << "### 📝 Resumen de Actividades\n" # Formato ADN
md << "| NODO | PENDIENTES |\n| :--- | :--- |\n"
pendientes.each do |p|
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
end
md << "\n"
end
en_proceso = gestion.select { |g| g['tipo'] == 'proceso' }
if en_proceso.any?
md << "### ⏳ En Proceso\n"
md << "| NODO | ACTIVIDAD |\n| :--- | :--- |\n"
en_proceso.each do |p|
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
end
md << "\n"
end
end
if resumenes && resumenes.ntuples > 0
md << "### 📝 Resumen Integral\n"
md << "| NODO | RESUMEN INTEGRAL |\n| :--- | :--- |\n"
resumenes.each do |r|
md << "| #{r['nodo_nombre']} | #{r['resumen']} |\n"
end
md << "\n"
end
md << "## 📂 Actividades Detalladas\n\n"
por_nodo = entradas.group_by { |e| e['nodo_nombre'] || 'sin-nodo' }
por_nodo.each do |nodo, entries|
md << "### #{nodo}\n\n"
md << "| I | F | Descripción | E |\n"
md << "| :--- | :--- | :--- | :--- |\n"
entries.each do |e|
inicio = e['inicio'] ? e['inicio'][0..4] : '-'
fin = e['fin'] ? e['fin'][0..4] : '-'
ia = e['es_ia'] == 't' ? '(IA) ' : ''
estado = "[#{e['modo']}] #{e['estado']}"
md << "| #{inicio} | #{fin} | #{ia}#{e['descripcion']} | #{estado} |\n"
end
md << "\n"
end
md << "---\n"
md << "<!-- GENERADO AUTOMÁTICAMENTE desde PostgreSQL el #{Time.now.strftime('%Y-%m-%d %H:%M')} -->\n"
md << "<!-- Fuente de verdad: DB. Regenerar con: adn db exportar:md #{fecha} -->\n"
md
end
def default_logger
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
end.new
end
end
end
end
+100 -38
View File
@@ -31,6 +31,7 @@
require 'date'
require 'time'
require 'yaml'
require 'digest'
module BitacorasDB
module Importers
@@ -43,14 +44,16 @@ module BitacorasDB
'⏸️' => '⏸️',
'⏳' => '⏳',
'👁️' => '⏳', # Sincronización mapea a ⏳
'➡️' => '⏳' # Tarea continúa en otro día
'➡️' => '⏳', # Tarea continúa en otro día
'🎭' => '✅' # Evento/Servicio
}.freeze
# Modos posibles en archivos .md
MODOS_MD = {
'[P]' => 'P', # Presencial
'[R]' => 'R', # Remoto
'[P/R]' => 'P' # Presencial/Remoto -> Presencial por defecto
'[P/R]' => 'P', # Presencial/Remoto -> Presencial por defecto
'[S]' => 'S' # Servicio (No nodo técnico)
}.freeze
# Tipos de nodo inferidos del nombre
@@ -61,6 +64,7 @@ module BitacorasDB
/^pcv-/ => 'vm',
/^pc-/ => 'pc',
/^srvv-/ => 'vm',
/^dtic-/ => 'servicio',
/-pmox\d+$/ => 'hipervisor',
/-xen\d+$/ => 'hipervisor'
}.freeze
@@ -264,7 +268,7 @@ module BitacorasDB
end
# Detectar inicio de tabla de entradas
if linea.match?(/^\| I \| F \| Descripción \| E \|$/)
if linea.match?(/^\| I \| F \| Descripción \| (?:J \| )?E \|$/)
en_tabla_entradas = true
encabezados_tabla = linea.split('|').map(&:strip).reject(&:empty?)
entradas_tabla = []
@@ -343,20 +347,32 @@ module BitacorasDB
# Parsea línea de tema (ej: "✅ - SEC01 - Refactorización ns8-candados")
def parsear_tema(linea)
# Patrón: estado - id - título (id puede contener puntos, como ADN01.1)
match = linea.match(/^([✅❌⚠️⏸️⏳👁️➡️]+)\s*-\s*([A-Z0-9.]+)\s*-\s*(.+)$/)
return nil unless match
# Intentar formato estándar: estado - id - título (ej: ✅ - ADN01 - Título)
match_std = linea.match(/^([^\s-]+)\s*-\s*([A-Z0-9.]+)\s*-\s*(.+)$/)
if match_std
return {
id_tema: match_std[2],
titulo: match_std[3].strip,
estado: ESTADOS_MD[match_std[1]] || '⏳',
descripcion: match_std[3].strip
}
end
estado = match[1]
id_tema = match[2]
titulo = match[3].strip
# Intentar formato simplificado: estado - título (ej: 🎭 - Servicio Auditorio)
match_simple = linea.match(/^([^\s-]+)\s*-\s*(.+)$/)
if match_simple
titulo = match_simple[2].strip
# Generar un ID determinista basado en el título para estabilidad
id_gen = "GEN" + Digest::MD5.hexdigest(titulo)[0..5].upcase
return {
id_tema: id_gen,
titulo: titulo,
estado: ESTADOS_MD[match_simple[1]] || '⏳',
descripcion: titulo
}
end
{
id_tema: id_tema,
titulo: titulo,
estado: ESTADOS_MD[estado] || '⏳',
descripcion: titulo # Por defecto, usar título como descripción
}
nil
end
# Parsea líneas de tabla de entradas
@@ -370,6 +386,7 @@ module BitacorasDB
when 'i' then columnas[:inicio] = idx
when 'f' then columnas[:fin] = idx
when 'descripción' then columnas[:descripcion] = idx
when 'j' then columnas[:jornada] = idx
when 'e' then columnas[:estado] = idx
end
end
@@ -379,28 +396,39 @@ module BitacorasDB
next if linea.match?(/^[\|\s:-]+$/) # Solo pipes, espacios, guiones y dos puntos
next if linea.match?(/^\|?\s*:?-+:?\s*\|/) # Separadores con :---, ---, :-:, etc.
celdas = linea.split('|').map(&:strip).reject(&:empty?)
# Limpiar pipes iniciales y finales antes de split
linea_limpia = linea.strip.gsub(/^\||\|$/, '')
celdas = linea_limpia.split('|').map(&:strip)
next if celdas.length < 3 # Mínimo: inicio, descripción, estado
# Validar que la celda de inicio no sea un separador
# Validar que la celda de inicio tenga contenido
valor_inicio = celdas[columnas[:inicio]]
next if valor_inicio.nil? || valor_inicio.match?(/^:?-+:?$/)
next if valor_inicio.nil? || valor_inicio.empty? || valor_inicio.match?(/^:?-+:?$/)
fin_valor = nil
if columnas[:fin] && celdas[columnas[:fin]] != '-'
fin_valor = celdas[columnas[:fin]]
if columnas[:fin] && !celdas[columnas[:fin]].nil?
raw_fin = celdas[columnas[:fin]].strip
fin_valor = raw_fin if !raw_fin.empty? && raw_fin != '-'
end
entrada = {
inicio: celdas[columnas[:inicio]],
fin: fin_valor,
inicio: valor_inicio.gsub(/\s*\(.*?\)\s*/, '').strip,
fin: fin_valor ? fin_valor.gsub(/\s*\(.*?\)\s*/, '').strip : nil,
descripcion: celdas[columnas[:descripcion]],
estado_modo: celdas[columnas[:estado]]
}
# Parsear estado y modo (ej: "[P] ✅")
if entrada[:estado_modo]
estado_modo = parsear_estado_modo(entrada[:estado_modo])
# Parsear estado y modo
if columnas[:jornada] && columnas[:estado]
# Nuevo formato: J y E separados
# Unimos temporalmente para usar parsear_estado_modo existente
campo_combinado = "[#{celdas[columnas[:jornada]]}] #{celdas[columnas[:estado]]}"
estado_modo = parsear_estado_modo(campo_combinado)
entrada[:estado] = estado_modo[:estado]
entrada[:modo] = estado_modo[:modo]
elsif columnas[:estado]
# Formato viejo o solo E
estado_modo = parsear_estado_modo(celdas[columnas[:estado]])
entrada[:estado] = estado_modo[:estado]
entrada[:modo] = estado_modo[:modo]
end
@@ -456,6 +484,16 @@ module BitacorasDB
bitacora_id = bitacora['id']
# 1b. Si se solicita sobrescribir, limpiar entradas previas de esta bitácora
if opciones[:sobrescribir]
@logger.info("Limpiando datos previos de la bitácora #{estructura[:fecha]}...")
# Borrar entradas primero (FK)
@db.execute("DELETE FROM bitacoras.entradas WHERE bitacora_id = $1", [bitacora_id])
# Borrar temas (FK)
@db.execute("DELETE FROM bitacoras.temas WHERE bitacora_id = $1", [bitacora_id])
@estadisticas[:datos_limpiados] = true
end
# 2. Procesar cada nodo
estructura[:nodos].each do |nombre_nodo, datos_nodo|
# Buscar o crear nodo
@@ -505,19 +543,43 @@ module BitacorasDB
# Validar campos requeridos
next unless entrada_md[:inicio] && entrada_md[:descripcion]
# Crear entrada en BD
@db.create_entrada(
inicio: entrada_md[:inicio],
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P',
es_ia: false, # Las importadas de .md no son IA
tema_id: tema_id,
bitacora_id: bitacora_id,
nodo_id: nodo_id
)
@estadisticas[:entradas_creadas] += 1
# Intentar buscar entrada existente para evitar duplicados (Upsert)
# Buscamos por: bitacora_id, nodo_id y hora de inicio (única para un nodo en un día)
entradas_nodo = @db.list_entradas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100)
# Normalizar hora para comparación
inicio_norm = @db.send(:parse_time, entrada_md[:inicio])
existente = entradas_nodo.find { |e| e['inicio'] == inicio_norm }
begin
if existente
# Actualizar si ya existe
@db.update_entrada(existente['id'], {
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P'
})
@estadisticas[:entradas_actualizadas] ||= 0
@estadisticas[:entradas_actualizadas] += 1
else
# Crear si no existe
@db.create_entrada(
inicio: entrada_md[:inicio],
fin: entrada_md[:fin],
descripcion: entrada_md[:descripcion],
estado: entrada_md[:estado] || '⏳',
modo: entrada_md[:modo] || 'P',
es_ia: false,
tema_id: tema_id,
bitacora_id: bitacora_id,
nodo_id: nodo_id
)
@estadisticas[:entradas_creadas] += 1
end
rescue => e
@logger.error("Error procesando entrada '#{entrada_md[:inicio]} - #{entrada_md[:descripcion]}': #{e.message}")
end
end
end
end
+35 -2
View File
@@ -19,6 +19,7 @@
# encrypt <txt> Cifrar texto plano
# decrypt <tok> Descifrar payload
# get <clave> Obtener secreto de la bóveda (salida: solo el valor)
# run <cl> <vr> <cmd> Ejecutar comando con secreto en variable de entorno <vr>
# set <clave> Guardar secreto (pide input interactivo, sin eco)
# rm <clave> Eliminar secreto de la bóveda
# list Listar claves disponibles en la bóveda
@@ -203,6 +204,33 @@ def cmd_get(cripto, boveda, clave)
puts cripto.decrypt(token)
end
def cmd_run(cripto, boveda, clave, env_var, comando)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
abort "#{C::RED}✗ Falta nombre de variable de entorno.#{C::RESET}" if env_var.nil?
abort "#{C::RED}✗ Falta comando a ejecutar.#{C::RESET}" if comando.nil? || comando.empty?
verificar_sesion!
token = boveda.get(clave)
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token.nil?
secret = cripto.decrypt(token)
registrar_acceso('RUN', "Clave: #{clave}, Comando: #{comando.is_a?(Array) ? comando.join(' ') : comando}")
# Ejecutar el comando inyectando la variable
ENV[env_var] = secret
begin
# Si comando es un string, lo ejecutamos vía shell, si es array, directo
if comando.is_a?(Array)
system(*comando)
else
system(comando)
end
exit $?.exitstatus || 0
ensure
ENV.delete(env_var)
end
end
def cmd_set(cripto, boveda, clave)
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
$stderr.print "#{C::YELLOW}🔑 Valor para '#{clave}': #{C::RESET}"
@@ -243,6 +271,7 @@ def cmd_help
encrypt <texto> Cifrar texto plano token
decrypt <token> Descifrar token texto plano
get <clave> Obtener secreto de la bóveda
run <cl> <vr> <cmd> Ejecutar comando con secreto en variable <vr>
set <clave> Guardar secreto (input seguro)
rm <clave> Eliminar secreto
list Listar claves disponibles
@@ -251,7 +280,10 @@ def cmd_help
# Autorizar para operar
ruby ns8-candados.rb authorize
#{C::DIM}# Usar en scripts (salida limpia)#{C::RESET}
#{C::DIM}# Usar de forma segura inyectando en el entorno (recomendado)#{C::RESET}
ruby ns8-candados.rb run admindasu PASS "sshpass -e ssh user@host"
#{C::DIM}# Usar en scripts (salida limpia - menos seguro)#{C::RESET}
PASS=$(ruby ns8-candados.rb get admindasu)
sshpass -p "$PASS" ssh user@host "cmd"
HELP
@@ -266,13 +298,14 @@ when 'authorize'
autorizar!
when 'cerrar'
cerrar_sesion!
when 'encrypt', 'decrypt', 'get', 'set', 'rm', 'list'
when 'encrypt', 'decrypt', 'get', 'run', 'set', 'rm', 'list'
cripto = Cripto.new(cargar_master_key)
boveda = Boveda.new(BOVEDA)
case comando
when 'encrypt' then cmd_encrypt(cripto, arg)
when 'decrypt' then cmd_decrypt(cripto, arg)
when 'get' then cmd_get(cripto, boveda, arg)
when 'run' then cmd_run(cripto, boveda, arg, ARGV[2], ARGV[3..].join(' '))
when 'set' then cmd_set(cripto, boveda, arg)
when 'rm' then cmd_rm(boveda, arg)
when 'list' then cmd_list(boveda)