Refactor(ADN): Migración de tools/adn a adn/tools en busca de la Armonía Integral del DIIAA

This commit is contained in:
Ricardo Monla
2026-03-10 22:25:01 -03:00
parent 174ef7d53c
commit 78006032eb
69 changed files with 432 additions and 671 deletions
+8 -8
View File
@@ -18,7 +18,7 @@ Esta hebra gobierna los protocolos de seguridad de red, gestión de secretos y a
## 2. Gestión de Secretos — `ns8-candados`
Herramienta unificada de seguridad: [`tools/adn/seguridad/ns8-candados.rb`](../tools/adn/seguridad/ns8-candados.rb)
Herramienta unificada de seguridad: [`adn/tools/seguridad/ns8-candados.rb`](../adn/tools/seguridad/ns8-candados.rb)
### 2.1 Prohibición de Texto Plano
La Inteligencia Artificial operante tiene **expresamente prohibido** solicitar contraseñas administrativas, tokens o claves privadas al operador, así como también plasmarlas en logs, comandos literales temporales o en la bitácora.
@@ -27,14 +27,14 @@ La Inteligencia Artificial operante tiene **expresamente prohibido** solicitar c
| Archivo | Ubicación | Git | Descripción |
| :--- | :--- | :---: | :--- |
| `ns8-candados.rb` | `tools/adn/seguridad/` | ✅ | Script Ruby unificado: cifrado, bóveda y autorización SSH. |
| `.boveda.json` | `tools/adn/seguridad/` | ❌ | Payloads AES-256-GCM cifrados. |
| `ns8-candados.rb` | `adn/tools/seguridad/` | ✅ | Script Ruby unificado: cifrado, bóveda y autorización SSH. |
| `.boveda.json` | `adn/tools/seguridad/` | ❌ | Payloads AES-256-GCM cifrados. |
| `.master.key` | Raíz del proyecto | ❌ | Clave maestra de la bóveda (nunca en Git). |
### 2.3 Comandos Disponibles
```bash
ruby tools/adn/seguridad/ns8-candados.rb <comando> [args]
ruby adn/tools/seguridad/ns8-candados.rb <comando> [args]
```
| Comando | Descripción |
@@ -51,7 +51,7 @@ ruby tools/adn/seguridad/ns8-candados.rb <comando> [args]
```bash
# Obtener password para uso en comando remoto
PASS=$(ruby tools/adn/seguridad/ns8-candados.rb get admindasu)
PASS=$(ruby adn/tools/seguridad/ns8-candados.rb get admindasu)
sshpass -p "$PASS" ssh -J root@10.0.10.205 admindasu@10.0.100.12 "cmd"
```
@@ -74,7 +74,7 @@ La llave SSH del operador está protegida con passphrase. El agente SSH no retie
El operador ejecuta el comando **interactivamente** para cargar su identidad SSH:
```bash
ruby tools/adn/seguridad/ns8-candados.rb abrir
ruby adn/tools/seguridad/ns8-candados.rb abrir
```
Las identidades permanecen activas mientras dure la sesión del agente SSH (sin timeout artificial).
@@ -93,7 +93,7 @@ Las identidades permanecen activas mientras dure la sesión del agente SSH (sin
**Uso programático** (ej: desde ns8-bkps):
```bash
# 1. Cargar llave SSH
ruby tools/adn/seguridad/ns8-candados.rb abrir
ruby adn/tools/seguridad/ns8-candados.rb abrir
# 2. Acceder a hipervisor
ssh root@10.0.10.201 "qm list"
@@ -106,7 +106,7 @@ ssh rmonla@10.0.10.4 "sudo shutdown -h now"
Los siguientes artefactos **nunca** deben ser rastreados por Git:
- `.master.key` (clave maestra de la bóveda)
- `tools/adn/seguridad/.boveda.json` (payloads cifrados)
- `adn/tools/seguridad/.boveda.json` (payloads cifrados)
- `.agent_token` (token legacy, obsoleto)
- `*.key` (claves privadas)
- `tmp/` (artefactos transitorios de la IA)
+2 -2
View File
@@ -50,9 +50,9 @@ Antes de iniciar escaneos, pruebas de red, o conexiones SSH a un nodo de un proy
- **Objetivo:** Identificar si el nodo objetivo está en una subred aislada, detrás de un NAT, o si requiere un salto de red (ProxyJump) a través de un hypervisor anfitrión, previniendo errores ciegos y garantizando trazabilidad geográfica.
### 14. Protocolo Autónomo de Ejecución (P.A.E.)
Para tareas operativas complejas (ej. backups), la IA **DEBE** utilizar las herramientas unificadas (`tools/adn/run`) que ya integran la lógica de seguridad y registro:
Para tareas operativas complejas (ej. backups), la IA **DEBE** utilizar las herramientas unificadas (`adn/tools/run`) que ya integran la lógica de seguridad y registro:
1. **Ejecución**: Usar `./tools/adn/run backup [nodo]`.
1. **Ejecución**: Usar `./adn/tools/run backup [nodo]`.
2. **Seguridad**: La herramienta utilizará internamente `ns8-candados run` para no exponer secretos.
3. **Bitácora**: El registro en `docs/bitacoras/YYYY-MM-DD.md` es automático mediante la herramienta.
4. **Validación**: La herramienta valida el contexto técnico (VMID, Host IP) consultando las fichas del ADN.
+3 -3
View File
@@ -21,14 +21,14 @@ El directorio `adn/` constituye el "núcleo genético" del servidor `srv-ns8`. N
## ⚙️ Configuración y Dependencias
- **Tecnologías Clave**:
- **Motor de ejecución**: Ruby (utilizado para scripts de automatización como `tools/adn/run`).
- **Motor de ejecución**: Ruby (utilizado para scripts de automatización como `adn/tools/run`).
- **Formatos**: Markdown para documentación y YAML para reglas de automatización.
- **Entorno**: Dockerizado para servicios de red (Nginx, Homepage, Bitácoras).
- **Configuración Relevante**:
- `triggers.yml`: Define qué acciones disparar ante eventos específicos (ej. cierre de tareas, creación de nuevas bitácoras).
- **Comandos Necesarios**:
- `./tools/adn/run triggers`: Procesa y sincroniza los cambios basados en las reglas del ADN.
- `ruby tools/adn/seguridad/ns8-candados.rb`: Gestiona la seguridad y secretos del servidor.
- `./adn/tools/run triggers`: Procesa y sincroniza los cambios basados en las reglas del ADN.
- `ruby adn/tools/seguridad/ns8-candados.rb`: Gestiona la seguridad y secretos del servidor.
## 🧠 Contexto para IA
- **Patrones y Convenciones**: Se debe mantener estrictamente la iconografía semántica (`04_iconografia.md`) y seguir las directivas de la Hebra 05. Ningún cambio debe romper la "Armonía Integral" definida en el índice.
+3 -3
View File
@@ -26,8 +26,8 @@ La persistencia de nuestras bitácoras vive dentro de un contenedor dedicado.
docker ps | grep postgres
```
## 3. Configuración de Credenciales CLI (`tools/adn/config/database.yml`)
Las herramientas ADN alojadas en `tools/adn/run` delegaron su configuración al archivo maestro que se sitúa en `tools/adn/config/database.yml`.
## 3. Configuración de Credenciales CLI (`adn/tools/config/database.yml`)
Las herramientas ADN alojadas en `adn/tools/run` delegaron su configuración al archivo maestro que se sitúa en `adn/tools/config/database.yml`.
> [!IMPORTANT]
> El entorno de `development` se define por defecto e interactúa en el puerto `5433` usando credenciales locales (`dtic_user` / `dtic_pass_2026`).
@@ -48,7 +48,7 @@ Inmediatamente, la CLI redirigirá los accesos al nodo principal configurado.
La mejor forma de testear que todo se ha inicializado y configurado correctamente es utilizando el Reporte de Salud de la BD proporcionado por la consola ADN:
```bash
./tools/adn/run db salud:bd
./adn/tools/run db salud:bd
```
Si recibes el siguiente dictamen, el sistema funciona correctamente:
`✅ Conexión a PostgreSQL establecida`
+3
View File
@@ -0,0 +1,3 @@
---
BUNDLE_PATH: "vendor/bundle"
BUNDLE_WITHOUT: "development:test:future"
+76
View File
@@ -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
+139
View File
@@ -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
+74
View File
@@ -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`.*
+110
View File
@@ -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
+651
View File
@@ -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
+58
View File
@@ -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
+177
View File
@@ -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
+139
View File
@@ -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}"
# 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
+166
View File
@@ -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
+112
View File
@@ -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
+260
View File
@@ -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
+214
View File
@@ -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
+247
View File
@@ -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
+139
View File
@@ -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
+230
View File
@@ -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
+61
View File
@@ -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
+507
View File
@@ -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
+121
View File
@@ -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
+304
View File
@@ -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
+128
View File
@@ -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
+108
View File
@@ -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
+72
View File
@@ -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' }"
+16
View File
@@ -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
+207
View File
@@ -0,0 +1,207 @@
# frozen_string_literal: true
require 'date'
require 'time'
require 'json'
require_relative 'configurador'
module ADN
class Conciliador
def initialize(logger)
@logger = logger
@candados_path = File.join(ADN::PROJECT_ROOT, 'tools', 'ns8-candados', 'ns8-candados.rb')
end
def ejecutar(silencioso: false)
@silencioso = silencioso
@logger.info("Iniciando saneamiento preventivo de pendientes...") unless @silencioso
# 1. Reconciliar backups de ayer y hoy
conciliar_backups_recientes
# 2. Traspasar pendientes no resueltos de ayer a hoy
traspasar_pendientes_ayer_a_hoy
end
private
def conciliar_backups_recientes
# Revisamos ayer y hoy
[Date.today - 1, Date.today].each do |fecha_obj|
fecha = fecha_obj.iso8601
ruta = File.join(ADN::BITACORAS_DIR, "#{fecha}.md")
next unless File.exist?(ruta)
# Si el archivo tiene relojes de arena (⏳)
contenido = File.read(ruta)
if contenido.include?('⏳')
@logger.info(" Revisando tareas pendientes del #{fecha}...") unless @silencioso
procesar_reconciliacion(fecha, ruta)
end
end
end
def procesar_reconciliacion(fecha, ruta)
# Obtener tareas de Proxmox
tasks = obtener_tareas_proxmox
return if tasks.empty?
vmid_map = cargar_mapa_vmid_nodos
contenido = File.read(ruta)
lineas = contenido.lines
nodo_actual = nil
cambio = false
lineas.each do |linea|
if linea.start_with?('### ') && !linea.include?('|')
nodo_actual = linea.sub('### ', '').strip
next
end
if nodo_actual && linea.include?('|') && linea.include?('⏳')
match_hora = linea.match(/\|\s*(\d{2}:\d{2})\s*\|/)
next unless match_hora
hora_inicio_str = match_hora[1]
vmid = vmid_map.key(nodo_actual)
next unless vmid
# Buscar tarea coincidente
hora_inicio_t = Time.parse("#{fecha} #{hora_inicio_str}") rescue nil
tarea = tasks.select { |t|
vmid_task = (t['vmid'] || t['id']).to_s
t['type'] == 'vzdump' &&
vmid_task == vmid &&
(t['status'] == 'OK' || !t['endtime'].nil?) &&
(hora_inicio_t ? Time.at(t['starttime']) >= (hora_inicio_t - 3600) : true)
}.max_by { |t| t['endtime'] || 0 }
if tarea && tarea['endtime']
exito = (tarea['status'] == 'OK')
hora_fin = Time.at(tarea['endtime']).strftime('%H:%M')
task_id = tarea['id']
@logger.exito(" ✨ Tarea auto-resuelta: #{nodo_actual} (#{hora_inicio_str} -> #{hora_fin})") unless @silencioso
# Aplicar cambio en el contenido
estado_ico = exito ? '✅' : '⚠️'
desc = exito ? 'Backup completado exitosamente.' : 'Fallo en la ejecución del backup.'
desc = "[task:#{task_id[0..7]}] #{desc}"
linea_nueva = "| #{hora_inicio_str} | #{hora_fin} | #{desc} | R | #{estado_ico} |\n"
# Reemplazo cuidadoso
regex_fila = /^\|\s*#{Regexp.escape(hora_inicio_str)}\s*\|\s*\|\s*[^|]*\|\s*[^|]*\|\s*[^|]*⏳[^|]*\|/
# Buscar el bloque del nodo
regex_seccion = /(### #{Regexp.escape(nodo_actual)}.*?)(?=\n###\s|<!--|\z)/m
contenido.sub!(regex_seccion) do |seccion|
nueva_seccion = seccion.sub(regex_fila, linea_nueva)
# Si ya no quedan ⏳ en esta sección, actualizamos su icono principal
unless nueva_seccion.include?('⏳')
nueva_seccion.gsub!(/#### [⏳⏳] - BKP-ADN/, "#### #{estado_ico} - BKP-ADN")
end
nueva_seccion
end
cambio = true
end
end
end
if cambio
File.write(ruta, contenido)
# Sincronizar con DB
system("#{File.join(ADN::PROJECT_ROOT, '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
+50
View File
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require 'yaml'
require 'fileutils'
module ADN
class Configurador
CONFIG_PATH = File.join(__dir__, '..', 'config', 'config.yml')
def self.leer
return {} unless File.exist?(CONFIG_PATH)
YAML.load_file(CONFIG_PATH) || {}
rescue StandardError => e
warn "Error leyendo configuración: #{e.message}"
{}
end
def self.guardar(config)
FileUtils.mkdir_p(File.dirname(CONFIG_PATH))
File.write(CONFIG_PATH, config.to_yaml)
true
rescue StandardError => e
warn "Error guardando configuración: #{e.message}"
false
end
def self.obtener(ruta_punto, default = nil)
config = leer
partes = ruta_punto.split('.')
valor = config
partes.each do |p|
valor = valor[p] if valor.is_a?(Hash)
return default if valor.nil?
end
valor
end
def self.establecer(ruta_punto, nuevo_valor)
config = leer
partes = ruta_punto.split('.')
temp = config
partes[0...-1].each do |p|
temp[p] ||= {}
temp = temp[p]
end
temp[partes.last] = nuevo_valor
guardar(config)
end
end
end
+340
View File
@@ -0,0 +1,340 @@
# frozen_string_literal: true
# adn/tools/eventos.rb — Bus de eventos interno para el sistema ADN
# ==================================================================
# Este módulo implementa un bus de eventos que permite la comunicación
# desacoplada entre los diferentes componentes del sistema ADN.
# Facilita la implementación del Principio de Armonía Integral al
# permitir que cambios en un componente disparen acciones automáticas
# en otros componentes dependientes.
#
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
# 📚 Referencia: docs/plan/260307-1400_Plan_Mejoras_ADN.md
# 🔗 Integración: adn/triggers.yml (configuración de triggers)
require 'singleton'
module ADN
class Eventos
include Singleton
# Eventos predefinidos del sistema ADN
# Estos eventos corresponden a los triggers definidos en adn/triggers.yml
EVENTOS = {
# Bitácoras
bitacora_creada: :bitacora_creada,
bitacora_modificada: :bitacora_modificada,
bitacora_guardada: :bitacora_guardada,
nueva_vinyeta_cronologia: :nueva_vinyeta_cronologia,
estado_cambiado: :estado_cambiado,
metrica_hibrida_registrada: :metrica_hibrida_registrada,
error_formato_detectado: :error_formato_detectado,
# Nodos
nodo_creado: :nodo_creado,
nodo_modificado: :nodo_modificado,
estado_nodo_cambiado: :estado_nodo_cambiado,
# Proyectos
proyecto_creado: :proyecto_creado,
proyecto_modificado: :proyecto_modificado,
hito_proyecto_actualizado: :hito_proyecto_actualizado,
estado_proyecto_cambiado: :estado_proyecto_cambiado,
# ADN y configuración
hebra_adn_modificada: :hebra_adn_modificada,
iconografia_actualizada: :iconografia_actualizada,
directivas_ia_actualizadas: :directivas_ia_actualizadas,
configuracion_actualizada: :configuracion_actualizada,
# Sistema
sistema_iniciado: :sistema_iniciado,
validacion_completada: :validacion_completada,
error_sistema: :error_sistema,
advertencia_sistema: :advertencia_sistema
}.freeze
# Inicializa el bus de eventos
def initialize
@suscriptores = Hash.new { |hash, key| hash[key] = [] }
@logger = ADN.logger
@mutex = Mutex.new
@logger.debug('Bus de eventos ADN inicializado')
end
# Registra un suscriptor para un tipo de evento
#
# @param evento [Symbol, String] Tipo de evento al que suscribirse
# @param bloque [Proc] Bloque a ejecutar cuando se publique el evento
# @param identificador [String] Identificador opcional para el suscriptor
# @return [String] Identificador del suscriptor
def suscriptor(evento, identificador = nil, &bloque)
evento_normalizado = normalizar_evento(evento)
@mutex.synchronize do
id = identificador || "suscriptor_#{@suscriptores[evento_normalizado].length + 1}"
@suscriptores[evento_normalizado] << { id: id, bloque: bloque }
@logger.debug("Suscriptor registrado: #{id} para evento #{evento_normalizado}")
id
end
end
# Publica un evento a todos los suscriptores registrados
#
# @param evento [Symbol, String] Tipo de evento a publicar
# @param datos [Hash] Datos asociados al evento
# @param opciones [Hash] Opciones de publicación
# @option opciones [Boolean] :async Ejecutar suscriptores en hilos separados
# @option opciones [Boolean] :silencioso No loggear la publicación
# @return [Array<Hash>] Resultados de la ejecución de suscriptores
def publicar(evento, datos = {}, opciones = {})
evento_normalizado = normalizar_evento(evento)
suscriptores = @suscriptores[evento_normalizado].dup
return [] if suscriptores.empty?
@logger.debug("Publicando evento #{evento_normalizado} a #{suscriptores.length} suscriptor(es)", datos) unless opciones[:silencioso]
resultados = []
suscriptores.each do |suscriptor|
resultado = ejecutar_suscriptor(suscriptor, evento_normalizado, datos, opciones)
resultados << resultado if resultado
end
resultados
end
# Elimina un suscriptor específico
#
# @param evento [Symbol, String] Tipo de evento
# @param identificador [String] Identificador del suscriptor a eliminar
# @return [Boolean] true si se eliminó, false si no se encontró
def eliminar_suscriptor(evento, identificador)
evento_normalizado = normalizar_evento(evento)
@mutex.synchronize do
inicial = @suscriptores[evento_normalizado].length
@suscriptores[evento_normalizado].reject! { |s| s[:id] == identificador }
eliminados = inicial - @suscriptores[evento_normalizado].length
if eliminados > 0
@logger.debug("Suscriptor eliminado: #{identificador} del evento #{evento_normalizado}")
true
else
@logger.advertencia("Suscriptor no encontrado: #{identificador} para evento #{evento_normalizado}")
false
end
end
end
# Elimina todos los suscriptores de un evento
#
# @param evento [Symbol, String] Tipo de evento
# @return [Integer] Número de suscriptores eliminados
def limpiar_suscriptores(evento = nil)
@mutex.synchronize do
if evento.nil?
total = @suscriptores.values.flatten.length
@suscriptores.clear
@logger.debug("Todos los suscriptores eliminados (#{total} total)")
total
else
evento_normalizado = normalizar_evento(evento)
eliminados = @suscriptores[evento_normalizado].length
@suscriptores.delete(evento_normalizado)
@logger.debug("#{eliminados} suscriptor(es) eliminados del evento #{evento_normalizado}")
eliminados
end
end
end
# Lista todos los eventos con suscriptores
#
# @return [Hash] Mapa de evento -> número de suscriptores
def lista_suscriptores
resultado = {}
@mutex.synchronize do
@suscriptores.each do |evento, suscriptores|
resultado[evento] = suscriptores.length
end
end
resultado
end
# Verifica si hay suscriptores para un evento
#
# @param evento [Symbol, String] Tipo de evento
# @return [Boolean] true si hay suscriptores
def tiene_suscriptores?(evento)
evento_normalizado = normalizar_evento(evento)
!@suscriptores[evento_normalizado].empty?
end
# Métodos de conveniencia para eventos comunes
# Publica un evento de archivo modificado
#
# @param ruta [String] Ruta del archivo modificado
# @param tipo [String] Tipo de archivo (bitacora, nodo, proyecto, hebra)
# @param accion [String] Acción (creado, modificado, eliminado)
# @param datos_extra [Hash] Datos adicionales
def archivo_modificado(ruta, tipo, accion, datos_extra = {})
datos = {
ruta: ruta,
tipo: tipo,
accion: accion,
timestamp: Time.now.utc
}.merge(datos_extra)
evento = case tipo
when 'bitacora' then :bitacora_modificada
when 'nodo' then :nodo_modificado
when 'proyecto' then :proyecto_modificado
when 'hebra' then :hebra_adn_modificada
else :archivo_modificado
end
publicar(evento, datos)
end
# Publica un evento de estado cambiado
#
# @param entidad [String] Tipo de entidad (nodo, proyecto, tarea)
# @param nombre [String] Nombre de la entidad
# @param estado_anterior [String] Estado anterior
# @param estado_nuevo [String] Estado nuevo
# @param datos_extra [Hash] Datos adicionales
def estado_cambiado(entidad, nombre, estado_anterior, estado_nuevo, datos_extra = {})
datos = {
entidad: entidad,
nombre: nombre,
estado_anterior: estado_anterior,
estado_nuevo: estado_nuevo,
timestamp: Time.now.utc
}.merge(datos_extra)
publicar(:estado_cambiado, datos)
end
# Publica un evento de error detectado
#
# @param tipo [String] Tipo de error (formato, validacion, sistema)
# @param ubicacion [String] Ubicación del error (archivo, módulo)
# @param mensaje [String] Mensaje descriptivo del error
# @param severidad [String] Severidad (baja, media, alta, critica)
# @param datos_extra [Hash] Datos adicionales
def error_detectado(tipo, ubicacion, mensaje, severidad = 'media', datos_extra = {})
datos = {
tipo: tipo,
ubicacion: ubicacion,
mensaje: mensaje,
severidad: severidad,
timestamp: Time.now.utc
}.merge(datos_extra)
publicar(:error_formato_detectado, datos)
end
# Publica un evento de validación completada
#
# @param tipo [String] Tipo de validación (bitacoras, nodos, proyectos, completa)
# @param resultados [Hash] Resultados de la validación
def validacion_completada(tipo, resultados)
datos = {
tipo: tipo,
resultados: resultados,
timestamp: Time.now.utc
}
publicar(:validacion_completada, datos)
end
private
# Normaliza un evento a símbolo
#
# @param evento [Symbol, String] Evento a normalizar
# @return [Symbol] Evento normalizado
def normalizar_evento(evento)
evento.to_s.to_sym
end
# Ejecuta un suscriptor individual con manejo de errores
#
# @param suscriptor [Hash] Información del suscriptor
# @param evento [Symbol] Evento que se está publicando
# @param datos [Hash] Datos del evento
# @param opciones [Hash] Opciones de publicación
# @return [Hash] Resultado de la ejecución
def ejecutar_suscriptor(suscriptor, evento, datos, opciones)
id = suscriptor[:id]
bloque = suscriptor[:bloque]
inicio = Time.now
begin
resultado = bloque.call(datos.dup.freeze)
duracion = Time.now - inicio
{
suscriptor: id,
evento: evento,
exito: true,
duracion: duracion,
resultado: resultado,
timestamp: inicio
}
rescue => e
duracion = Time.now - inicio
@logger.error("Error en suscriptor #{id} para evento #{evento}: #{e.message}", {
backtrace: e.backtrace.take(5),
evento: evento,
datos: datos
})
{
suscriptor: id,
evento: evento,
exito: false,
duracion: duracion,
error: e.message,
backtrace: e.backtrace.take(5),
timestamp: inicio
}
end
end
end
# Acceso global al bus de eventos
#
# @return [ADN::Eventos] Instancia singleton del bus de eventos
def self.eventos
Eventos.instance
end
end
# ─── Notas de uso ──────────────────────────────────────────────────────
#
# Ejemplo de suscripción:
# ADN.eventos.suscriptor(:bitacora_creada) do |datos|
# puts "Nueva bitácora creada: #{datos[:ruta]}"
# end
#
# Ejemplo de publicación:
# ADN.eventos.publicar(:bitacora_creada, {
# ruta: 'bitacoras/2026-03-07.md',
# fecha: Date.today
# })
#
# Ejemplo de publicación con método de conveniencia:
# ADN.eventos.archivo_modificado(
# 'bitacoras/2026-03-07.md',
# 'bitacora',
# 'creado'
# )
#
# El bus de eventos es especialmente útil para:
# - Implementar triggers basados en eventos
# - Desacoplar componentes del sistema
# - Facilitar la extensibilidad mediante plugins
# - Loggear actividades del sistema de forma centralizada
#
+228
View File
@@ -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
+525
View File
@@ -0,0 +1,525 @@
# frozen_string_literal: true
# adn/tools/triggers.rb — Sistema de triggers para sincronización automática del ADN
# =============================================================================
# Este módulo implementa el sistema de triggers definido en adn/triggers.yml.
# Los triggers implementan el Principio de Armonía Integral al propagar
# automáticamente cambios entre diferentes componentes del sistema ADN.
#
# 📅 Última actualización: 2026-03-07 (Fase 2: Automatización Inteligente)
# 📚 Referencia: adn/triggers.yml (configuración)
# 📚 Plan: docs/plan/260307-1400_Plan_Mejoras_ADN.md
# 🔗 Integración: adn/tools/eventos.rb (bus de eventos)
require 'yaml'
require 'singleton'
require_relative 'eventos'
module ADN
class Triggers
include Singleton
# Inicializa el sistema de triggers
def initialize
@config_path = File.join(ADN::PROJECT_ROOT, 'adn', 'triggers.yml')
@config = nil
@suscriptores_registrados = {}
@logger = ADN.logger
@mutex = Mutex.new
@logger.debug('Sistema de triggers ADN inicializado')
end
# Carga la configuración de triggers desde el archivo YAML
#
# @return [Hash] Configuración cargada
# @raise [Errno::ENOENT] Si el archivo no existe
# @raise [Psych::SyntaxError] Si el YAML es inválido
def cargar_configuracion
unless File.exist?(@config_path)
@logger.advertencia("Archivo de configuración de triggers no encontrado: #{@config_path}")
return {}
end
begin
@config = YAML.load_file(@config_path, permitted_classes: [Date, Time, Symbol])
@logger.info("Configuración de triggers cargada desde #{@config_path}")
@config
rescue Psych::SyntaxError => e
@logger.error("Error de sintaxis YAML en #{@config_path}: #{e.message}")
raise
rescue => e
@logger.error("Error cargando configuración de triggers: #{e.message}")
raise
end
end
# Obtiene la configuración actual (carga si es necesario)
#
# @return [Hash] Configuración actual
def configuracion
@config || cargar_configuracion
end
# Registra todos los triggers configurados en el bus de eventos
#
# @return [Integer] Número de triggers registrados
def registrar_triggers
config = configuracion
return 0 unless config['configuracion'] && config['configuracion']['sistema_habilitado']
@mutex.synchronize do
limpiar_triggers_registrados
total_registrados = 0
# Registrar triggers de cada categoría
%w[bitacoras nodos proyectos adn].each do |categoria|
next unless config[categoria]
config[categoria].each do |trigger_def|
next unless trigger_def['habilitado']
registrar_trigger_individual(trigger_def, categoria)
total_registrados += 1
end
end
@logger.exito("#{total_registrados} trigger(s) registrado(s) en el bus de eventos")
total_registrados
end
end
# Ejecuta todos los triggers registrados (para modo batch/cron)
#
# @param opciones [Hash] Opciones de ejecución
# @option opciones [Boolean] :forzar Ejecutar incluso si sistema deshabilitado
# @option opciones [Array<String>] :categorias Categorías específicas a ejecutar
# @return [Hash] Resultados de la ejecución
def ejecutar_triggers(opciones = {})
resultados = {
timestamp: Time.now.utc,
total_ejecutados: 0,
exitosos: 0,
fallidos: 0,
detalles: []
}
config = configuracion
# Verificar si el sistema está habilitado
unless config['configuracion'] && (config['configuracion']['sistema_habilitado'] || opciones[:forzar])
@logger.advertencia('Sistema de triggers deshabilitado (usar --forzar para ejecutar de todos modos)')
return resultados
end
categorias = opciones[:categorias] || %w[bitacoras nodos proyectos adn]
categorias.each do |categoria|
next unless config[categoria]
config[categoria].each do |trigger_def|
next unless trigger_def['habilitado']
resultado = ejecutar_trigger_individual(trigger_def, categoria, opciones)
resultados[:detalles] << resultado
resultados[:total_ejecutados] += 1
resultados[resultado[:exito] ? :exitosos : :fallidos] += 1
end
end
resultados
end
# Prueba un trigger específico sin afectar el sistema
#
# @param identificador [String] Identificador del trigger a probar
# @param datos_simulados [Hash] Datos para simular el evento
# @return [Hash] Resultado de la prueba
def probar_trigger(identificador, datos_simulados = {})
trigger = buscar_trigger_por_identificador(identificador)
unless trigger
@logger.error("Trigger no encontrado: #{identificador}")
return { exito: false, error: "Trigger no encontrado: #{identificador}" }
end
@logger.info("Probando trigger: #{identificador}")
resultado = {
trigger: identificador,
timestamp: Time.now.utc,
datos_simulados: datos_simulados
}
begin
# Simular la ejecución sin efectos reales
accion_resultado = simular_accion(trigger['accion'], trigger['categoria'], datos_simulados)
resultado.merge!(
exito: true,
accion_simulada: accion_resultado,
mensaje: "Trigger probado exitosamente (modo simulación)"
)
rescue => e
resultado.merge!(
exito: false,
error: e.message,
backtrace: e.backtrace.take(5)
)
@logger.error("Error probando trigger #{identificador}: #{e.message}")
end
resultado
end
# Obtiene información sobre todos los triggers configurados
#
# @return [Array<Hash>] Lista de triggers con información detallada
def lista_triggers
config = configuracion
triggers = []
%w[bitacoras nodos proyectos adn].each do |categoria|
next unless config[categoria]
config[categoria].each_with_index do |trigger_def, index|
triggers << {
id: "#{categoria}_#{index}",
categoria: categoria,
evento: trigger_def['evento'],
condicion: trigger_def['condicion'],
accion: trigger_def['accion'],
descripcion: trigger_def['descripcion'],
habilitado: trigger_def['habilitado'],
suscriptores_registrados: @suscriptores_registrados["#{categoria}_#{index}"] || 0
}
end
end
triggers
end
# Limpia todos los triggers registrados del bus de eventos
#
# @return [Integer] Número de triggers limpiados
def limpiar_triggers_registrados
total = 0
@suscriptores_registrados.each do |trigger_id, _|
ADN.eventos.eliminar_suscriptor(trigger_id, "trigger_#{trigger_id}")
total += 1
end
@suscriptores_registrados.clear
@logger.debug("#{total} trigger(s) limpiados del bus de eventos")
total
end
private
# Registra un trigger individual en el bus de eventos
#
# @param trigger_def [Hash] Definición del trigger
# @param categoria [String] Categoría del trigger
# @param index [Integer] Índice dentro de la categoría
def registrar_trigger_individual(trigger_def, categoria, index = nil)
evento = trigger_def['evento']
condicion = trigger_def['condicion']
accion = trigger_def['accion']
descripcion = trigger_def['descripcion']
trigger_id = index ? "#{categoria}_#{index}" : "#{categoria}_#{Time.now.to_i}"
# Registrar en el bus de eventos
suscriptor_id = ADN.eventos.suscriptor(evento, "trigger_#{trigger_id}") do |datos|
ejecutar_accion_si_cumple_condicion(accion, condicion, datos, trigger_id)
end
@suscriptores_registrados[trigger_id] = {
evento: evento,
condicion: condicion,
accion: accion,
descripcion: descripcion,
suscriptor_id: suscriptor_id
}
@logger.debug("Trigger registrado: #{trigger_id} (#{descripcion})")
end
# Ejecuta una acción si se cumple la condición
#
# @param accion [String] Acción a ejecutar
# @param condicion [String] Condición a evaluar
# @param datos [Hash] Datos del evento
# @param trigger_id [String] Identificador del trigger
def ejecutar_accion_si_cumple_condicion(accion, condicion, datos, trigger_id)
# Evaluar condición si existe
if condicion && !condicion.empty?
unless evaluar_condicion(condicion, datos)
@logger.debug("Condición no cumplida para trigger #{trigger_id}")
return
end
end
# Ejecutar acción
ejecutar_accion(accion, datos, trigger_id)
end
# Evalúa una condición contra los datos del evento
#
# @param condicion [String] Expresión de condición
# @param datos [Hash] Datos del evento
# @return [Boolean] true si la condición se cumple
def evaluar_condicion(condicion, datos)
# Implementación básica - en una versión futura podría usar un parser
# Para ahora, solo verifica condiciones simples
case condicion
when /estado_anterior == '([^']+)' and estado_nuevo == '([^']+)'/
estado_anterior_esperado = $1
estado_nuevo_esperado = $2
datos[:estado_anterior] == estado_anterior_esperado &&
datos[:estado_nuevo] == estado_nuevo_esperado
when /estado_nuevo == '([^']+)'/
datos[:estado_nuevo] == $1
when /estado_tema == '([^']+)'/
datos[:estado_tema] == $1
else
# Si no reconocemos la condición, asumimos que se cumple
# (para triggers sin condición explícita)
true
end
end
# Ejecuta una acción específica
#
# @param accion [String] Nombre de la acción
# @param datos [Hash] Datos del evento
# @param trigger_id [String] Identificador del trigger
def ejecutar_accion(accion, datos, trigger_id)
inicio = Time.now
@logger.info("Ejecutando acción #{accion} (trigger: #{trigger_id})")
begin
resultado = case accion
when 'actualizar_detalle_en_proceso'
accion_actualizar_detalle_en_proceso(datos)
when 'actualizar_resumen_integral'
accion_actualizar_resumen_integral(datos)
when 'ejecutar_rollover'
accion_ejecutar_rollover(datos)
when 'extraer_metricas_telemetria'
accion_extraer_metricas_telemetria(datos)
when 'validar_formato_adn'
accion_validar_formato_adn(datos)
when 'actualizar_ontologia'
accion_actualizar_ontologia(datos)
when 'vincular_nodo_a_proyectos'
accion_vincular_nodo_a_proyectos(datos)
when 'actualizar_dashboard_estados'
accion_actualizar_dashboard_estados(datos)
when 'registrar_en_bitacora'
accion_registrar_en_bitacora(datos)
when 'actualizar_referencias_cruzadas'
accion_actualizar_referencias_cruzadas(datos)
when 'generar_reporte_cierre'
accion_generar_reporte_cierre(datos)
when 'verificar_impacto_bitacoras'
accion_verificar_impacto_bitacoras(datos)
when 'actualizar_iconos_obsoletos'
accion_actualizar_iconos_obsoletos(datos)
when 'notificar_y_aplicar_directivas'
accion_notificar_y_aplicar_directivas(datos)
else
@logger.advertencia("Acción desconocida: #{accion}")
{ accion: accion, estado: 'desconocida', mensaje: 'Acción no implementada' }
end
duracion = Time.now - inicio
@logger.exito("Acción #{accion} completada en #{duracion.round(3)}s")
resultado
rescue => e
duracion = Time.now - inicio
@logger.error("Error ejecutando acción #{accion}: #{e.message}", {
trigger_id: trigger_id,
datos: datos,
backtrace: e.backtrace.take(5)
})
{ accion: accion, estado: 'error', error: e.message, duracion: duracion }
end
end
# Simula una acción sin efectos reales (para pruebas)
#
# @param accion [String] Nombre de la acción
# @param categoria [String] Categoría del trigger
# @param datos [Hash] Datos simulados
# @return [Hash] Resultado de la simulación
def simular_accion(accion, categoria, datos)
{
accion: accion,
categoria: categoria,
modo: 'simulacion',
mensaje: "Esta acción se habría ejecutado con los datos: #{datos.keys.join(', ')}",
timestamp: Time.now.utc
}
end
# Busca un trigger por identificador
#
# @param identificador [String] Identificador del trigger
# @return [Hash, nil] Definición del trigger encontrado o nil
def buscar_trigger_por_identificador(identificador)
config = configuracion
%w[bitacoras nodos proyectos adn].each do |categoria|
next unless config[categoria]
config[categoria].each_with_index do |trigger_def, index|
trigger_id = "#{categoria}_#{index}"
return trigger_def.merge('categoria' => categoria, 'id' => trigger_id) if trigger_id == identificador
end
end
nil
end
# Ejecuta un trigger individual
#
# @param trigger_def [Hash] Definición del trigger
# @param categoria [String] Categoría del trigger
# @param opciones [Hash] Opciones de ejecución
# @return [Hash] Resultado de la ejecución
def ejecutar_trigger_individual(trigger_def, categoria, opciones)
inicio = Time.now
trigger_id = "#{categoria}_#{Time.now.to_i}_#{rand(1000)}"
resultado = {
trigger_id: trigger_id,
evento: trigger_def['evento'],
categoria: categoria,
descripcion: trigger_def['descripcion'],
inicio: inicio,
exito: false
}
begin
# Simular datos para ejecución manual
datos_simulados = {
modo: 'manual',
trigger_id: trigger_id,
timestamp: inicio
}
accion_resultado = ejecutar_accion(trigger_def['accion'], datos_simulados, trigger_id)
resultado.merge!(
exito: true,
fin: Time.now,
duracion: Time.now - inicio,
accion_resultado: accion_resultado
)
rescue => e
resultado.merge!(
fin: Time.now,
duracion: Time.now - inicio,
error: e.message,
backtrace: e.backtrace.take(3)
)
end
resultado
end
# Implementaciones de acciones específicas (stubs para Fase 2)
def accion_actualizar_detalle_en_proceso(datos)
{ accion: 'actualizar_detalle_en_proceso', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_resumen_integral(datos)
{ accion: 'actualizar_resumen_integral', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_ejecutar_rollover(datos)
{ accion: 'ejecutar_rollover', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_extraer_metricas_telemetria(datos)
{ accion: 'extraer_metricas_telemetria', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_validar_formato_adn(datos)
{ accion: 'validar_formato_adn', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_ontologia(datos)
{ accion: 'actualizar_ontologia', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_vincular_nodo_a_proyectos(datos)
{ accion: 'vincular_nodo_a_proyectos', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_dashboard_estados(datos)
{ accion: 'actualizar_dashboard_estados', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_registrar_en_bitacora(datos)
{ accion: 'registrar_en_bitacora', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_referencias_cruzadas(datos)
{ accion: 'actualizar_referencias_cruzadas', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_generar_reporte_cierre(datos)
{ accion: 'generar_reporte_cierre', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_verificar_impacto_bitacoras(datos)
{ accion: 'verificar_impacto_bitacoras', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_actualizar_iconos_obsoletos(datos)
{ accion: 'actualizar_iconos_obsoletos', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
def accion_notificar_y_aplicar_directivas(datos)
{ accion: 'notificar_y_aplicar_directivas', estado: 'stub', mensaje: 'Implementado en Fase 2' }
end
end
# Acceso global al sistema de triggers
#
# @return [ADN::Triggers] Instancia singleton del sistema de triggers
def self.triggers
Triggers.instance
end
end
# ─── Notas de uso ──────────────────────────────────────────────────────
#
# Ejemplo de registro y uso:
# # Cargar configuración y registrar triggers
# ADN.triggers.cargar_configuracion
# ADN.triggers.registrar_triggers
#
# Ejemplo de ejecución manual:
# resultados = ADN.triggers.ejecutar_triggers(forzar: true)
#
# Ejemplo de prueba de trigger:
# resultado = ADN.triggers.probar_trigger('bitacoras_0', { estado_tema: '⏳' })
#
# El sistema de triggers se integra automáticamente con el bus de eventos.
# Cuando ocurre un evento (ej: bitácora modificada), los triggers registrados
# que escuchan ese evento se ejecutarán automáticamente si se cumplen las condiciones.
#
# ⚠️ ADVERTENCIA: Este módulo está en desarrollo (Fase 2).
# Las acciones específicas se implementarán en iteraciones posteriores.
+492
View File
@@ -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
+72
View File
@@ -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
+121
View File
@@ -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
+839
View File
@@ -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
+772
View File
@@ -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
+586
View File
@@ -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
+130
View File
@@ -0,0 +1,130 @@
# frozen_string_literal: true
require 'date'
module BitacorasDB
module Exporters
class MDExporter
def initialize(db, logger = nil)
@db = db
@logger = logger || default_logger
end
def exportar_bitacora(fecha, ruta_destino)
@logger.info("Exportando bitácora #{fecha} a #{ruta_destino}...")
bitacora = @db.find_bitacora_by_fecha(fecha)
unless bitacora
@logger.error("No se encontró la bitácora para la fecha: #{fecha}")
return false
end
bitacora_id = bitacora['id']
# Obtener datos estructurados (incluyendo lo nuevo)
entradas = @db.execute("
SELECT e.*, n.nombre as nodo_nombre
FROM bitacoras.entradas e
LEFT JOIN bitacoras.nodos n ON e.nodo_id = n.id
WHERE e.bitacora_id = $1
ORDER BY n.nombre, e.inicio ASC
", [bitacora_id])
gestion = @db.execute("
SELECT g.*, n.nombre as nodo_nombre
FROM bitacoras.gestion g
LEFT JOIN bitacoras.nodos n ON g.nodo_id = n.id
WHERE g.bitacora_id = $1 OR g.activo = true
ORDER BY g.tipo, g.orden
", [bitacora_id]) rescue [] # Por si la tabla no existe aún en otros entornos
resumenes = @db.execute("
SELECT r.*, n.nombre as nodo_nombre
FROM bitacoras.resumen_nodos r
JOIN bitacoras.nodos n ON r.nodo_id = n.id
WHERE r.bitacora_id = $1
", [bitacora_id]) rescue []
# Generar contenido Markdown
contenido = generar_markdown(fecha, bitacora['resumen'], gestion, resumenes, entradas)
File.write(ruta_destino, contenido)
@logger.info("✅ Exportación completada con éxito.")
true
rescue => e
@logger.error("Error exportando bitácora: #{e.message}")
false
end
private
def generar_markdown(fecha, resumen_gen, gestion, resumenes, entradas)
y, m, d = fecha.to_s.split('-')
md = String.new("<!-- GENERADO AUTOMÁTICAMENTE por adn db exportar:md — No editar manualmente -->\n")
md << "# Bitácora de Operaciones - #{d}/#{m}/#{y}\n\n"
if gestion && gestion.ntuples > 0
md << "## 📋 Control de Gestión\n\n"
pendientes = gestion.select { |g| g['tipo'] == 'pendiente' }
if pendientes.any?
md << "### 📝 Resumen de Actividades\n" # Formato ADN
md << "| NODO | PENDIENTES |\n| :--- | :--- |\n"
pendientes.each do |p|
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
end
md << "\n"
end
en_proceso = gestion.select { |g| g['tipo'] == 'proceso' }
if en_proceso.any?
md << "### ⏳ En Proceso\n"
md << "| NODO | ACTIVIDAD |\n| :--- | :--- |\n"
en_proceso.each do |p|
md << "| #{p['nodo_nombre'] || '—'} | #{p['detalle']} |\n"
end
md << "\n"
end
end
if resumenes && resumenes.ntuples > 0
md << "### 📝 Resumen Integral\n"
md << "| NODO | RESUMEN INTEGRAL |\n| :--- | :--- |\n"
resumenes.each do |r|
md << "| #{r['nodo_nombre']} | #{r['resumen']} |\n"
end
md << "\n"
end
md << "## 📂 Actividades Detalladas\n\n"
por_nodo = entradas.group_by { |e| e['nodo_nombre'] || 'sin-nodo' }
por_nodo.each do |nodo, entries|
md << "### #{nodo}\n\n"
md << "| I | F | Descripción | E |\n"
md << "| :--- | :--- | :--- | :--- |\n"
entries.each do |e|
inicio = e['inicio'] ? e['inicio'][0..4] : '-'
fin = e['fin'] ? e['fin'][0..4] : '-'
ia = e['es_ia'] == 't' ? '(IA) ' : ''
estado = "[#{e['modo']}] #{e['estado']}"
md << "| #{inicio} | #{fin} | #{ia}#{e['descripcion']} | #{estado} |\n"
end
md << "\n"
end
md << "---\n"
md << "<!-- GENERADO AUTOMÁTICAMENTE desde PostgreSQL el #{Time.now.strftime('%Y-%m-%d %H:%M')} -->\n"
md << "<!-- Fuente de verdad: DB. Regenerar con: adn db exportar:md #{fecha} -->\n"
md
end
def default_logger
Class.new do
def info(msg); puts "[INFO] #{msg}"; end
def error(msg); puts "[ERROR] #{msg}"; end
end.new
end
end
end
end
+639
View File
@@ -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
+228
View File
@@ -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
+684
View File
@@ -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
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
{
"sudo": "eyJpdiI6IkgzUnpoNHVGeWVqQUVGSDQiLCJ0YWciOiI3TmhPcjRGVFo3TFZHTUpNREVncXlBPT0iLCJkYXRhIjoiQlptVmtYbGdEcjFYWkxpZ21nPT0ifQ==",
"rsa": "eyJpdiI6IkpoTmpZRWZMeE12eWhXSVMiLCJ0YWciOiJUUnJGY05qRlVoOTl2REplTms0ZU5RPT0iLCJkYXRhIjoiNkFIWCsyYm9ndzZKbGM3c0hRPT0ifQ==",
"domain_admin": "eyJpdiI6IjlYV0JRSVA1MzNLblFycUEiLCJ0YWciOiJ3aHI4a1cwanpLWU5OUmVHby9pd3ZRPT0iLCJkYXRhIjoiT1B3VGNHNkpUSWI0SnJ5NW5adHNVZz09In0=",
"admindasu": "eyJpdiI6IjlYV0JRSVA1MzNLblFycUEiLCJ0YWciOiJ3aHI4a1cwanpLWU5OUmVHby9pd3ZRPT0iLCJkYXRhIjoiT1B3VGNHNkpUSWI0SnJ5NW5adHNVZz09In0=",
"win_domain": "eyJpdiI6IitrY2V0eEQxektEMFk2cGgiLCJ0YWciOiJtQmdIYU90ZTFFWUlGZElsMXBIOTJBPT0iLCJkYXRhIjoiNVVFanhaN2tIdHViZDRDRnlnPT0ifQ==",
"deepseek_key": "eyJpdiI6InRCc2IvL3J4SGNsejgvL04iLCJ0YWciOiJBbjRlMTNXNlZzUGhNSWowSUU1a01nPT0iLCJkYXRhIjoiQXR4azk1aU9sb2QydzNwa0FMTHpjVkJKQTIvZU4wY1U2V2crK3NBbVd1UnRmUGh6In0="
}
+55
View File
@@ -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*
+319
View File
@@ -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
+60
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
OpenSSH-Win64.zip
+115
View File
@@ -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 "=========================================="
+40
View File
@@ -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
+106
View File
@@ -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 "=========================================="
+48
View File
@@ -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
+38
View File
@@ -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
+147
View File
@@ -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
+3 -3
View File
@@ -190,13 +190,13 @@ configuracion:
# implementado en la Fase 2 del plan de mejoras del ADN.
#
# La implementación del motor de triggers se encuentra en:
# tools/adn/core/triggers.rb (Fase 2)
# adn/tools/core/triggers.rb (Fase 2)
#
# Los triggers se ejecutarán mediante:
# ./tools/adn/run triggers [--test] [--force]
# ./adn/tools/run triggers [--test] [--force]
#
# Para probar triggers individualmente durante el desarrollo:
# ./tools/adn/run triggers --test --trigger="nombre_del_trigger"
# ./adn/tools/run triggers --test --trigger="nombre_del_trigger"
#
# ⚠️ ADVERTENCIA: No modificar manualmente las secciones marcadas como
# "GENERADO AUTOMÁTICAMENTE" una vez que el sistema esté en producción.