[A04.P005] DASUTEN: Pipeline refactorizado + Documentación + Informes

Refactorización del pipeline de backups DASUTEN:

1. Flujo simplificado (un solo destino):
   - Backup directo a X:\ (share srv-ns8)
   - Eliminado paso intermedio E:\BK_SQL\sysdasuten\
   - Reducido de 3 ubicaciones a 2 (X: → /var/tmp/)

2. Módulo común DasuExecutor (lib/dasu_executor.rb):
   - Centraliza ejecución PowerShell en dasu-sql4
   - Centraliza verificación de archivos en fenix
   - Elimina código repetido en 6 drones (-52% líneas)

3. Drones refactorizados:
   - dasuten_exportar_srvv-fenix.rb (FULL → X:)
   - dasuten_exportar_diferencial_srvv-fenix.rb (DIF → X:)
   - dasuten_transferir_srvv-fenix-srv-ns8.rb (simplificado)
   - dasuten_restaurar_dasu-sql4.rb (usa DasuExecutor)
   - dasuten_restaurar_diferencial_dasu-sql4.rb (usa DasuExecutor)
   - dasuten_download_drive-dasu-sql4.rb (usa DasuExecutor)
   - dasuten_verificar-integridad_dasu-sql4.rb (usa DasuExecutor)

4. Integración BKPs mantenida:
   - T8-T15: Tareas individuales
   - C7: dasuten_full (pipeline semanal)
   - C8: dasuten_diferencial (pipeline diario)

5. Documentación actualizada:
   - A04.P006_Backups-DASUTEN.md: flujo simplificado + módulo común
   - Lecciones aprendidas agregadas

6. Nuevos archivos:
   - Informes técnicos DASUTEN (docs/informes/2026-04_DASUTEN-migracion/)
   - A01.P011/P012: Documentación de drones y pipeline
   - proc_linux.rb, ops.rb: Utilitarios del ecosistema

7. Scripts legacy (para referencia):
   - orquestador_pipeline.rb, orquestador_pipeline_diferencial.rb
   - dasuten_upload_srv-ns8-drive.rb
