[ADN] Commit final DB-First - Actualización completa de documentación, herramientas y componentes

This commit is contained in:
Ricardo Monla
2026-03-12 06:03:48 -03:00
parent 4b000aeb89
commit e554bb5d0d
37 changed files with 1479 additions and 122 deletions
+227
View File
@@ -0,0 +1,227 @@
require 'optparse'
require_relative '../core/colores'
module ADN
class SubcomandoEstados
def initialize(args, logger)
@args = args
@logger = logger
@opciones = { verificar: false }
end
def ejecutar
parser = OptionParser.new do |opts|
opts.banner = "Uso: ./adn/tools/run estados [OPCIONES]"
opts.on("--verificar", "Verificar si las tareas en proceso ya terminaron") do
@opciones[:verificar] = true
end
end
parser.parse!(@args)
puts "#{Color::CYAN}⏳ Consultando items en proceso (Bitácora Web)#{Color::RESET}"
puts "#{Color::DIM}#{'-' * 70}#{Color::RESET}"
begin
require_relative '../db/core/bitacora_db'
# 1. Preparar SSH si es necesario (Una sola vez)
preparar_ssh if @opciones[:verificar]
BitacorasDB::BitacoraDB.with_connection do |db|
# Buscamos entradas con estado ⏳ para hoy o recientes
entradas = db.list_entradas(
estado: '⏳',
desde: (Date.today - 1).to_s,
limit: 20
)
if entradas.empty?
puts " #{Color::GREEN}✅ No hay tareas pendientes en proceso.#{Color::RESET}"
return
end
puts "#{Color::BOLD}#{"ID".ljust(5)} #{"Inicio".ljust(8)} #{"Nodo".ljust(15)} #{"Descripción".ljust(40)}#{Color::RESET}"
puts "#{"" * 5} #{"" * 8} #{"" * 15} #{"" * 40}"
entradas.each do |e|
id = e['id'].to_s
inicio = e['inicio'][0..4] # HH:MM
nodo = e['nodo_nombre'] || '(sin nodo)'
nodo_id = e['nodo_id']
desc = e['descripcion']
# Si se pidió verificar y es un proceso gestionable
if @opciones[:verificar] && desc.start_with?('PROCESO:') && nodo_id
print " #{Color::DIM}Verificando #{nodo}...#{Color::RESET} "
result = tarea_status_detallado(nodo, desc)
if result[:terminado]
puts "#{Color::GREEN}TERMINADO#{Color::RESET} #{Color::DIM}(Proceso no encontrado)#{Color::RESET}"
# Al finalizar, mantenemos el historial de viñetas
final_desc = desc.split("\n")[0].gsub('Iniciando ', '').gsub('..', '.') + " (Completado y auto-confirmado)\n" + desc.split("\n")[1..].join("\n")
db.update_entrada(e['id'], {
estado: '✅',
fin: Time.now.strftime('%H:%M'),
descripcion: final_desc.strip
})
next
elsif result[:activo]
puts "#{Color::YELLOW}ACTIVO#{Color::RESET}"
ahora = Time.now.strftime('%H:%M')
# Solo agregamos si no hay ya una entrada para este mismo minuto
unless desc.include?("- #{ahora} >")
# Limpiamos el formato anterior si existiera
base_desc = desc.split(' [Último control:')[0].strip
nueva_desc = "#{base_desc}\n- #{ahora} > Activo y en proceso sin problemas"
db.update_entrada(e['id'], { descripcion: nueva_desc })
end
else
puts "#{Color::RED}PENDIENTE#{Color::RESET} #{Color::DIM}(Error)#{Color::RESET}"
end
end
nodo_fmt = nodo[0..13] + '..' if nodo.length > 15
nodo_fmt ||= nodo
desc_fmt = desc[0..37] + '..' if desc.length > 40
desc_fmt ||= desc
puts "#{id.ljust(5)} #{inicio.ljust(8)} #{nodo_fmt.ljust(15)} #{desc_fmt}"
end
puts "#{Color::DIM}#{'-' * 70}#{Color::RESET}"
puts "Total: #{entradas.length} item(s) en proceso."
end
rescue => e
@logger.error("Error al consultar estados: #{e.message}")
puts "#{Color::RED}✗ Error: #{e.message}#{Color::RESET}"
ensure
limpiar_ssh if @opciones[:verificar]
end
end
private
def preparar_ssh
# Obtener passphrase de la bóveda
@passphrase = nil
begin
candados_path = File.join(File.dirname(__FILE__), '../seguridad/ns8-candados.rb')
if File.exist?(File.join(File.dirname(__FILE__), '../seguridad/.session'))
output = `ruby #{candados_path} get rsa 2>/dev/null`.strip
@passphrase = output unless output.empty? || output.include?('Error')
end
rescue
end
return unless @passphrase
# Crear script de askpass único
@askpass_sh = "/tmp/adn_askpass_#{Process.pid}.sh"
File.write(@askpass_sh, "#!/bin/sh\necho \"#{@passphrase}\"\n")
FileUtils.chmod(0700, @askpass_sh)
@ssh_env = {
'DISPLAY' => ':0',
'SSH_ASKPASS_REQUIRE' => 'force',
'SSH_ASKPASS' => @askpass_sh
}
# Intentar añadir la llave una sola vez si no está cargada
unless system("ssh-add -l >/dev/null 2>&1")
system(@ssh_env, "ssh-add /home/rmonla/.ssh/id_rsa < /dev/null 2>/dev/null")
end
end
def limpiar_ssh
File.delete(@askpass_sh) if @askpass_sh && File.exist?(@askpass_sh)
end
def tarea_status_detallado(nodo, descripcion)
archivo_nodo = File.join(ADN::NODOS_DIR, "#{nodo}.md")
return { terminado: false, activo: false, code: -1 } unless File.exist?(archivo_nodo)
contenido = File.read(archivo_nodo)
ip = detectar_ip(contenido)
so = detectar_so(contenido)
return { terminado: false, activo: false, code: -2 } unless ip
if descripcion.include?('actualiza') && descripcion.include?('SO')
if so == 'Linux'
# Comando robusto que no se empareja a sí mismo
cmd = "pgrep -x 'apt-get' || pgrep -x 'dpkg' || pgrep -x 'apt' || pgrep -x 'dpkg-deb'"
exit_code = system_ssh_status(ip, cmd)
return { terminado: true, activo: false, code: exit_code } if exit_code == 1
return { terminado: false, activo: true, code: exit_code } if exit_code == 0
return { terminado: false, activo: false, code: exit_code }
elsif so == 'Windows'
# Verificamos si procesos de actualización están corriendo
# En PowerShell el errorlevel $LASTEXITCODE o $? se puede traducir
cmd = "powershell -Command \"if (Get-Process TiWorker, TrustedInstaller, wuauclt -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }\""
exit_code = system_ssh_status_win(ip, cmd)
return { terminado: true, activo: false, code: exit_code } if exit_code == 1
return { terminado: false, activo: true, code: exit_code } if exit_code == 0
return { terminado: false, activo: false, code: exit_code }
end
end
{ terminado: false, activo: false, code: -3 }
end
def detectar_ip(contenido)
# 1. Buscar configuración SSH completa en tabla (user@IP:port)
if contenido.match(/\|\s*\*\*SSH\*\*\s*\|\s*`?([a-zA-Z0-9_-]+@[0-9\.]+):([0-9]+)`?/)
user_host = $1
port = $2
return "-p #{port} #{user_host}"
end
if contenido.match(/\*\*(?:IP VPN|IP Tailscale)\*\*\s*[:\|]\s*`?([0-9\.]+)`?/)
return $1
end
if contenido.match(/\*\*IP(?:\s+\w+)?\*\*\s*[:\|]\s*`?([0-9\.]+)`?/)
return $1
end
nil
end
def detectar_so(contenido)
# 1. Buscar campo explícito OS o SO en los metadatos iniciales
identidad = contenido.split('##')[0..1].join("\n")
if identidad.match(/(?:- |\|)\s*\*\*(?:OS|SO|Sistema Operativo)\*\*\s*[:\|]\s*([^\|\n]+)/i)
so_valor = $1.strip
return 'Windows' if so_valor.match(/Windows|Win10|Server/i)
return 'Linux' if so_valor.match(/Debian|Ubuntu|Linux|Proxmox|LXC/i)
end
# 2. Fallback por palabras clave
if identidad.match(/Windows|Win10|Server Core/i)
'Windows'
else
'Linux'
end
end
def system_ssh_status(ip, remote_cmd)
if @passphrase
system(@ssh_env, "ssh -n -o ConnectTimeout=5 -o StrictHostKeyChecking=no #{ip} '#{remote_cmd}' > /dev/null 2>&1")
return $?.exitstatus
else
system("ssh -n -o ConnectTimeout=5 -o BatchMode=yes -o StrictHostKeyChecking=no #{ip} '#{remote_cmd}' > /dev/null 2>&1")
return $?.exitstatus
end
end
def system_ssh_status_win(ip, remote_cmd)
candados_path = File.join(File.dirname(__FILE__), '../seguridad/ns8-candados.rb')
if File.exist?(File.join(File.dirname(__FILE__), '../seguridad/.session'))
shell_eval = "sshpass -e ssh -n -o ConnectTimeout=5 -o StrictHostKeyChecking=no #{ip} '#{remote_cmd}' > /dev/null 2>&1"
system("ruby", candados_path, "run", "win_domain", "SSHPASS", shell_eval)
return $?.exitstatus
else
# Si no hay sesión, fallar y devolver un error genérico (255)
return 255
end
end
end
end