[P2604-S13] Consolidar core/ — lógica de dominio a submódulos CLI

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)
This commit is contained in:
Ricardo Monla
2026-03-15 17:19:14 -03:00
parent f902b4aa89
commit a5f71df15d
15 changed files with 31 additions and 15 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
require 'optparse'
require 'fileutils'
require 'date'
require_relative '../core/configurador'
require_relative 'backup/configurador'
module ADN
class SubcomandoBackup
+50
View File
@@ -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
+1 -1
View File
@@ -2,7 +2,7 @@
require 'optparse'
require_relative '../core/colores'
require_relative '../core/nodos_info'
require_relative 'nodos/info'
class SubcomandoContexto
def initialize(args, logger)
+1 -1
View File
@@ -9,7 +9,7 @@
require 'optparse'
require_relative '../core/colores'
require_relative '../core/nodos_info'
require_relative 'nodos/info'
require_relative '../core/help_formatter'
module ADN
+134
View File
@@ -0,0 +1,134 @@
# frozen_string_literal: true
# adn/tools/cli/nodos/info.rb — Utilidades para extracción de metadatos de fichas de nodos
# ======================================================================================
require_relative '../../core/constants'
module ADN
module NodosInfo
def self.extraer_metadata(nodo_nombre)
ruta = File.join(ADN::NODOS_DIR, "#{nodo_nombre}.md")
return nil unless File.exist?(ruta)
contenido = File.read(ruta, encoding: 'UTF-8')
{
nombre: nodo_nombre,
ip: detectar_ip(contenido),
host: detectar_host(contenido),
so: detectar_so(contenido),
vmid: detectar_vmid(contenido),
puerto_ssh: detectar_puerto_ssh(contenido) || 22,
usuario: detectar_usuario(contenido),
clave_boveda: detectar_clave_boveda(contenido),
auth_type: detectar_auth_type(contenido)
}
end
def self.detectar_usuario(contenido)
# **SSH**: user@ip:port o DOMAIN\user@ip:port
if contenido =~ /\*\*SSH\*\*:\s*`?([^`\s(]+)/i
full = $1
# Extraer usuario de formato user@ip:port o DOMAIN\user@ip:port
if full.include?('@')
return full.split('@')[0...-1].join('@') if full.split('@').last =~ /^[\d\.]+/
return full.split(':')[0]
end
return full
end
# Fallback: | SSH | user@domain@ip:7022 |
if contenido =~ /(?:SSH|Acceso)\*\*?:?\s*`?([a-zA-Z0-9_\-\.\\@]+)/i
full = $1
if full.include?('@')
parts = full.split('@')
if parts.last =~ /^\d{1,3}(\.\d{1,3}){3}/
return parts[0...-1].join('@')
else
return full.split(':')[0]
end
end
return full
end
nil
end
def self.detectar_ip(contenido)
# Prioridad 1: IP de línea SSH (es la IP operativa para conexión)
if contenido =~ /\*\*SSH\*\*:\s*`?[^@]+@([\d\.]+)/i
return $1
end
# | IP / Ubicación | 10.0.10.10 |
# | IP | 10.0.100.11/24 |
if contenido =~ /\|\s*IP\s*(?:\/\s*Ubicación)?\s*\|\s*`?([\d\.]+)/i
return $1
end
# - **IP**: `10.0.10.200`
if contenido =~ /IP\*\*:\s*`?([\d\.]+)/i
return $1
end
nil
end
def self.detectar_host(contenido)
if contenido =~ /\*\*(?:Padre\/Host|Host Anfitrión|Anfitrión|Host)\*\*\s*[:\|]?\s*`?([a-zA-Z0-9\-]+)`?/i
return $1
end
nil
end
def self.detectar_so(contenido)
# | Sistema Operativo | ... | o - **Sistema Operativo**: ...
if contenido =~ /(?:\||\-)\s*\*\*?(?:Sistema Operativo|SO|OS)\*\*?\s*[:\|]\s*([^\|\n]+)/i
so_raw = $1.strip
return :windows if so_raw =~ /windows|server|win/i
return :linux if so_raw =~ /linux|ubuntu|debian|centos|rocky|proxmox/i
end
:linux # Default
end
def self.detectar_vmid(contenido)
if contenido =~ /(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i
return $1
end
nil
end
def self.detectar_puerto_ssh(contenido)
# **SSH**: user@ip:7022
if contenido =~ /\*\*SSH\*\*:\s*`?[^`\s]+:(\d+)/i
return $1.to_i
end
# | SSH | user@ip:7022 |
if contenido =~ /(?:SSH|Puerto|Puerto SSH)\*\*?:?\s*.*:(\d+)/i
return $1.to_i
end
# (Puerto 7022)
if contenido =~ /\(Puerto\s+(\d+)\)/i
return $1.to_i
end
nil
end
# Detecta la clave de la bóveda de candados desde la ficha
# Formato esperado: (Bóveda: `clave`) o (Bóveda: clave)
def self.detectar_clave_boveda(contenido)
if contenido =~ /\*\*SSH\*\*:.*Bóveda:\s*`?([a-zA-Z0-9_\-:]+)`?\)?/i
return $1
end
# Fallback: buscar en cualquier línea de credenciales
if contenido =~ /[Bb]óveda:\s*`?([a-zA-Z0-9_\-:]+)`?\)?/
return $1
end
nil
end
# Detecta tipo de autenticación SSH
# Formato: (Passphrase RSA, ...) o (Password, ...)
def self.detectar_auth_type(contenido)
if contenido =~ /\*\*SSH\*\*:.*\((Passphrase|Password)/i
return $1.downcase == 'passphrase' ? :passphrase : :password
end
:password # Default para VMs Windows
end
end
end
+2 -2
View File
@@ -8,8 +8,8 @@ require 'open3'
require 'date'
require_relative '../core/colores'
require_relative '../core/nodos_info'
require_relative '../core/ejecutor_remoto'
require_relative 'nodos/info'
require_relative 'ssh/ejecutor'
require_relative '../db/core/bitacora_db'
module ADN
+1 -1
View File
@@ -17,7 +17,7 @@ require 'shellwords'
require_relative '../core/colores'
require_relative '../core/constants'
require_relative '../core/error_handler'
require_relative '../core/nodos_info'
require_relative 'nodos/info'
module ADN
class SubcomandoSSH
+62
View File
@@ -0,0 +1,62 @@
# frozen_string_literal: true
# adn/tools/cli/ssh/ejecutor.rb — Abstracción para ejecución segura via SSH y Candados
# ======================================================================================
require 'open3'
require_relative '../../core/constants'
module ADN
class EjecutorRemoto
CANDADOS_CMD = ["ruby", ADN::CANDADOS_PATH]
def self.ejecutar(nodo_info, comando, options = {})
# Si es local, ejecutar directamente
if nodo_info[:ip] == 'localhost' || nodo_info[:ip] == '127.0.0.1'
puts " [LOCAL] > #{comando[0..50]}..." if options[:verbose]
output = `#{comando} 2>&1`
return { success: $?.success?, output: output }
end
clave = options[:clave] || (nodo_info[:host] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'win_domain')
usuario = options[:usuario] || (nodo_info[:so] == :windows ? "monlaricardo@utnlaroja.intranet" : "rmonla")
# Construir comando SSH base como un array para evitar problemas de shell
ssh_cmd = [
"sshpass", "-e", "ssh",
"-p", (nodo_info[:puerto_ssh] || 22).to_s,
"-o", "StrictHostKeyChecking=no",
"#{usuario}@#{nodo_info[:ip]}",
comando
]
# Comando final encapsulado vía candados
# Usamos el modo array de candados: run <clave> <var> <cmd_array>
candados_full = CANDADOS_CMD + ["run", clave, "SSHPASS"] + ssh_cmd
puts " [SSH] #{nodo_info[:nombre]} (#{nodo_info[:ip]}) > #{comando[0..50]}..." if options[:verbose]
# Usar Open3 para capturar salida limpia
stdout, stderr, status = Open3.capture3(*candados_full)
{ success: status.success?, output: stdout + stderr }
end
# Pipeline para streaming (mejorado)
def self.stream_pipeline(src_array, dst_array)
begin
# Open3.pipeline acepta múltiples arrays de comando
statuses = Open3.pipeline(src_array, dst_array)
statuses.all?(&:success?)
rescue => e
puts "Error en pipeline: #{e.message}"
false
end
end
# Generador de comandos para candados (reutilizable)
def self.comando_candados(clave, env_var, comando_array)
CANDADOS_CMD + ["run", clave, env_var] + comando_array
end
end
end
+2 -2
View File
@@ -12,8 +12,8 @@
# 📚 Referencia: docs/plan/260307-1400_Plan_Mejoras_ADN.md
require 'optparse'
require_relative '../core/triggers'
require_relative '../core/eventos'
require_relative 'triggers/motor'
require_relative 'triggers/eventos'
module ADN
class SubcomandoTriggers
+340
View File
@@ -0,0 +1,340 @@
# frozen_string_literal: true
# adn/tools/eventos.rb — Bus de eventos interno para el sistema ADN
# ==================================================================
# Este módulo implementa un bus de eventos que permite la comunicación
# desacoplada entre los diferentes componentes del sistema ADN.
# Facilita la implementación del Principio de Armonía Integral al
# permitir que cambios en un componente disparen acciones automáticas
# en otros componentes dependientes.
#
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
# 📚 Referencia: docs/plan/260307-1400_Plan_Mejoras_ADN.md
# 🔗 Integración: adn/triggers.yml (configuración de triggers)
require 'singleton'
module ADN
class Eventos
include Singleton
# Eventos predefinidos del sistema ADN
# Estos eventos corresponden a los triggers definidos en adn/triggers.yml
EVENTOS = {
# Bitácoras
bitacora_creada: :bitacora_creada,
bitacora_modificada: :bitacora_modificada,
bitacora_guardada: :bitacora_guardada,
nueva_vinyeta_cronologia: :nueva_vinyeta_cronologia,
estado_cambiado: :estado_cambiado,
metrica_hibrida_registrada: :metrica_hibrida_registrada,
error_formato_detectado: :error_formato_detectado,
# Nodos
nodo_creado: :nodo_creado,
nodo_modificado: :nodo_modificado,
estado_nodo_cambiado: :estado_nodo_cambiado,
# Proyectos
proyecto_creado: :proyecto_creado,
proyecto_modificado: :proyecto_modificado,
hito_proyecto_actualizado: :hito_proyecto_actualizado,
estado_proyecto_cambiado: :estado_proyecto_cambiado,
# ADN y configuración
hebra_adn_modificada: :hebra_adn_modificada,
iconografia_actualizada: :iconografia_actualizada,
directivas_ia_actualizadas: :directivas_ia_actualizadas,
configuracion_actualizada: :configuracion_actualizada,
# Sistema
sistema_iniciado: :sistema_iniciado,
validacion_completada: :validacion_completada,
error_sistema: :error_sistema,
advertencia_sistema: :advertencia_sistema
}.freeze
# Inicializa el bus de eventos
def initialize
@suscriptores = Hash.new { |hash, key| hash[key] = [] }
@logger = ADN.logger
@mutex = Mutex.new
@logger.debug('Bus de eventos ADN inicializado')
end
# Registra un suscriptor para un tipo de evento
#
# @param evento [Symbol, String] Tipo de evento al que suscribirse
# @param bloque [Proc] Bloque a ejecutar cuando se publique el evento
# @param identificador [String] Identificador opcional para el suscriptor
# @return [String] Identificador del suscriptor
def suscriptor(evento, identificador = nil, &bloque)
evento_normalizado = normalizar_evento(evento)
@mutex.synchronize do
id = identificador || "suscriptor_#{@suscriptores[evento_normalizado].length + 1}"
@suscriptores[evento_normalizado] << { id: id, bloque: bloque }
@logger.debug("Suscriptor registrado: #{id} para evento #{evento_normalizado}")
id
end
end
# Publica un evento a todos los suscriptores registrados
#
# @param evento [Symbol, String] Tipo de evento a publicar
# @param datos [Hash] Datos asociados al evento
# @param opciones [Hash] Opciones de publicación
# @option opciones [Boolean] :async Ejecutar suscriptores en hilos separados
# @option opciones [Boolean] :silencioso No loggear la publicación
# @return [Array<Hash>] Resultados de la ejecución de suscriptores
def publicar(evento, datos = {}, opciones = {})
evento_normalizado = normalizar_evento(evento)
suscriptores = @suscriptores[evento_normalizado].dup
return [] if suscriptores.empty?
@logger.debug("Publicando evento #{evento_normalizado} a #{suscriptores.length} suscriptor(es)", datos) unless opciones[:silencioso]
resultados = []
suscriptores.each do |suscriptor|
resultado = ejecutar_suscriptor(suscriptor, evento_normalizado, datos, opciones)
resultados << resultado if resultado
end
resultados
end
# Elimina un suscriptor específico
#
# @param evento [Symbol, String] Tipo de evento
# @param identificador [String] Identificador del suscriptor a eliminar
# @return [Boolean] true si se eliminó, false si no se encontró
def eliminar_suscriptor(evento, identificador)
evento_normalizado = normalizar_evento(evento)
@mutex.synchronize do
inicial = @suscriptores[evento_normalizado].length
@suscriptores[evento_normalizado].reject! { |s| s[:id] == identificador }
eliminados = inicial - @suscriptores[evento_normalizado].length
if eliminados > 0
@logger.debug("Suscriptor eliminado: #{identificador} del evento #{evento_normalizado}")
true
else
@logger.advertencia("Suscriptor no encontrado: #{identificador} para evento #{evento_normalizado}")
false
end
end
end
# Elimina todos los suscriptores de un evento
#
# @param evento [Symbol, String] Tipo de evento
# @return [Integer] Número de suscriptores eliminados
def limpiar_suscriptores(evento = nil)
@mutex.synchronize do
if evento.nil?
total = @suscriptores.values.flatten.length
@suscriptores.clear
@logger.debug("Todos los suscriptores eliminados (#{total} total)")
total
else
evento_normalizado = normalizar_evento(evento)
eliminados = @suscriptores[evento_normalizado].length
@suscriptores.delete(evento_normalizado)
@logger.debug("#{eliminados} suscriptor(es) eliminados del evento #{evento_normalizado}")
eliminados
end
end
end
# Lista todos los eventos con suscriptores
#
# @return [Hash] Mapa de evento -> número de suscriptores
def lista_suscriptores
resultado = {}
@mutex.synchronize do
@suscriptores.each do |evento, suscriptores|
resultado[evento] = suscriptores.length
end
end
resultado
end
# Verifica si hay suscriptores para un evento
#
# @param evento [Symbol, String] Tipo de evento
# @return [Boolean] true si hay suscriptores
def tiene_suscriptores?(evento)
evento_normalizado = normalizar_evento(evento)
!@suscriptores[evento_normalizado].empty?
end
# Métodos de conveniencia para eventos comunes
# Publica un evento de archivo modificado
#
# @param ruta [String] Ruta del archivo modificado
# @param tipo [String] Tipo de archivo (bitacora, nodo, proyecto, hebra)
# @param accion [String] Acción (creado, modificado, eliminado)
# @param datos_extra [Hash] Datos adicionales
def archivo_modificado(ruta, tipo, accion, datos_extra = {})
datos = {
ruta: ruta,
tipo: tipo,
accion: accion,
timestamp: Time.now.utc
}.merge(datos_extra)
evento = case tipo
when 'bitacora' then :bitacora_modificada
when 'nodo' then :nodo_modificado
when 'proyecto' then :proyecto_modificado
when 'hebra' then :hebra_adn_modificada
else :archivo_modificado
end
publicar(evento, datos)
end
# Publica un evento de estado cambiado
#
# @param entidad [String] Tipo de entidad (nodo, proyecto, tarea)
# @param nombre [String] Nombre de la entidad
# @param estado_anterior [String] Estado anterior
# @param estado_nuevo [String] Estado nuevo
# @param datos_extra [Hash] Datos adicionales
def estado_cambiado(entidad, nombre, estado_anterior, estado_nuevo, datos_extra = {})
datos = {
entidad: entidad,
nombre: nombre,
estado_anterior: estado_anterior,
estado_nuevo: estado_nuevo,
timestamp: Time.now.utc
}.merge(datos_extra)
publicar(:estado_cambiado, datos)
end
# Publica un evento de error detectado
#
# @param tipo [String] Tipo de error (formato, validacion, sistema)
# @param ubicacion [String] Ubicación del error (archivo, módulo)
# @param mensaje [String] Mensaje descriptivo del error
# @param severidad [String] Severidad (baja, media, alta, critica)
# @param datos_extra [Hash] Datos adicionales
def error_detectado(tipo, ubicacion, mensaje, severidad = 'media', datos_extra = {})
datos = {
tipo: tipo,
ubicacion: ubicacion,
mensaje: mensaje,
severidad: severidad,
timestamp: Time.now.utc
}.merge(datos_extra)
publicar(:error_formato_detectado, datos)
end
# Publica un evento de validación completada
#
# @param tipo [String] Tipo de validación (bitacoras, nodos, proyectos, completa)
# @param resultados [Hash] Resultados de la validación
def validacion_completada(tipo, resultados)
datos = {
tipo: tipo,
resultados: resultados,
timestamp: Time.now.utc
}
publicar(:validacion_completada, datos)
end
private
# Normaliza un evento a símbolo
#
# @param evento [Symbol, String] Evento a normalizar
# @return [Symbol] Evento normalizado
def normalizar_evento(evento)
evento.to_s.to_sym
end
# Ejecuta un suscriptor individual con manejo de errores
#
# @param suscriptor [Hash] Información del suscriptor
# @param evento [Symbol] Evento que se está publicando
# @param datos [Hash] Datos del evento
# @param opciones [Hash] Opciones de publicación
# @return [Hash] Resultado de la ejecución
def ejecutar_suscriptor(suscriptor, evento, datos, opciones)
id = suscriptor[:id]
bloque = suscriptor[:bloque]
inicio = Time.now
begin
resultado = bloque.call(datos.dup.freeze)
duracion = Time.now - inicio
{
suscriptor: id,
evento: evento,
exito: true,
duracion: duracion,
resultado: resultado,
timestamp: inicio
}
rescue => e
duracion = Time.now - inicio
@logger.error("Error en suscriptor #{id} para evento #{evento}: #{e.message}", {
backtrace: e.backtrace.take(5),
evento: evento,
datos: datos
})
{
suscriptor: id,
evento: evento,
exito: false,
duracion: duracion,
error: e.message,
backtrace: e.backtrace.take(5),
timestamp: inicio
}
end
end
end
# Acceso global al bus de eventos
#
# @return [ADN::Eventos] Instancia singleton del bus de eventos
def self.eventos
Eventos.instance
end
end
# ─── Notas de uso ──────────────────────────────────────────────────────
#
# Ejemplo de suscripción:
# ADN.eventos.suscriptor(:bitacora_creada) do |datos|
# puts "Nueva bitácora creada: #{datos[:ruta]}"
# end
#
# Ejemplo de publicación:
# ADN.eventos.publicar(:bitacora_creada, {
# ruta: 'bitacoras/2026-03-07.md',
# fecha: Date.today
# })
#
# Ejemplo de publicación con método de conveniencia:
# ADN.eventos.archivo_modificado(
# 'bitacoras/2026-03-07.md',
# 'bitacora',
# 'creado'
# )
#
# El bus de eventos es especialmente útil para:
# - Implementar triggers basados en eventos
# - Desacoplar componentes del sistema
# - Facilitar la extensibilidad mediante plugins
# - Loggear actividades del sistema de forma centralizada
#
+525
View File
@@ -0,0 +1,525 @@
# frozen_string_literal: true
# adn/tools/triggers.rb — Sistema de triggers para sincronización automática del ADN
# =============================================================================
# Este módulo implementa el sistema de triggers definido en adn/triggers.yml.
# Los triggers implementan el Principio de Armonía Integral al propagar
# automáticamente cambios entre diferentes componentes del sistema ADN.
#
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
# 📚 Referencia: adn/triggers.yml (configuración)
# 📚 Plan: docs/plan/260307-1400_Plan_Mejoras_ADN.md
# 🔗 Integración: adn/tools/eventos.rb (bus de eventos)
require 'yaml'
require 'singleton'
require_relative 'eventos'
module ADN
class Triggers
include Singleton
# Inicializa el sistema de triggers
def initialize
@config_path = File.join(ADN::PROJECT_ROOT, 'adn', 'triggers.yml')
@config = nil
@suscriptores_registrados = {}
@logger = ADN.logger
@mutex = Mutex.new
@logger.debug('Sistema de triggers ADN inicializado')
end
# Carga la configuración de triggers desde el archivo YAML
#
# @return [Hash] Configuración cargada
# @raise [Errno::ENOENT] Si el archivo no existe
# @raise [Psych::SyntaxError] Si el YAML es inválido
def cargar_configuracion
unless File.exist?(@config_path)
@logger.advertencia("Archivo de configuración de triggers no encontrado: #{@config_path}")
return {}
end
begin
@config = YAML.load_file(@config_path, permitted_classes: [Date, Time, Symbol])
@logger.info("Configuración de triggers cargada desde #{@config_path}")
@config
rescue Psych::SyntaxError => e
@logger.error("Error de sintaxis YAML en #{@config_path}: #{e.message}")
raise
rescue => e
@logger.error("Error cargando configuración de triggers: #{e.message}")
raise
end
end
# Obtiene la configuración actual (carga si es necesario)
#
# @return [Hash] Configuración actual
def configuracion
@config || cargar_configuracion
end
# Registra todos los triggers configurados en el bus de eventos
#
# @return [Integer] Número de triggers registrados
def registrar_triggers
config = configuracion
return 0 unless config['configuracion'] && config['configuracion']['sistema_habilitado']
@mutex.synchronize do
limpiar_triggers_registrados
total_registrados = 0
# Registrar triggers de cada categoría
%w[bitacoras nodos proyectos adn].each do |categoria|
next unless config[categoria]
config[categoria].each do |trigger_def|
next unless trigger_def['habilitado']
registrar_trigger_individual(trigger_def, categoria)
total_registrados += 1
end
end
@logger.exito("#{total_registrados} trigger(s) registrado(s) en el bus de eventos")
total_registrados
end
end
# Ejecuta todos los triggers registrados (para modo batch/cron)
#
# @param opciones [Hash] Opciones de ejecución
# @option opciones [Boolean] :forzar Ejecutar incluso si sistema deshabilitado
# @option opciones [Array<String>] :categorias Categorías específicas a ejecutar
# @return [Hash] Resultados de la ejecución
def ejecutar_triggers(opciones = {})
resultados = {
timestamp: Time.now.utc,
total_ejecutados: 0,
exitosos: 0,
fallidos: 0,
detalles: []
}
config = configuracion
# Verificar si el sistema está habilitado
unless config['configuracion'] && (config['configuracion']['sistema_habilitado'] || opciones[:forzar])
@logger.advertencia('Sistema de triggers deshabilitado (usar --forzar para ejecutar de todos modos)')
return resultados
end
categorias = opciones[:categorias] || %w[bitacoras nodos proyectos adn]
categorias.each do |categoria|
next unless config[categoria]
config[categoria].each do |trigger_def|
next unless trigger_def['habilitado']
resultado = ejecutar_trigger_individual(trigger_def, categoria, opciones)
resultados[:detalles] << resultado
resultados[:total_ejecutados] += 1
resultados[resultado[:exito] ? :exitosos : :fallidos] += 1
end
end
resultados
end
# Prueba un trigger específico sin afectar el sistema
#
# @param identificador [String] Identificador del trigger a probar
# @param datos_simulados [Hash] Datos para simular el evento
# @return [Hash] Resultado de la prueba
def probar_trigger(identificador, datos_simulados = {})
trigger = buscar_trigger_por_identificador(identificador)
unless trigger
@logger.error("Trigger no encontrado: #{identificador}")
return { exito: false, error: "Trigger no encontrado: #{identificador}" }
end
@logger.info("Probando trigger: #{identificador}")
resultado = {
trigger: identificador,
timestamp: Time.now.utc,
datos_simulados: datos_simulados
}
begin
# Simular la ejecución sin efectos reales
accion_resultado = simular_accion(trigger['accion'], trigger['categoria'], datos_simulados)
resultado.merge!(
exito: true,
accion_simulada: accion_resultado,
mensaje: "Trigger probado exitosamente (modo simulación)"
)
rescue => e
resultado.merge!(
exito: false,
error: e.message,
backtrace: e.backtrace.take(5)
)
@logger.error("Error probando trigger #{identificador}: #{e.message}")
end
resultado
end
# Obtiene información sobre todos los triggers configurados
#
# @return [Array<Hash>] Lista de triggers con información detallada
def lista_triggers
config = configuracion
triggers = []
%w[bitacoras nodos proyectos adn].each do |categoria|
next unless config[categoria]
config[categoria].each_with_index do |trigger_def, index|
triggers << {
id: "#{categoria}_#{index}",
categoria: categoria,
evento: trigger_def['evento'],
condicion: trigger_def['condicion'],
accion: trigger_def['accion'],
descripcion: trigger_def['descripcion'],
habilitado: trigger_def['habilitado'],
suscriptores_registrados: @suscriptores_registrados["#{categoria}_#{index}"] || 0
}
end
end
triggers
end
# Limpia todos los triggers registrados del bus de eventos
#
# @return [Integer] Número de triggers limpiados
def limpiar_triggers_registrados
total = 0
@suscriptores_registrados.each do |trigger_id, _|
ADN.eventos.eliminar_suscriptor(trigger_id, "trigger_#{trigger_id}")
total += 1
end
@suscriptores_registrados.clear
@logger.debug("#{total} trigger(s) limpiados del bus de eventos")
total
end
private
# Registra un trigger individual en el bus de eventos
#
# @param trigger_def [Hash] Definición del trigger
# @param categoria [String] Categoría del trigger
# @param index [Integer] Índice dentro de la categoría
def registrar_trigger_individual(trigger_def, categoria, index = nil)
evento = trigger_def['evento']
condicion = trigger_def['condicion']
accion = trigger_def['accion']
descripcion = trigger_def['descripcion']
trigger_id = index ? "#{categoria}_#{index}" : "#{categoria}_#{Time.now.to_i}"
# Registrar en el bus de eventos
suscriptor_id = ADN.eventos.suscriptor(evento, "trigger_#{trigger_id}") do |datos|
ejecutar_accion_si_cumple_condicion(accion, condicion, datos, trigger_id)
end
@suscriptores_registrados[trigger_id] = {
evento: evento,
condicion: condicion,
accion: accion,
descripcion: descripcion,
suscriptor_id: suscriptor_id
}
@logger.debug("Trigger registrado: #{trigger_id} (#{descripcion})")
end
# Ejecuta una acción si se cumple la condición
#
# @param accion [String] Acción a ejecutar
# @param condicion [String] Condición a evaluar
# @param datos [Hash] Datos del evento
# @param trigger_id [String] Identificador del trigger
def ejecutar_accion_si_cumple_condicion(accion, condicion, datos, trigger_id)
# Evaluar condición si existe
if condicion && !condicion.empty?
unless evaluar_condicion(condicion, datos)
@logger.debug("Condición no cumplida para trigger #{trigger_id}")
return
end
end
# Ejecutar acción
ejecutar_accion(accion, datos, trigger_id)
end
# Evalúa una condición contra los datos del evento
#
# @param condicion [String] Expresión de condición
# @param datos [Hash] Datos del evento
# @return [Boolean] true si la condición se cumple
def evaluar_condicion(condicion, datos)
# Implementación básica - en una versión futura podría usar un parser
# Para ahora, solo verifica condiciones simples
case condicion
when /estado_anterior == '([^']+)' and estado_nuevo == '([^']+)'/
estado_anterior_esperado = $1
estado_nuevo_esperado = $2
datos[:estado_anterior] == estado_anterior_esperado &&
datos[:estado_nuevo] == estado_nuevo_esperado
when /estado_nuevo == '([^']+)'/
datos[:estado_nuevo] == $1
when /estado_tema == '([^']+)'/
datos[:estado_tema] == $1
else
# Si no reconocemos la condición, asumimos que se cumple
# (para triggers sin condición explícita)
true
end
end
# Ejecuta una acción específica
#
# @param accion [String] Nombre de la acción
# @param datos [Hash] Datos del evento
# @param trigger_id [String] Identificador del trigger
def ejecutar_accion(accion, datos, trigger_id)
inicio = Time.now
@logger.info("Ejecutando acción #{accion} (trigger: #{trigger_id})")
begin
resultado = case accion
when 'actualizar_detalle_en_proceso'
accion_actualizar_detalle_en_proceso(datos)
when 'actualizar_resumen_integral'
accion_actualizar_resumen_integral(datos)
when 'ejecutar_rollover'
accion_ejecutar_rollover(datos)
when 'extraer_metricas_telemetria'
accion_extraer_metricas_telemetria(datos)
when 'validar_formato_adn'
accion_validar_formato_adn(datos)
when 'actualizar_ontologia'
accion_actualizar_ontologia(datos)
when 'vincular_nodo_a_proyectos'
accion_vincular_nodo_a_proyectos(datos)
when 'actualizar_dashboard_estados'
accion_actualizar_dashboard_estados(datos)
when 'registrar_en_bitacora'
accion_registrar_en_bitacora(datos)
when 'actualizar_referencias_cruzadas'
accion_actualizar_referencias_cruzadas(datos)
when 'generar_reporte_cierre'
accion_generar_reporte_cierre(datos)
when 'verificar_impacto_bitacoras'
accion_verificar_impacto_bitacoras(datos)
when 'actualizar_iconos_obsoletos'
accion_actualizar_iconos_obsoletos(datos)
when 'notificar_y_aplicar_directivas'
accion_notificar_y_aplicar_directivas(datos)
else
@logger.advertencia("Acción desconocida: #{accion}")
{ accion: accion, estado: 'desconocida', mensaje: 'Acción no implementada' }
end
duracion = Time.now - inicio
@logger.exito("Acción #{accion} completada en #{duracion.round(3)}s")
resultado
rescue => e
duracion = Time.now - inicio
@logger.error("Error ejecutando acción #{accion}: #{e.message}", {
trigger_id: trigger_id,
datos: datos,
backtrace: e.backtrace.take(5)
})
{ accion: accion, estado: 'error', error: e.message, duracion: duracion }
end
end
# Simula una acción sin efectos reales (para pruebas)
#
# @param accion [String] Nombre de la acción
# @param categoria [String] Categoría del trigger
# @param datos [Hash] Datos simulados
# @return [Hash] Resultado de la simulación
def simular_accion(accion, categoria, datos)
{
accion: accion,
categoria: categoria,
modo: 'simulacion',
mensaje: "Esta acción se habría ejecutado con los datos: #{datos.keys.join(', ')}",
timestamp: Time.now.utc
}
end
# Busca un trigger por identificador
#
# @param identificador [String] Identificador del trigger
# @return [Hash, nil] Definición del trigger encontrado o nil
def buscar_trigger_por_identificador(identificador)
config = configuracion
%w[bitacoras nodos proyectos adn].each do |categoria|
next unless config[categoria]
config[categoria].each_with_index do |trigger_def, index|
trigger_id = "#{categoria}_#{index}"
return trigger_def.merge('categoria' => categoria, 'id' => trigger_id) if trigger_id == identificador
end
end
nil
end
# Ejecuta un trigger individual
#
# @param trigger_def [Hash] Definición del trigger
# @param categoria [String] Categoría del trigger
# @param opciones [Hash] Opciones de ejecución
# @return [Hash] Resultado de la ejecución
def ejecutar_trigger_individual(trigger_def, categoria, opciones)
inicio = Time.now
trigger_id = "#{categoria}_#{Time.now.to_i}_#{rand(1000)}"
resultado = {
trigger_id: trigger_id,
evento: trigger_def['evento'],
categoria: categoria,
descripcion: trigger_def['descripcion'],
inicio: inicio,
exito: false
}
begin
# Simular datos para ejecución manual
datos_simulados = {
modo: 'manual',
trigger_id: trigger_id,
timestamp: inicio
}
accion_resultado = ejecutar_accion(trigger_def['accion'], datos_simulados, trigger_id)
resultado.merge!(
exito: true,
fin: Time.now,
duracion: Time.now - inicio,
accion_resultado: accion_resultado
)
rescue => e
resultado.merge!(
fin: Time.now,
duracion: Time.now - inicio,
error: e.message,
backtrace: e.backtrace.take(3)
)
end
resultado
end
# Implementaciones de acciones específicas (stubs para Fase 2)
def accion_actualizar_detalle_en_proceso(datos)
{ accion: 'actualizar_detalle_en_proceso', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_resumen_integral(datos)
{ accion: 'actualizar_resumen_integral', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_ejecutar_rollover(datos)
{ accion: 'ejecutar_rollover', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_extraer_metricas_telemetria(datos)
{ accion: 'extraer_metricas_telemetria', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_validar_formato_adn(datos)
{ accion: 'validar_formato_adn', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_ontologia(datos)
{ accion: 'actualizar_ontologia', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_vincular_nodo_a_proyectos(datos)
{ accion: 'vincular_nodo_a_proyectos', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_dashboard_estados(datos)
{ accion: 'actualizar_dashboard_estados', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_registrar_en_bitacora(datos)
{ accion: 'registrar_en_bitacora', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_referencias_cruzadas(datos)
{ accion: 'actualizar_referencias_cruzadas', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_generar_reporte_cierre(datos)
{ accion: 'generar_reporte_cierre', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_verificar_impacto_bitacoras(datos)
{ accion: 'verificar_impacto_bitacoras', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_iconos_obsoletos(datos)
{ accion: 'actualizar_iconos_obsoletos', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_notificar_y_aplicar_directivas(datos)
{ accion: 'notificar_y_aplicar_directivas', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
end
# Acceso global al sistema de triggers
#
# @return [ADN::Triggers] Instancia singleton del sistema de triggers
def self.triggers
Triggers.instance
end
end
# ─── Notas de uso ──────────────────────────────────────────────────────
#
# Ejemplo de registro y uso:
# # Cargar configuración y registrar triggers
# ADN.triggers.cargar_configuracion
# ADN.triggers.registrar_triggers
#
# Ejemplo de ejecución manual:
# resultados = ADN.triggers.ejecutar_triggers(forzar: true)
#
# Ejemplo de prueba de trigger:
# resultado = ADN.triggers.probar_trigger('bitacoras_0', { estado_tema: '⏳' })
#
# El sistema de triggers se integra automáticamente con el bus de eventos.
# Cuando ocurre un evento (ej: bitácora modificada), los triggers registrados
# que escuchan ese evento se ejecutarán automáticamente si se cumplen las condiciones.
#
# ⚠️ ADVERTENCIA: Este módulo está en desarrollo (Fase 2).
# Las acciones específicas se implementarán en iteraciones posteriores.