This commit is contained in:
Ricardo Monla
2026-04-14 20:06:06 -03:00
parent bc1f42db1f
commit 892019fa22
59 changed files with 5438 additions and 42 deletions
+172
View File
@@ -0,0 +1,172 @@
# frozen_string_literal: true
# ==========================================================
# adn/tools/bkps/lib/proc_linux.rb — Procesador Linux (apt)
#
# Fase 2: Soporta orquestación 1-dron-por-nodo
# listar(tarea) → lista nodos Linux pendientes
# ejecutar_uno(tarea, nodo) → actualiza UN solo nodo
# ejecutar(tarea) → actualiza TODOS (legacy)
# ==========================================================
require 'shellwords'
require 'net/ssh'
module ADN
module BKPs
module Procesador
class Linux
def initialize(log)
@log = log
end
# Scanner: devuelve lista de nodos Linux a actualizar
# Lee los archivos .md de nodos/ y filtra los Linux (Debian/Ubuntu)
def listar(tarea)
nodos_dir = File.join(ADN::NODOS_DIR || 'nodos', '')
unless Dir.exist?(nodos_dir)
@log.error("Directorio de nodos no existe: #{nodos_dir}")
return []
end
# Filtrar nodos Linux (excluye Windows y otros)
nodos_linux = Dir.glob(File.join(nodos_dir, '*.md'))
.select { |f| es_nodo_linux?(f) }
.map { |f| extraer_info_nodo(f) }
.compact
.reject { |n| n[:ip].nil? || n[:ip].empty? }
nodos_linux.map do |nodo|
{
archivo: nodo[:hostname], # Identificador para ejecutar_uno
vm: nodo[:hostname], # Nombre visible (nodo)
ruta: nodo[:ip], # IP del nodo
destino: nodo[:ip], # Mismo destino (es SSH)
tamaño: 0, # No aplicable para updates
pendiente: true, # Siempre verificar
metadata: nodo # Info adicional (usuario, etc.)
}
end
end
# Atómico: actualiza UN solo nodo Linux vía SSH
def ejecutar_uno(tarea, nombre_nodo)
ruta_md = File.join(ADN::NODOS_DIR || 'nodos', "#{nombre_nodo}.md")
nodo_info = File.exist?(ruta_md) ? extraer_info_nodo(ruta_md) : (tarea[:metadata] || {})
ip = nodo_info[:ip] || tarea[:origen]
usuario = nodo_info[:usuario] || tarea[:usuario] || 'root'
@log.info("🐧 Actualización Linux: #{nombre_nodo}")
@log.info(" SSH: #{usuario}@#{ip}")
begin
Net::SSH.start(ip, usuario, keys_only: true, timeout: 10) do |ssh|
# Variables de entorno para evitar interactividad
env_vars = "DEBIAN_FRONTEND=noninteractive DEBIAN_PRIORITY=critical"
apt_opts = "-o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold'"
# Paso 1: apt update
@log.info(" [1/3] Ejecutando apt update...")
output_update = ssh.exec!("sudo #{env_vars} apt-get update 2>&1")
@log.info(" #{output_update.lines.last&.strip || 'OK'}")
# Paso 2: apt upgrade -y
@log.info(" [2/3] Ejecutando apt upgrade -y...")
output_upgrade = ssh.exec!("sudo #{env_vars} apt-get upgrade -y #{apt_opts} 2>&1")
@log.info(" #{output_upgrade.lines.last&.strip || 'OK'}")
# Paso 3: apt autoremove -y (limpieza)
@log.info(" [3/3] Ejecutando apt autoremove -y...")
output_autoremove = ssh.exec!("sudo #{env_vars} apt-get autoremove -y 2>&1")
@log.info(" #{output_autoremove.lines.last&.strip || 'OK'}")
@log.info("✔ Nodo #{nombre_nodo} actualizado exitosamente.")
true
end
rescue => e
@log.error("✗ Error actualizando #{nombre_nodo}: #{e.message}")
false
end
end
# Legacy: actualiza TODOS los nodos (compatibilidad)
def ejecutar(tarea)
@log.info("--- #{tarea[:texto]} ---")
@log.info("Origen: #{tarea[:origen]}")
nodos = listar(tarea)
if nodos.empty?
@log.info("Sin nodos Linux encontrados.")
return true
end
@log.info("Encontrados #{nodos.length} nodo(s) Linux.")
actualizados = 0
fallidos = 0
nodos.each do |nodo|
@log.info("\n" + "=" * 50)
if ejecutar_uno(tarea, nodo[:vm])
actualizados += 1
else
fallidos += 1
end
end
@log.info("\n" + "=" * 50)
@log.info("#{actualizados} nodo(s) actualizado(s), #{fallidos} fallido(s).")
fallidos == 0
end
private
# Verifica si el nodo es Linux (busca Debian, Ubuntu, Linux en el .md)
def es_nodo_linux?(archivo_md)
contenido = File.read(archivo_md)
# Excluir explícitamente Windows
return false if contenido.match?(/Windows\s+(Server\s+)?\d+/i)
return false if contenido.match?(/Windows\s+10/i)
return false if contenido.match?(/Windows\s+11/i)
# Incluir Linux/Debian/Ubuntu
return true if contenido.match?(/Debian/i)
return true if contenido.match?(/Ubuntu/i)
return true if contenido.match?(/Linux/i)
# Incluir si tiene IP LAN y es srvv-* o srv-* (convención de nombres)
hostname = File.basename(archivo_md, '.md')
return true if hostname.match?(/^srvv?-/)
false
end
# Extrae información del nodo desde el archivo .md
def extraer_info_nodo(archivo_md)
contenido = File.read(archivo_md)
hostname = File.basename(archivo_md, '.md')
# Extraer IP LAN
ip = contenido.match(/\*\*IP(?:\s+LAN)?\*\*:\s*`?(\d+\.\d+\.\d+\.\d+)`?/i)&.[](1) ||
contenido.match(/\|\s*IP(?:\s+LAN)?[^\|]*\|\s*`?(\d+\.\d+\.\d+\.\d+)`?\s*\|/i)&.[](1)
# Extraer usuario SSH (si está especificado)
usuario = contenido.match(/\*\*Usuario\s*SSH\*\*:\s*`?(\w+)`?/i)&.[](1) ||
contenido.match(/\*\*SSH\*\*:\s*`?ssh\s+(\w+)@/i)&.[](1) ||
'root'
# Extraer estado (para filtrar nodos offline)
estado = contenido.match(/\*\*Estado\*\*:\s*(🟢|🔴|⚫|🚧|🟡)/i)&.[](1)
{
hostname: hostname,
ip: ip,
usuario: usuario,
estado: estado || '🟢'
}
end
end
end
end
end
+2 -1
View File
@@ -17,5 +17,6 @@
"dasu-sql4:Administrador": "eyJpdiI6ImQzc09sRmo2NEZBbHZmN2IiLCJ0YWciOiJxK3Z3RU9uZG1QMXNUV1hJbEFmV3d3PT0iLCJkYXRhIjoicXd5dDI4bHBkeFNhRk1yTzluczlxdz09In0=",
"dasu-sql4:sa": "eyJpdiI6Ik43aWR5d2RacFVFR3pyNysiLCJ0YWciOiJxeVAyTmxtYXloNTdFTVlvMFphQ2hRPT0iLCJkYXRhIjoiT0lrR1FGMXRVZ0czYU5PK1ZFcG8wUT09In0=",
"dasu-pcv:rustdesk": "eyJpdiI6ImRwRFRCYjNmc1htZUJMZ1ciLCJ0YWciOiJRYTdpcy9wRTdlTnpLemQ0Y1VqMTF3PT0iLCJkYXRhIjoiL3hEczU5OVRrRzAralFNPSJ9",
"dasu-pcv:adminpcv": "eyJpdiI6IlVUK3JOamxCeFhGUUlCMlUiLCJ0YWciOiI0MC81bE10Y0FJM1BqMTQ1NUIxWjhBPT0iLCJkYXRhIjoiUzZuOUIxaTVISmJpclNrREZYSTkwM1k9In0="
"dasu-pcv:adminpcv": "eyJpdiI6IlVUK3JOamxCeFhGUUlCMlUiLCJ0YWciOiI0MC81bE10Y0FJM1BqMTQ1NUIxWjhBPT0iLCJkYXRhIjoiUzZuOUIxaTVISmJpclNrREZYSTkwM1k9In0=",
"dasu-pc:UTNLR": "eyJpdiI6IjhLejRXUjNaTGU1RkRmaUgiLCJ0YWciOiJMRi9DdUhKWXg4VjJNL3MvajNqK1V3PT0iLCJkYXRhIjoicmJmdDZjZjNScXhFcVNzUC9ZSXc3SzA9In0="
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# ==========================================================
# adn/tools/cli/dasuten_backup_download.rb — Download desde Google Drive
# ==========================================================
# Flujo: Google Drive (drive_bkps-dasu) → dasu-sql4
# Uso: ruby adn/tools/cli/dasuten_backup_download.rb
# ==========================================================
require 'shellwords'
REMOTE_DRIVE = "rmonla-GDrive:drive_bkps-dasu"
LOCAL_BACKUP = "F:\\BACKUP\\sysdasuten_compressed_ADN.bak"
puts "[*] Iniciando descarga de backup desde Google Drive..."
begin
# Paso 1: Listar backups disponibles
puts "[1/4] Buscando backup más reciente en Drive..."
cmd_list = "rclone lsf '#{REMOTE_DRIVE}' --files-only --order '-modified' 2>/dev/null | grep sysdasuten | head -1"
backup_remoto = `#{cmd_list}`.strip
if backup_remoto.empty?
puts "[!] No se encontraron backups en #{REMOTE_DRIVE}"
exit 1
end
puts " Backup encontrado: #{backup_remoto}"
# Paso 2: Descargar backup
puts "[2/4] Descargando backup..."
puts " Origen: #{REMOTE_DRIVE}/#{backup_remoto}"
puts " Destino: #{LOCAL_BACKUP}"
puts " (Esto puede tomar 2-5 minutos)"
cmd_download = "rclone copy '#{REMOTE_DRIVE}/#{backup_remoto}' '#{LOCAL_BACKUP}' --progress -v"
start_download = Time.now
system(cmd_download)
if $?.success?
puts "[+] Descarga completada en #{(Time.now - start_download).round(2)} segundos."
else
puts "[!] Error en descarga (código: #{$?.exitstatus})"
exit 1
end
# Paso 3: Verificar archivo descargado
puts "[3/4] Verificando archivo descargado..."
cmd_verify = "powershell -Command \"(Get-Item '#{LOCAL_BACKUP}').Length\""
tamaño = `#{cmd_verify}`.strip.to_i
if tamaño > 0
puts "[+] Archivo verificado: #{(tamaño / 1024.0 / 1024.0).round(2)} MB"
else
puts "[!] Archivo descargado parece estar vacío"
exit 1
end
# Paso 4: Restaurar en SQL Server
puts "[4/4] Preparando para restauración..."
puts " Backup disponible en: #{LOCAL_BACKUP}"
puts ""
puts " Para restaurar, ejecutar:"
puts " sqlcmd -S localhost -Q \"RESTORE DATABASE sysdasuten FROM DISK = '#{LOCAL_BACKUP}' WITH REPLACE, MOVE 'sysdasuten' TO 'F:\\DATA\\sysdasuten.mdf', MOVE 'sysdasuten_log' TO 'F:\\LOG\\sysdasuten_log.ldf'\""
puts "\n✅ Descarga completada exitosamente."
rescue => e
puts "[!] Error fatal: #{e.message}"
exit 1
end
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# ==========================================================
# adn/tools/cli/dasuten_backup_upload.rb — Upload a Google Drive
# ==========================================================
# Flujo: srv-ns8 (backup local) → Google Drive
# Uso: ruby adn/tools/cli/dasuten_backup_upload.rb
# ==========================================================
require 'time'
REMOTE_DRIVE = "rmonla-GDrive:drive_bkps-dasu"
BACKUP_LOCAL = "/var/tmp/sysdasuten_compressed_ADN.bak"
BACKUP_NAME = "sysdasuten_compressed_ADN_#{Time.now.strftime('%Y%m%d_%H%M%S')}.bak"
puts "[*] Iniciando upload de backup a Google Drive..."
# Verificar archivo local
unless File.exist?(BACKUP_LOCAL)
puts "[!] Backup no encontrado en #{BACKUP_LOCAL}"
puts " Primero generar backup en srvv-fenix y descargar a srv-ns8"
exit 1
end
tamaño_mb = (File.size(BACKUP_LOCAL) / 1024.0 / 1024.0).round(2)
puts " Archivo local: #{BACKUP_LOCAL}"
puts " Tamaño: #{tamaño_mb} MB"
puts " Destino: #{REMOTE_DRIVE}/#{BACKUP_NAME}"
begin
# Upload a Google Drive
puts "\n[1/2] Subiendo a Google Drive..."
puts " (Esto puede tomar 3-10 minutos dependiendo del ancho de banda)"
cmd_upload = "rclone copy '#{BACKUP_LOCAL}' '#{REMOTE_DRIVE}/#{BACKUP_NAME}' --progress -v"
start_upload = Time.now
system(cmd_upload)
if $?.success?
duracion = (Time.now - start_upload).round(2)
puts "[+] Upload completado en #{duracion} segundos."
else
puts "[!] Error en upload (código: #{$?.exitstatus})"
exit 1
end
# Verificar archivo en Drive
puts "\n[2/2] Verificando archivo en Drive..."
cmd_verify = "rclone size '#{REMOTE_DRIVE}/#{BACKUP_NAME}' 2>/dev/null"
output = `#{cmd_verify}`.strip
puts " Tamaño en Drive: #{output}"
puts "\n✅ Backup subido exitosamente a Google Drive:"
puts " #{REMOTE_DRIVE}/#{BACKUP_NAME}"
rescue => e
puts "[!] Error fatal: #{e.message}"
exit 1
end
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# ==========================================================
# adn/tools/cli/dasuten_orquestador.rb — Orquestador Fase 8b
# ==========================================================
# Flujo completo: srvv-fenix → Google Drive → dasu-sql4
# Uso: ruby adn/tools/cli/dasuten_orquestador.rb [upload|download|restore]
# ==========================================================
require 'optparse'
require_relative 'dron/dispatcher'
PROJECT_ROOT = File.expand_path('../..', __dir__)
CLI_DIR = File.join(PROJECT_ROOT, 'adn', 'tools', 'cli')
ADN_RUN = File.join(PROJECT_ROOT, 'adn', 'tools', 'run')
opciones = {
paso: 'all',
dry_run: false,
nodo: nil
}
OptionParser.new do |opts|
opts.banner = "Uso: #{ADN_RUN} dasuten_orquestador [opciones]"
opts.on("--paso PASO", %w[upload download restore all], "Ejecutar solo este paso (upload|download|restore|all)") { |p| opciones[:paso] = p }
opts.on("--nodo NOMBRE", "Nodo para bitácora del dron") { |n| opciones[:nodo] = n }
opts.on("--dry-run", "Simulación sin ejecutar") { opciones[:dry_run] = true }
end.parse!(ARGV)
puts "\n#{'='*60}"
puts "🛸 ORQUESTADOR DASUTEN — Fase 8b (Refresco BD vía Google Drive)"
puts '='*60
puts " Paso seleccionado: #{opciones[:paso]}"
puts " Dry-run: #{opciones[:dry_run]}"
puts " Nodo: #{opciones[:nodo] || 'auto-detect'}"
puts '='*60
def lanzar_dron(cmd, nota, nodo: nil)
puts "\n🛸 Lanzando dron: #{nota}"
puts " Comando: #{cmd}"
args = ["lanzar", "--nota", nota]
args << "--nodo" << nodo if nodo
args << "--" << cmd
# Ejecutar dron directamente
require_relative 'dron/ejecutor'
resultado = Dron::Ejecutor.lanzar(
cmd: cmd,
nota: nota,
nodo: nodo,
timeout: 1800 # 30 minutos max
)
resultado
end
caso = opciones[:paso]
# ──────────────────────────────────────────────────────────────
# PASO 1: UPLOAD desde srvv-fenix a Google Drive
# ──────────────────────────────────────────────────────────────
if caso == 'all' || caso == 'upload'
puts "\n" + ""*60
puts "📤 PASO 1: Upload desde srvv-fenix → Google Drive"
puts ""*60
cmd_upload = "ruby #{CLI_DIR}/dasuten_backup_upload.rb"
if opciones[:dry_run]
puts "[DRY-RUN] Ejecutaría: #{cmd_upload}"
else
resultado = lanzar_dron(cmd_upload, "📤 Upload backup DASUTEN a Drive", nodo: 'srvv-fenix')
if resultado[:exit_code] != 0
puts "\n❌ Upload fallido. Deteniendo orquestación."
exit 1
end
end
end
# ──────────────────────────────────────────────────────────────
# PASO 2: DOWNLOAD desde Google Drive a dasu-sql4
# ──────────────────────────────────────────────────────────────
if caso == 'all' || caso == 'download'
puts "\n" + ""*60
puts "📥 PASO 2: Download desde Google Drive → dasu-sql4"
puts ""*60
cmd_download = "ruby #{CLI_DIR}/dasuten_backup_download.rb"
if opciones[:dry_run]
puts "[DRY-RUN] Ejecutaría: #{cmd_download}"
else
resultado = lanzar_dron(cmd_download, "📥 Download backup DASUTEN desde Drive", nodo: 'dasu-sql4')
if resultado[:exit_code] != 0
puts "\n❌ Download fallido. Deteniendo orquestación."
exit 1
end
end
end
# ──────────────────────────────────────────────────────────────
# PASO 3: RESTORE en dasu-sql4
# ──────────────────────────────────────────────────────────────
if caso == 'all' || caso == 'restore'
puts "\n" + ""*60
puts "💾 PASO 3: Restore + DBCC CHECKDB en dasu-sql4"
puts ""*60
cmd_restore = <<~SQL.gsub("\n", ' ').strip
powershell -Command "
$query = @'
RESTORE DATABASE sysdasuten FROM DISK = 'F:\\BACKUP\\sysdasuten_compressed_ADN.bak'
WITH REPLACE,
MOVE 'sysdasuten' TO 'F:\\DATA\\sysdasuten.mdf',
MOVE 'sysdasuten_log' TO 'F:\\LOG\\sysdasuten_log.ldf';
DBCC CHECKDB('sysdasuten');
'@
sqlcmd -S localhost -Q $query
"
SQL
if opciones[:dry_run]
puts "[DRY-RUN] Ejecutaría: #{cmd_restore}"
else
resultado = lanzar_dron(cmd_restore, "💾 Restore + CHECKDB DASUTEN", nodo: 'dasu-sql4')
if resultado[:exit_code] != 0
puts "\n❌ Restore fallido. Verificar logs."
exit 1
end
end
end
puts "\n" + ""*60
puts "✅ Orquestación completada exitosamente!"
puts ""*60
+12
View File
@@ -36,6 +36,8 @@ module Dron
SanadorComando.reintentar(args)
when 'estado', 'status'
VigilanteComando.estado(args)
when 'ops', 'operaciones'
OpsComando.ejecutar(args)
when 'ayuda', 'help', '--help', '-h'
mostrar_ayuda
else
@@ -59,6 +61,7 @@ module Dron
lines << " #{Color::GREEN}flota#{Color::RESET} Dashboard compacto de la flota"
lines << " #{Color::GREEN}salud#{Color::RESET} Health check activo (detecta zombies)"
lines << " #{Color::GREEN}estado <ID>#{Color::RESET} Estado detallado de un dron"
lines << " #{Color::GREEN}ops <acción>#{Color::RESET} Operaciones de infraestructura (update_os)"
lines << " #{Color::GREEN}sanear#{Color::RESET} Sanear flota (reintentar + limpiar)"
lines << " #{Color::GREEN}limpiar#{Color::RESET} Limpiar drones antiguos"
lines << " #{Color::GREEN}reintentar#{Color::RESET} Re-intentar drones fallidos"
@@ -74,6 +77,8 @@ module Dron
lines << "\n#{Color::BOLD}Ejemplos:#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron lanzar --nota 'Backup SQL' -- vzdump 103#{Color::RESET}"
lines << " #{Color::CYAN}Lanzar backup como dron#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron ops update_os --target linux#{Color::RESET}"
lines << " #{Color::CYAN}Actualizar OS de todos los nodos Linux#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron flota#{Color::RESET}"
lines << " #{Color::CYAN}Ver estado de la flota#{Color::RESET}"
lines << " #{Color::DIM}$ ./adn/tools/run dron salud#{Color::RESET}"
@@ -279,4 +284,11 @@ module Dron
opciones
end
end
class OpsComando
def self.ejecutar(args)
require_relative 'ops'
Dron::Ops.ejecutar(args)
end
end
end
+293
View File
@@ -0,0 +1,293 @@
# frozen_string_literal: true
# ==========================================================
# Dron::Ops — Operaciones de Infraestructura via AtomicDrones
# ----------------------------------------------------------
# Responsabilidad: Orquestar tareas de mantenimiento de nodos
# (update_os, etc.) usando 1 dron ejecutor por nodo.
#
# Reutiliza:
# - NodosInfo (scanner de fichas .md)
# - Dron::Ejecutor (ejecución atómica con auto-bitácora)
#
# Uso:
# ./adn/tools/run dron ops update_os --target linux
# ./adn/tools/run dron ops update_os --nodo srvv-dns
# ./adn/tools/run dron ops update_os --target linux --dry-run
#
# Plan: A01.P011 — Drones de Operaciones de Infraestructura
# ==========================================================
require 'optparse'
require 'shellwords'
require_relative 'base'
require_relative 'ejecutor'
require_relative '../nodos/info'
require_relative '../../core/colores'
require_relative '../../core/constants'
module Dron
class Ops
# Comandos SSH para actualización desatendida
APT_ENV = 'DEBIAN_FRONTEND=noninteractive DEBIAN_PRIORITY=critical'
APT_OPTS = "-o Dpkg::Options::='--force-confdef' -o Dpkg::Options::='--force-confold'"
APT_CMD = "sudo #{APT_ENV} apt-get update 2>&1 && " \
"sudo #{APT_ENV} apt-get upgrade -y #{APT_OPTS} 2>&1 && " \
"sudo #{APT_ENV} apt-get autoremove -y 2>&1"
class << self
# ─── Punto de entrada CLI ──────────────────────────────────
def ejecutar(args)
accion = args.shift
case accion
when 'update_os'
cmd_update_os(args)
when 'ayuda', 'help', '--help', '-h', nil
mostrar_ayuda
else
Base.logger.error("❌ Acción desconocida para ops: #{accion}")
mostrar_ayuda
exit 1
end
end
private
# ─── update_os ─────────────────────────────────────────────
def cmd_update_os(args)
opciones = parsear_opciones(args)
# Obtener nodos candidatos
nodos = escanear_nodos(opciones)
if nodos.empty?
puts "#{Color::YELLOW}⚠ Sin nodos Linux encontrados para actualizar.#{Color::RESET}"
return
end
mostrar_resumen(nodos, opciones)
return if opciones[:dry_run]
# Orquestar: 1 dron ejecutor por nodo
resultados = orquestar_nodos(nodos, opciones)
# Resumen final
mostrar_resultados(resultados, nodos.length)
exit 1 if resultados[:failed] > 0 && opciones[:on_error] == 'abort'
end
# ─── Scanner: reutiliza NodosInfo ──────────────────────────
def escanear_nodos(opciones)
nodos_dir = ADN::NODOS_DIR
unless Dir.exist?(nodos_dir)
Base.logger.error("Directorio de nodos no existe: #{nodos_dir}")
return []
end
candidatos = Dir.glob(File.join(nodos_dir, '*.md'))
.reject { |f| File.basename(f).start_with?('_') }
.map { |f| ADN::NodosInfo.extraer_metadata(File.basename(f, '.md')) }
.compact
# Filtrar por OS
if opciones[:target] == 'linux'
candidatos.select! { |n| n[:so] == :linux }
end
# Excluir nodos sin IP
candidatos.reject! { |n| n[:ip].nil? || n[:ip].to_s.strip.empty? }
# Excluir nodos offline
candidatos.reject! { |n| n[:estado]&.to_s&.include?('🔴') }
# Filtrar nodo específico
if opciones[:nodo]
candidatos.select! { |n| n[:nombre] == opciones[:nodo] }
end
# Filtrar por ring
if opciones[:ring]
candidatos.select! { |n| n[:ring] == opciones[:ring] }
end
candidatos
end
# ─── Orquestación: 1 dron por nodo ─────────────────────────
def orquestar_nodos(nodos, opciones)
resultados = { ok: 0, failed: 0, skipped: 0 }
abortado = false
nodos.each_with_index do |nodo, i|
break if abortado
puts " #{Color::BOLD}[#{i + 1}/#{nodos.length}]#{Color::RESET} " \
"#{Color::CYAN}#{nodo[:nombre]}#{Color::RESET} (#{nodo[:ip]})"
# Construir comando SSH para este nodo
usuario = nodo[:usuario] || 'root'
puerto = nodo[:puerto_ssh] || 22
ssh_cmd = construir_ssh_cmd(nodo, usuario, puerto)
# Lanzar dron ejecutor (auto-bitácora incluida)
nota = "🐧 Actualizar #{nodo[:nombre]}"
resultado = Dron::Ejecutor.lanzar(
cmd: ssh_cmd,
nota: nota,
timeout: opciones[:timeout],
nodo: nodo[:nombre]
)
if resultado[:exit_code] == 0
puts " #{Color::GREEN}✔ Completado#{Color::RESET} (#{resultado[:duration]}s)"
resultados[:ok] += 1
else
resultados[:failed] += 1
case opciones[:on_error]
when 'abort'
puts " #{Color::RED}✖ Falló — deteniendo orquestación#{Color::RESET}"
abortado = true
resultados[:skipped] = nodos.length - i - 1
when 'continue'
puts " #{Color::YELLOW}⚠ Falló — continuando con el siguiente#{Color::RESET}"
end
end
puts ""
end
resultados
end
# ─── Construcción de comando SSH ───────────────────────────
def construir_ssh_cmd(nodo, usuario, puerto)
ssh_opts = "-o StrictHostKeyChecking=no -o ConnectTimeout=10"
# Puerto no estándar
ssh_opts += " -p #{puerto}" if puerto != 22
# Opciones especiales (legacy servers como XenServer)
ssh_opts += " #{nodo[:ssh_opts]}" if nodo[:ssh_opts]
# Clave SSH específica
if nodo[:clave_ssh]
clave = nodo[:clave_ssh].gsub('~', ENV['HOME'] || '/root')
ssh_opts += " -i #{clave}"
end
remote_cmd = APT_CMD
# Determinar método de autenticación
if nodo[:clave_boveda]
# AUTH POR PASSWORD: Usar candados run + sshpass
# candados inyecta $PASS en el entorno, sshpass lo consume
adn_run = File.join(ADN::PROJECT_ROOT, 'adn/tools/run')
"#{adn_run} candados run #{nodo[:clave_boveda]} " \
"'sshpass -p $PASS ssh #{ssh_opts} #{usuario}@#{nodo[:ip]} \"#{remote_cmd}\"'"
else
# AUTH POR CLAVE SSH: Conexión directa
"ssh #{ssh_opts} #{usuario}@#{nodo[:ip]} '#{remote_cmd}'"
end
end
# ─── Presentación ─────────────────────────────────────────
def mostrar_resumen(nodos, opciones)
puts ""
puts "#{Color::BOLD}#{Color::CYAN}🐧 Dron Ops — Actualización de OS Linux#{Color::RESET}"
puts "" * 60
puts " Nodos detectados: #{nodos.length}"
puts " Target: #{opciones[:target]}"
puts " Ring: #{opciones[:ring] || 'todos'}" if opciones[:ring]
puts " On-error: #{opciones[:on_error]}"
puts " Timeout por nodo: #{opciones[:timeout]}s"
if opciones[:dry_run]
puts " Modo: #{Color::YELLOW}DRY-RUN (sin ejecutar)#{Color::RESET}"
end
puts ""
if opciones[:dry_run]
nodos.each_with_index do |nodo, i|
auth = nodo[:clave_boveda] ? "🔐 #{nodo[:clave_boveda]}" : "🔑 key"
ring_tag = nodo[:ring] ? " [#{nodo[:ring]}]" : ""
puts " #{Color::DIM}[#{i + 1}]#{Color::RESET} #{nodo[:nombre].ljust(18)} " \
"#{Color::DIM}#{nodo[:ip].ljust(16)} #{(nodo[:usuario] || 'root').ljust(8)} #{auth}#{ring_tag}#{Color::RESET}"
end
puts ""
puts "#{Color::YELLOW}[DRY-RUN] No se lanzaron drones.#{Color::RESET}"
end
end
def mostrar_resultados(resultados, total)
puts "#{Color::BOLD}🐧 Resumen de Operaciones#{Color::RESET}"
puts "" * 40
puts " ✅ Exitosos: #{resultados[:ok]}"
puts " ❌ Fallidos: #{resultados[:failed]}" if resultados[:failed] > 0
puts " ⏭️ Saltados: #{resultados[:skipped]}" if resultados[:skipped] > 0
puts "#{Color::BOLD}🐧 Operación #{resultados[:failed] > 0 ? 'con errores' : 'finalizada'}.#{Color::RESET}"
end
# ─── Parser de opciones ────────────────────────────────────
def parsear_opciones(args)
opciones = {
target: 'linux',
nodo: nil,
ring: nil,
on_error: 'continue',
timeout: 600,
dry_run: false
}
OptionParser.new do |opts|
opts.banner = "Uso: ./adn/tools/run dron ops update_os [opciones]"
opts.on("--target OS", "OS a actualizar (linux)") { |t| opciones[:target] = t }
opts.on("--nodo NOMBRE", "Actualizar solo este nodo") { |n| opciones[:nodo] = n }
opts.on("--ring RING", %w[test standard core], "Solo nodos de este ring (test|standard|core)") { |r| opciones[:ring] = r }
opts.on("--on-error MODE", %w[abort continue], "Qué hacer si un dron falla (abort|continue)") { |m| opciones[:on_error] = m }
opts.on("--timeout N", Integer, "Timeout por nodo en segundos (default: 600)") { |t| opciones[:timeout] = t }
opts.on("--dry-run", "Solo listar nodos sin ejecutar") { opciones[:dry_run] = true }
end.parse!(args)
opciones
end
def mostrar_ayuda
puts "#{Color::BOLD}🐧 Dron Ops — Operaciones de Infraestructura#{Color::RESET}"
puts "#{Color::DIM}#{'=' * 50}#{Color::RESET}"
puts ""
puts "#{Color::CYAN}Operaciones de mantenimiento sobre nodos vía AtomicDrones#{Color::RESET}"
puts ""
puts "Uso: #{Color::YELLOW}./adn/tools/run dron ops <acción> [opciones]#{Color::RESET}"
puts ""
puts "#{Color::BOLD}Acciones:#{Color::RESET}"
puts " #{Color::GREEN}update_os#{Color::RESET} Actualizar sistema operativo de nodos"
puts ""
puts "#{Color::BOLD}Opciones:#{Color::RESET}"
puts " #{Color::GREEN}--target OS#{Color::RESET} OS a actualizar: linux (default: linux)"
puts " #{Color::GREEN}--nodo NOMBRE#{Color::RESET} Actualizar solo un nodo específico"
puts " #{Color::GREEN}--on-error MODE#{Color::RESET} abort|continue (default: continue)"
puts " #{Color::GREEN}--timeout N#{Color::RESET} Timeout por nodo en segundos (default: 600)"
puts " #{Color::GREEN}--dry-run#{Color::RESET} Solo listar sin ejecutar"
puts ""
puts "#{Color::BOLD}Ejemplos:#{Color::RESET}"
puts " #{Color::DIM}$ ./adn/tools/run dron ops update_os --target linux --dry-run#{Color::RESET}"
puts " #{Color::CYAN}Listar nodos Linux sin actualizar#{Color::RESET}"
puts " #{Color::DIM}$ ./adn/tools/run dron ops update_os --nodo srvv-dns#{Color::RESET}"
puts " #{Color::CYAN}Actualizar solo srvv-dns#{Color::RESET}"
puts " #{Color::DIM}$ ./adn/tools/run dron ops update_os --on-error continue#{Color::RESET}"
puts " #{Color::CYAN}Actualizar toda la flota Linux continuando ante fallos#{Color::RESET}"
puts ""
end
end
end
end
@@ -0,0 +1,88 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# ==========================================================
# adn/tools/cli/drones/dasuten_upload_srv-ns8-drive.rb
# ==========================================================
# Dron: Upload a Google Drive desde srv-ns8
# Flujo: /var/tmp/sysdasuten_compressed_ADN.bak → Google Drive
# Entrada: Backup ya descargado en /var/tmp/
# Salida: URL de Google Drive
# ==========================================================
require 'time'
require 'json'
require 'shellwords'
REMOTE_DRIVE = "rmonla-GDrive:drive_bkps-dasu"
# Leer ruta del backup desde el output de la transferencia
TRANSFER_OUTPUT = '/tmp/dron_transfer_output.json'
if File.exist?(TRANSFER_OUTPUT)
output = JSON.parse(File.read(TRANSFER_OUTPUT))
BACKUP_LOCAL = output['backup_local']
BACKUP_NAME = File.basename(BACKUP_LOCAL)
puts "[*] Dron: Upload a Google Drive (DIFERENCIAL)"
else
BACKUP_LOCAL = "/var/tmp/sysdasuten_compressed_ADN.bak"
BACKUP_NAME = "sysdasuten_compressed_ADN_#{Time.now.strftime('%Y%m%d_%H%M%S')}.bak"
puts "[*] Dron: Upload a Google Drive (COMPLETO)"
end
puts " Nodo: srv-ns8"
puts " Origen: #{BACKUP_LOCAL}"
puts " Destino: #{REMOTE_DRIVE}/#{BACKUP_NAME}"
# Verificar archivo local
unless File.exist?(BACKUP_LOCAL)
puts "[!] Backup no encontrado en #{BACKUP_LOCAL}"
puts " Ejecutar primero: dasuten_transferir_srvv-fenix-srv-ns8.rb"
exit 1
end
tamaño_mb = (File.size(BACKUP_LOCAL) / 1024.0 / 1024.0).round(2)
puts "[+] Archivo local: #{tamaño_mb} MB"
begin
# Upload a Google Drive
puts "\n[1/2] Subiendo a Google Drive..."
start = Time.now
cmd_upload = "rclone copy '#{BACKUP_LOCAL}' '#{REMOTE_DRIVE}/#{BACKUP_NAME}' --progress -v 2>&1"
system(cmd_upload)
if $?.success?
duracion = (Time.now - start).round(2)
puts "[+] Upload completado en #{duracion} segundos"
else
puts "[!] Error en upload (código: #{$?.exitstatus})"
exit 1
end
# Verificar en Drive
puts "\n[2/2] Verificando archivo en Drive..."
cmd_verify = "rclone size '#{REMOTE_DRIVE}/#{BACKUP_NAME}' 2>/dev/null"
output = `#{cmd_verify}`.strip
puts " Tamaño en Drive: #{output}"
# Output para orquestador
resultado = {
paso: 'upload_complete',
drive_ruta: "#{REMOTE_DRIVE}/#{BACKUP_NAME}",
drive_url: "https://drive.google.com/drive/folders/1Tgrn2QYyWf0v0eyY1bSGCLlhiqhtIA2n",
nodo: 'srv-ns8',
duracion: duracion,
timestamp: Time.now.iso8601,
siguiente_paso: 'download_drive'
}
puts "\n✅ Upload completado"
puts " Output: #{resultado.to_json}"
File.write('/tmp/dron_upload_output.json', resultado.to_json)
rescue => e
puts "[!] Error fatal: #{e.message}"
exit 1
end
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# ==========================================================
# adn/tools/cli/drones/orquestador_pipeline.rb
# ==========================================================
# Orquestador de Pipeline DASUTEN — Intercomunicación vía JSON
# ==========================================================
# Flujo: exportar → transferir SMB → upload → download → restaurar → verificar
# Cada dron escribe su output en JSON y el orquestador lo lee
# para decidir el siguiente paso.
# ==========================================================
require 'optparse'
require 'json'
require 'time'
require 'shellwords'
CLI_DIR = File.expand_path(__dir__)
ADN_RUN = File.join(File.dirname(File.dirname(File.dirname(CLI_DIR))), 'tools', 'run')
# Configuración
opciones = {
paso_inicial: 'exportar',
dry_run: false,
timeout: 1800, # 30 min por dron
nodo_bitacora: 'dasuten-pipeline'
}
OptionParser.new do |opts|
opts.banner = "Uso: #{ADN_RUN} dron lanzar --nota 'Pipeline DASUTEN' -- ruby adn/tools/cli/drones/orquestador_pipeline.rb [opciones]"
opts.on("--paso PASO", %w[exportar transferir upload download restaurar verificar], "Comenzar desde este paso") { |p| opciones[:paso_inicial] = p }
opts.on("--dry-run", "Simulación sin ejecutar drones") { opciones[:dry_run] = true }
opts.on("--timeout N", Integer, "Timeout por dron en segundos") { |t| opciones[:timeout] = t }
end.parse!(ARGV)
# Estados del pipeline (6 drones atómicos)
ESTADOS = {
exportar: {
script: 'dasuten_exportar_srvv-fenix.rb',
nodo: 'srvv-fenix',
nota: '📤 Exportar backup DASUTEN',
output_file: '/tmp/dron_export_output.json',
siguiente: :transferir
},
transferir: {
script: 'dasuten_transferir_srvv-fenix-srv-ns8.rb',
nodo: 'srv-ns8',
nota: '🔄 Transferir SMB (fenix → ns8)',
output_file: '/tmp/dron_transfer_output.json',
siguiente: :upload
},
upload: {
script: 'dasuten_upload_srv-ns8-drive.rb',
nodo: 'srv-ns8',
nota: '☁️ Upload a Google Drive',
output_file: '/tmp/dron_upload_output.json',
siguiente: :download
},
download: {
script: 'dasuten_download_drive-dasu-sql4.rb',
nodo: 'dasu-sql4',
nota: '📥 Download desde Google Drive',
output_file: '/tmp/dron_download_output.json',
siguiente: :restaurar
},
restaurar: {
script: 'dasuten_restaurar_dasu-sql4.rb',
nodo: 'dasu-sql4',
nota: '💾 Restaurar backup',
output_file: '/tmp/dron_restore_output.json',
siguiente: :verificar
},
verificar: {
script: 'dasuten_verificar-integridad_dasu-sql4.rb',
nodo: 'dasu-sql4',
nota: '✅ Verificar integridad BD',
output_file: '/tmp/dron_verify_output.json',
siguiente: nil # Final
}
}.freeze
puts "\n" + "="*70
puts "🛸 ORQUESTADOR PIPELINE DASUTEN (Fase 8b — 6 drones atómicos)"
puts "="*70
puts " Paso inicial: #{opciones[:paso_inicial]}"
puts " Dry-run: #{opciones[:dry_run]}"
puts " Timeout: #{opciones[:timeout]}s por dron"
puts " Drones: #{ESTADOS.keys.join(' → ')}"
puts "="*70
# ──────────────────────────────────────────────────────────────
# Función para lanzar un dron y esperar su resultado
# ──────────────────────────────────────────────────────────────
def lanzar_dron(script, nodo, nota, timeout)
cmd = "ruby #{File.join(File.dirname(__FILE__), script)}"
puts "\n" + "-"*70
puts "🛸 Lanzando dron: #{nota}"
puts " Nodo: #{nodo}"
puts " Script: #{script}"
puts "-"*70
# Lanzar dron ejecutor
dron_cmd = "#{ADN_RUN} dron lanzar --nota #{Shellwords.escape(nota)} --nodo #{Shellwords.escape(nodo)} --timeout #{timeout} -- #{cmd}"
puts " Comando: #{dron_cmd}"
if opciones[:dry_run]
puts " [DRY-RUN] No ejecutado"
return true, {}
end
exito = system(dron_cmd)
codigo = $?.exitstatus
if exito
puts "\n✅ Dron completado exitosamente"
# Leer output del dron
output_file = ESTADOS[nodo.to_sym][:output_file]
if File.exist?(output_file)
resultado = JSON.parse(File.read(output_file))
puts " Output: #{resultado.inspect}"
return true, resultado
else
puts " [!] No se encontró archivo de output"
return true, {}
end
else
puts "\n❌ Dron fallido (código: #{codigo})"
return false, {}
end
end
# ──────────────────────────────────────────────────────────────
# Ejecutar pipeline
# ──────────────────────────────────────────────────────────────
paso_actual = opciones[:paso_inicial].to_sym
contexto = {} # Para pasar datos entre drones
duracioniones = []
begin
loop do
break unless ESTADOS[paso_actual]
config = ESTADOS[paso_actual]
# Inyectar contexto del paso anterior si es necesario
case paso_actual
when :upload
# El dron de upload ya sabe dónde está el archivo (hardcodeado en /var/tmp/)
when :download
# El dron de download usa file_id o test_file
when :restaurar
# El dron de restaurar usa la ruta hardcodeada F:\BACKUP\
end
# Lanzar dron
exito, resultado = lanzar_dron(
config[:script],
config[:nodo],
config[:nota],
opciones[:timeout]
)
unless exito
puts "\n❌ Pipeline fallido en paso: #{paso_actual}"
exit 1
end
# Guardar contexto para siguiente paso
contexto.merge!(resultado) if resultado
duraciones << resultado[:duracion] if resultado[:duracion]
# Decidir siguiente paso
if config[:siguiente]
puts "\n➡️ Pasando a: #{config[:siguiente]}"
paso_actual = config[:siguiente]
else
puts "\n✅ Pipeline completado exitosamente!"
break
end
end
# Resumen final
puts "\n" + "="*70
puts "📊 RESUMEN DEL PIPELINE"
puts "="*70
if contexto[:pipeline_completo] || contexto[:estado_integridad] == 'OK'
puts " Estado: ✅ COMPLETADO"
puts " Integridad BD: #{contexto[:estado_integridad] || 'N/A'}"
else
puts " Estado: ⏳ EN PROGRESO"
end
puts " Backup Drive: #{contexto[:drive_ruta] || contexto[:drive_origen] || 'N/A'}"
puts " Duración total: #{duraciones.sum.round(2)}s (#{duraciones.count} drones)"
puts "="*70
rescue => e
puts "\n❌ Error en orquestador: #{e.message}"
puts e.backtrace.first(5).join("\n")
exit 1
end
@@ -0,0 +1,206 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
# ==========================================================
# adn/tools/cli/drones/orquestador_pipeline_diferencial.rb
# ==========================================================
# Orquestador de Pipeline DIFERENCIAL DASUTEN
# ==========================================================
# Flujo: exportar_dif → transferir → upload → download → restaurar_dif → verificar
# Para refrescos diarios (solo cambios desde último backup completo)
# ==========================================================
require 'optparse'
require 'json'
require 'time'
require 'shellwords'
CLI_DIR = File.expand_path(__dir__)
ADN_RUN = File.join(File.dirname(File.dirname(File.dirname(CLI_DIR))), 'tools', 'run')
# Configuración
opciones = {
paso_inicial: 'exportar_dif',
dry_run: false,
timeout: 900, # 15 min por dron (suficiente para diferencial)
nodo_bitacora: 'dasuten-pipeline-dif'
}
OptionParser.new do |opts|
opts.banner = "Uso: #{ADN_RUN} dron lanzar --nota 'Pipeline Diferencial DASUTEN' -- ruby adn/tools/cli/drones/orquestador_pipeline_diferencial.rb [opciones]"
opts.on("--paso PASO", %w[exportar_dif transferir upload download restaurar_dif verificar], "Comenzar desde este paso") { |p| opciones[:paso_inicial] = p }
opts.on("--dry-run", "Simulación sin ejecutar drones") { opciones[:dry_run] = true }
opts.on("--timeout N", Integer, "Timeout por dron en segundos") { |t| opciones[:timeout] = t }
end.parse!(ARGV)
# Estados del pipeline diferencial (6 drones atómicos)
ESTADOS = {
exportar_dif: {
script: 'dasuten_exportar_diferencial_srvv-fenix.rb',
nodo: 'srvv-fenix',
nota: '📤 Exportar backup DIFERENCIAL DASUTEN',
output_file: '/tmp/dron_export_dif_output.json',
siguiente: :transferir
},
transferir: {
script: 'dasuten_transferir_srvv-fenix-srv-ns8.rb',
nodo: 'srv-ns8',
nota: '🔄 Transferir SMB (fenix → ns8)',
output_file: '/tmp/dron_transfer_output.json',
siguiente: :upload
},
upload: {
script: 'dasuten_upload_srv-ns8-drive.rb',
nodo: 'srv-ns8',
nota: '☁️ Upload a Google Drive',
output_file: '/tmp/dron_upload_output.json',
siguiente: :download
},
download: {
script: 'dasuten_download_drive-dasu-sql4.rb',
nodo: 'dasu-sql4',
nota: '📥 Download desde Google Drive',
output_file: '/tmp/dron_download_output.json',
siguiente: :restaurar_dif
},
restaurar_dif: {
script: 'dasuten_restaurar_diferencial_dasu-sql4.rb',
nodo: 'dasu-sql4',
nota: '💾 Restaurar backup DIFERENCIAL',
output_file: '/tmp/dron_restore_dif_output.json',
siguiente: :verificar
},
verificar: {
script: 'dasuten_verificar-integridad_dasu-sql4.rb',
nodo: 'dasu-sql4',
nota: '✅ Verificar integridad BD',
output_file: '/tmp/dron_verify_output.json',
siguiente: nil # Final
}
}.freeze
puts "\n" + "="*70
puts "🛸 ORQUESTADOR PIPELINE DIFERENCIAL DASUTEN (Refresco diario)"
puts "="*70
puts " Paso inicial: #{opciones[:paso_inicial]}"
puts " Dry-run: #{opciones[:dry_run]}"
puts " Timeout: #{opciones[:timeout]}s por dron"
puts " Drones: #{ESTADOS.keys.join(' → ')}"
puts "="*70
puts ""
puts " ️ Este pipeline usa backup DIFERENCIAL (solo cambios)"
puts " Esperado: ~5-10 min total (vs ~35 min del completo)"
puts "="*70
# ──────────────────────────────────────────────────────────────
# Función para lanzar un dron y esperar su resultado
# ──────────────────────────────────────────────────────────────
def lanzar_dron(script, nodo, nota, timeout, dry_run)
cmd = "ruby #{File.join(File.dirname(__FILE__), script)}"
puts "\n" + "-"*70
puts "🛸 Lanzando dron: #{nota}"
puts " Nodo: #{nodo}"
puts " Script: #{script}"
puts "-"*70
# Lanzar dron ejecutor
dron_cmd = "#{ADN_RUN} dron lanzar --nota #{Shellwords.escape(nota)} --nodo #{Shellwords.escape(nodo)} --timeout #{timeout} -- #{cmd}"
puts " Comando: #{dron_cmd}"
if dry_run
puts " [DRY-RUN] No ejecutado"
return true, {}
end
exito = system(dron_cmd)
codigo = $?.exitstatus
if exito
puts "\n✅ Dron completado exitosamente"
# Leer output del dron (buscar en todos los archivos posibles)
output_files = ['/tmp/dron_export_dif_output.json', '/tmp/dron_transfer_output.json',
'/tmp/dron_upload_output.json', '/tmp/dron_download_output.json',
'/tmp/dron_restore_dif_output.json', '/tmp/dron_verify_output.json']
output_file = output_files.find { |f| File.exist?(f) }
if output_file && File.exist?(output_file)
resultado = JSON.parse(File.read(output_file))
puts " Output: #{resultado.inspect}"
return true, resultado
else
puts " [!] No se encontró archivo de output"
return true, {}
end
else
puts "\n❌ Dron fallido (código: #{codigo})"
return false, {}
end
end
# ──────────────────────────────────────────────────────────────
# Ejecutar pipeline
# ──────────────────────────────────────────────────────────────
paso_actual = opciones[:paso_inicial].to_sym
contexto = {} # Para pasar datos entre drones
duraciones = []
begin
loop do
break unless ESTADOS[paso_actual]
config = ESTADOS[paso_actual]
# Lanzar dron
exito, resultado = lanzar_dron(
config[:script],
config[:nodo],
config[:nota],
opciones[:timeout],
opciones[:dry_run]
)
unless exito
puts "\n❌ Pipeline fallido en paso: #{paso_actual}"
exit 1
end
# Guardar contexto para siguiente paso
contexto.merge!(resultado) if resultado
duraciones << resultado[:duracion] if resultado[:duracion]
# Decidir siguiente paso
if config[:siguiente]
puts "\n➡️ Pasando a: #{config[:siguiente]}"
paso_actual = config[:siguiente]
else
puts "\n✅ Pipeline completado exitosamente!"
break
end
end
# Resumen final
puts "\n" + "="*70
puts "📊 RESUMEN DEL PIPELINE DIFERENCIAL"
puts "="*70
if contexto[:pipeline_completo] || contexto[:estado_integridad] == 'OK'
puts " Estado: ✅ COMPLETADO"
puts " Integridad BD: #{contexto[:estado_integridad] || 'N/A'}"
else
puts " Estado: ⏳ EN PROGRESO"
end
puts " Backup Drive: #{contexto[:drive_ruta] || contexto[:drive_origen] || 'N/A'}"
puts " Duración total: #{duraciones.sum.round(2)}s (#{duraciones.count} drones)"
puts " Tipo: #{contexto[:backup_tipo] || 'differential'}"
puts "="*70
rescue => e
puts "\n❌ Error en orquestador: #{e.message}"
puts e.backtrace.first(5).join("\n")
exit 1
end
+82 -1
View File
@@ -2,6 +2,12 @@
# adn/tools/cli/nodos/info.rb — Utilidades para extracción de metadatos de fichas de nodos
# ======================================================================================
#
# Fase 2.5: Soporte dual — Tabla canónica (META:BEGIN/META:END) + fallback regex
#
# Prioridad de lectura:
# 1. Tabla canónica entre <!-- META:BEGIN --> y <!-- META:END -->
# 2. Regex heurísticas (legacy, para fichas no migradas)
require_relative '../../core/constants'
@@ -12,6 +18,30 @@ module ADN
return nil unless File.exist?(ruta)
contenido = File.read(ruta, encoding: 'UTF-8')
# ─── Intentar tabla canónica primero ──────────────────────
meta = extraer_meta_canonica(contenido)
if meta
return {
nombre: nodo_nombre,
ip: meta['IP'],
ip_lan: detectar_ip_lan(contenido),
host: meta['Host'],
so: detectar_so_desde_valor(meta['OS']),
vmid: meta['VMID'],
puerto_ssh: extraer_puerto_de_ssh(meta['SSH']),
usuario: extraer_usuario_de_ssh(meta['SSH']),
clave_boveda: meta['Bóveda'],
auth_type: meta['Auth']&.to_sym,
clave_ssh: meta['Clave SSH'],
ssh_opts: meta['SSH Opts'],
ring: meta['Ring'],
estado: meta['Estado'],
_fuente: :canonica
}
end
# ─── Fallback: Regex heurísticas (legacy) ─────────────────
{
nombre: nodo_nombre,
ip: detectar_ip(contenido),
@@ -24,7 +54,10 @@ module ADN
clave_boveda: detectar_clave_boveda(contenido),
auth_type: detectar_auth_type(contenido),
clave_ssh: detectar_clave_ssh(contenido),
ssh_opts: detectar_ssh_opts(contenido)
ssh_opts: detectar_ssh_opts(contenido),
ring: nil,
estado: nil,
_fuente: :regex
}
end
@@ -35,6 +68,53 @@ module ADN
detectar_ip_lan(contenido)
end
# ─── Parser de tabla canónica ──────────────────────────────
def self.extraer_meta_canonica(contenido)
match = contenido.match(/<!-- META:BEGIN -->(.*?)<!-- META:END -->/m)
return nil unless match
meta = {}
match[1].each_line do |linea|
# | **Campo** | `valor` | o | **Campo** | valor |
if linea =~ /\|\s*\*\*(.+?)\*\*\s*\|\s*`?([^`|]+?)`?\s*\|/
campo = $1.strip
valor = $2.strip
meta[campo] = valor unless valor.empty?
end
end
meta.empty? ? nil : meta
end
def self.extraer_usuario_de_ssh(ssh_str)
return nil unless ssh_str
# Formato: user@ip:port
if ssh_str =~ /^([a-zA-Z0-9_\-\.]+)@/
return $1
end
nil
end
def self.extraer_puerto_de_ssh(ssh_str)
return 22 unless ssh_str
# Formato: user@ip:port
if ssh_str =~ /:(\d+)$/
return $1.to_i
end
22
end
def self.detectar_so_desde_valor(os_str)
return :linux unless os_str
return :linux if os_str =~ /linux|ubuntu|debian|centos|rocky|proxmox|xen/i
return :windows if os_str =~ /windows|server|win/i
return :firmware if os_str =~ /firmware|hikvision|dvr|mikrotik|routeros/i
:linux
end
# ─── Regex heurísticas (legacy — para fichas sin META) ─────
def self.detectar_usuario(contenido)
# **SSH**: user@ip:port o DOMAIN\user@ip:port
if contenido =~ /\*\*SSH\*\*:\s*`?([^`\s(]+)/i
@@ -182,3 +262,4 @@ module ADN
end
end
end
+16 -5
View File
@@ -21,7 +21,7 @@ require_relative 'nodos/info'
module ADN
class SubcomandoSSH
CANDADOS_PATH = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'seguridad', 'candados.rb')
CANDADOS_PATH = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb')
def initialize(args, logger)
@args = args
@@ -261,17 +261,28 @@ module ADN
if proxy_config
@logger.info("Via ProxyJump #{config[:proxy]} (#{proxy_config[:ip]})")
proxy_passphrase = obtener_secreto(proxy_config[:clave_boveda])
# ProxyCommand con sshpass: el proxy usa passphrase RSA,
# el destino usa password. Todo se ejecuta localmente via bash.
# Determinar comando ProxyCommand según auth_type del proxy
proxy_cmd = case proxy_config[:auth_type]
when :passphrase
proxy_passphrase = obtener_secreto(proxy_config[:clave_boveda])
"sshpass -P passphrase -p '#{proxy_passphrase}' ssh -o StrictHostKeyChecking=no -p #{proxy_config[:puerto_ssh]} -W %h:%p #{proxy_config[:usuario]}@#{proxy_config[:ip]}"
when :key, :rsa
# Proxy usa clave SSH sin passphrase
"ssh -o StrictHostKeyChecking=no -p #{proxy_config[:puerto_ssh]} -W %h:%p #{proxy_config[:usuario]}@#{proxy_config[:ip]}"
else
# Default: ssh sin passphrase
"ssh -o StrictHostKeyChecking=no -p #{proxy_config[:puerto_ssh]} -W %h:%p #{proxy_config[:usuario]}@#{proxy_config[:ip]}"
end
# ProxyCommand con sshpass: el proxy usa clave/passphrase, el destino usa password
script = <<~BASH
export SSHPASS=#{password.shellescape}
sshpass -e ssh \
-o StrictHostKeyChecking=no \
-o ConnectTimeout=30 \
-o ServerAliveInterval=5 \
-o ProxyCommand="sshpass -P passphrase -p '#{proxy_passphrase}' ssh -o StrictHostKeyChecking=no -p #{proxy_config[:puerto_ssh]} -W %h:%p #{proxy_config[:usuario]}@#{proxy_config[:ip]}" \
-o ProxyCommand="#{proxy_cmd}" \
-p #{config[:puerto_ssh]} \
'#{ssh_target}' \
"#{comando_remoto.gsub('"', '\\"')}"