refactor(ambitos): Migración completa dtic-DASUTEN (A04) y dtic-BKPs (A03) + herramientas ADN
Migración dtic-DASUTEN a estándar A04: - A04_dtic-DASUTEN.md: Manifiesto creado - A04.P001 a A04.P005: Planes renombrados de P2601.* - _hist/_hist_P2601_dasuten.md: Legacy archivado Migración dtic-BKPs a estándar A03: - A03_dtic-BKPs.md: Manifiesto creado - A03.P001, A03.P002: Planes migrados - docs/ambito/dtic-BKPs/: Nueva estructura Herramientas ADN nuevas: - adn/tools/bkps/: BKPs migrado a tools ADN - adn/tools/cli/dron.rb: Vigía de tareas largas - adn/tools/cli/copiloto.rb: Asistente CLI - adn/tools/cli/novato.rb: CLI de aprendizaje Limpieza: - dtic-BKPs/ → _hist/dtic-BKPs/ (legacy archivado) - adn/tools/cli/backup.rb → _hist/ (migrado a bkps.rb) Otros: - nodos/pcv-dasu1.md, srvv-data.md, srvv-nginx-rm.md: Actualizados - adn/tools/core/conciliador.rb: Mejoras - adn/tools/w-zombi/data/cmd.json: Actualizado Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
018e45e046
commit
1802c25194
@@ -0,0 +1,825 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/bkps/bkps.rb — CLI BKPs para ecosistema ADN
|
||||
# ----------------------------------------------------------
|
||||
# Migrado desde dtic-BKPs v6.0 (A03.P002)
|
||||
# Uso: ./adn/tools/run bkps <subcomando>
|
||||
# ==========================================================
|
||||
|
||||
require 'optparse'
|
||||
require 'shellwords'
|
||||
require 'json'
|
||||
require 'date'
|
||||
require 'time'
|
||||
|
||||
require_relative 'lib/config'
|
||||
require_relative 'lib/proc_proxmox'
|
||||
require_relative 'lib/proc_xen'
|
||||
require_relative 'lib/sync_rclone'
|
||||
require_relative 'lib/menu'
|
||||
|
||||
module ADN
|
||||
class SubcomandoBkps
|
||||
BKPS_DIR = File.dirname(File.expand_path(__FILE__))
|
||||
CONFIG_PATH = File.join(BKPS_DIR, 'bkps.yml')
|
||||
|
||||
TIPOS = {
|
||||
'proxmox' => -> (log) { BKPs::Procesador::Proxmox.new(log) },
|
||||
'xva' => -> (log) { BKPs::Procesador::Xen.new(log) },
|
||||
'rclone' => -> (log) { BKPs::Procesador::Rclone.new(log) },
|
||||
}.freeze
|
||||
|
||||
def initialize(args, logger = nil)
|
||||
@args = args
|
||||
@logger = logger || ADN::Logger.new
|
||||
@config = BKPs::Config.new(CONFIG_PATH)
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || @args.first == 'help' || @args.first == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
comando = @args.shift
|
||||
case comando
|
||||
when 'list'
|
||||
cmd_list
|
||||
when 'run'
|
||||
cmd_run(@args)
|
||||
when 'menu'
|
||||
cmd_menu
|
||||
when 'status'
|
||||
cmd_status
|
||||
when 'estados'
|
||||
cmd_estados
|
||||
when 'sanear'
|
||||
cmd_sanear(@args)
|
||||
when 'backup'
|
||||
cmd_backup(@args)
|
||||
when 'buscar'
|
||||
cmd_buscar(@args)
|
||||
else
|
||||
puts "#{Color::RED}✗ Subcomando desconocido en bkps: #{comando}#{Color::RESET}"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ─── Subcomandos ─────────────────────────────────────────────
|
||||
|
||||
def cmd_buscar(args)
|
||||
nodo = args.shift
|
||||
unless nodo
|
||||
puts "#{Color::RED}✗ Falta nombre de nodo#{Color::RESET}"
|
||||
puts "Uso: ./adn/tools/run bkps buscar <nodo>"
|
||||
return
|
||||
end
|
||||
|
||||
retencion = @config.raw_config['retencion'] || {}
|
||||
tiers = retencion['tiers'] || []
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb')
|
||||
vmid_map = cargar_mapa_vmid
|
||||
vmid_inverso = vmid_map.invert # nodo => vmid
|
||||
|
||||
# Normalizar búsqueda (case insensitive)
|
||||
patron = nodo.downcase
|
||||
total = 0
|
||||
|
||||
puts "\n#{Color::BOLD}#{Color::CYAN}🔍 Backups de '#{nodo}' en todos los tiers#{Color::RESET}\n"
|
||||
|
||||
tiers.each_with_index do |tier, idx|
|
||||
tier_num = idx + 1
|
||||
resultados = []
|
||||
|
||||
case tier['tipo']
|
||||
when 'proxmox'
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
storage = tier['storage']
|
||||
hosts = tier['hosts'] || []
|
||||
ruta_dump = "/mnt/pve/#{storage}/dump"
|
||||
|
||||
# Buscar VMID del nodo
|
||||
vmid = vmid_inverso[nodo] || vmid_inverso.find { |k, _| k.downcase.include?(patron) }&.last
|
||||
|
||||
if vmid
|
||||
hosts.each do |h|
|
||||
cmd_ls = "ls -lh #{ruta_dump}/vzdump-*#{vmid}* 2>/dev/null"
|
||||
res = `ruby #{candados_path} run admindasu SSHPASS 'sshpass -e ssh -o StrictHostKeyChecking=no root@#{h['ip']} "#{cmd_ls}"' 2>&1`
|
||||
unless res.include?('No such file') || res.strip.empty? || res.include?('Connection')
|
||||
res.split("\n").each do |linea|
|
||||
next if linea.empty? || linea.include?('OPTIONS') || linea.include?('total')
|
||||
partes = linea.split(/\s+/)
|
||||
next if partes.length < 9
|
||||
size = partes[4]
|
||||
fecha = "#{partes[5]} #{partes[6]} #{partes[7]}"
|
||||
nombre = File.basename(partes[8..-1].join(' '))
|
||||
resultados << { nombre: nombre, size: size, fecha: fecha }
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
|
||||
when 'local'
|
||||
ruta_base = tier['ruta']
|
||||
# Buscar directorio del nodo (case insensitive)
|
||||
Dir.glob("#{ruta_base}/*").each do |dir|
|
||||
dir_name = File.basename(dir)
|
||||
next unless dir_name.downcase.include?(patron)
|
||||
Dir.glob("#{dir}/*.{tar.gz,tar.zst,vma.zst}").sort_by { |f| File.mtime(f) }.each do |archivo|
|
||||
nombre = File.basename(archivo)
|
||||
size = format_size(File.size(archivo))
|
||||
fecha = File.mtime(archivo).strftime('%Y-%m-%d %H:%M')
|
||||
resultados << { nombre: nombre, size: size, fecha: fecha }
|
||||
end
|
||||
end
|
||||
|
||||
when 'rclone'
|
||||
remote = tier['remote']
|
||||
# Buscar directorio del nodo
|
||||
output = `rclone lsl '#{remote}' --max-depth 2 2>&1`
|
||||
output.split("\n").each do |linea|
|
||||
partes = linea.strip.split(/\s+/, 4)
|
||||
next if partes.length < 4
|
||||
ruta_rel = partes[3]
|
||||
next unless ruta_rel.include?('/')
|
||||
dir_name = ruta_rel.split('/').first
|
||||
next unless dir_name.downcase.include?(patron)
|
||||
nombre = File.basename(ruta_rel)
|
||||
next unless nombre.match?(/\.(tar\.gz|tar\.zst|vma\.zst)$/)
|
||||
size_bytes = partes[0].to_i
|
||||
fecha = "#{partes[1]} #{partes[2].split('.').first}"
|
||||
resultados << { nombre: nombre, size: format_size(size_bytes), fecha: fecha }
|
||||
end
|
||||
end
|
||||
|
||||
# Mostrar
|
||||
puts "#{Color::BOLD}Tier #{tier_num}: #{tier['nombre']}#{Color::RESET}"
|
||||
if resultados.empty?
|
||||
puts " #{Color::DIM}(sin backups)#{Color::RESET}"
|
||||
else
|
||||
resultados.each do |r|
|
||||
puts " #{Color::GREEN}✔#{Color::RESET} #{r[:fecha]} #{r[:size].rjust(8)} #{r[:nombre]}"
|
||||
end
|
||||
total += resultados.length
|
||||
end
|
||||
puts ""
|
||||
end
|
||||
|
||||
puts "#{Color::BOLD}Total: #{total} archivo(s) en #{tiers.length} tiers#{Color::RESET}"
|
||||
end
|
||||
|
||||
def format_size(bytes)
|
||||
return '0B' if bytes == 0
|
||||
units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
exp = (Math.log(bytes) / Math.log(1024)).to_i
|
||||
exp = units.length - 1 if exp >= units.length
|
||||
"%.1f%s" % [bytes.to_f / (1024 ** exp), units[exp]]
|
||||
end
|
||||
def cmd_list
|
||||
puts "#{Color::BOLD}Tareas:#{Color::RESET}"
|
||||
@config.tareas.each_with_index do |t, i|
|
||||
puts " T#{i+1} #{t[:id]} #{t[:texto]} [#{t[:tipo]}] #{t[:origen]} → #{t[:destino]}"
|
||||
end
|
||||
puts "\n#{Color::BOLD}Comandos:#{Color::RESET}"
|
||||
@config.comandos.each_with_index do |c, i|
|
||||
puts " C#{i+1} #{c[:id]} #{c[:texto]} → #{c[:tareas]&.join(', ')}"
|
||||
end
|
||||
end
|
||||
|
||||
def cmd_run(args)
|
||||
options = {}
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run bkps run <T1|C1|ID> [opciones]"
|
||||
opts.on("--batch", "Modo batch (sin interacción, para cron)") { options[:batch] = true }
|
||||
opts.on("--dry-run", "Simulación sin ejecutar") { options[:dry_run] = true }
|
||||
end.parse!(args)
|
||||
|
||||
ref = args.shift
|
||||
unless ref
|
||||
puts "#{Color::RED}✗ Falta referencia (T1, C1, o ID de tarea/comando)#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
case ref.upcase
|
||||
when /^T(\d+)$/
|
||||
idx = $1.to_i - 1
|
||||
tarea = @config.tareas[idx]
|
||||
if tarea
|
||||
ejecutar_tarea(tarea, options)
|
||||
else
|
||||
puts "#{Color::RED}✗ Tarea T#{idx+1} no encontrada#{Color::RESET}"
|
||||
end
|
||||
when /^C(\d+)$/
|
||||
idx = $1.to_i - 1
|
||||
comando = @config.comandos[idx]
|
||||
if comando
|
||||
ejecutar_comando(comando, options)
|
||||
else
|
||||
puts "#{Color::RED}✗ Comando C#{idx+1} no encontrado#{Color::RESET}"
|
||||
end
|
||||
else
|
||||
# Buscar por ID textual
|
||||
tarea = @config.find_tarea(ref)
|
||||
if tarea
|
||||
ejecutar_tarea(tarea, options)
|
||||
else
|
||||
comando = @config.comandos.find { |c| c[:id].to_s == ref }
|
||||
if comando
|
||||
ejecutar_comando(comando, options)
|
||||
else
|
||||
puts "#{Color::RED}✗ '#{ref}' no es una tarea ni comando válido#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cmd_menu
|
||||
# Auto-screen para sesiones desatendidas
|
||||
auto_screen! unless ENV['TERM']&.start_with?('screen') || ENV['STY']
|
||||
BKPs::Menu.new(@config, self, @logger).ejecutar
|
||||
end
|
||||
|
||||
def cmd_status
|
||||
puts "#{Color::BOLD}#{Color::CYAN}dtic-BKPs — Estado del Sistema#{Color::RESET}\n\n"
|
||||
puts "#{Color::BOLD}Configuración:#{Color::RESET} #{CONFIG_PATH}"
|
||||
puts "#{Color::BOLD}Auto-avanzar:#{Color::RESET} #{@config.auto_avanzar}"
|
||||
puts "#{Color::BOLD}Tareas:#{Color::RESET} #{@config.tareas.length}"
|
||||
puts "#{Color::BOLD}Comandos:#{Color::RESET} #{@config.comandos.length}"
|
||||
puts ""
|
||||
|
||||
# Verificar herramientas
|
||||
tools = { 'rclone' => 'rclone version', 'pv' => 'pv --version', 'tar' => 'tar --version' }
|
||||
puts "#{Color::BOLD}Herramientas:#{Color::RESET}"
|
||||
tools.each do |name, cmd|
|
||||
disponible = system("#{cmd} > /dev/null 2>&1")
|
||||
icono = disponible ? "#{Color::GREEN}✔#{Color::RESET}" : "#{Color::RED}✖#{Color::RESET}"
|
||||
puts " #{icono} #{name}"
|
||||
end
|
||||
|
||||
# Reporte de espacio en disco
|
||||
monitoreo = @config.raw_config['monitoreo']
|
||||
if monitoreo && monitoreo['rutas_espacio']
|
||||
puts "\n#{Color::BOLD}📊 Espacio en Disco#{Color::RESET}"
|
||||
puts "─" * 70
|
||||
printf " %-20s %8s %8s %8s %6s\n", 'Ruta', 'Total', 'Usado', 'Libre', 'Uso%'
|
||||
puts " #{"─" * 64}"
|
||||
|
||||
monitoreo['rutas_espacio'].each do |r|
|
||||
ruta = r['ruta']
|
||||
if File.exist?(ruta)
|
||||
df = `df -h '#{ruta}' 2>/dev/null`.lines.last&.split
|
||||
if df && df.length >= 5
|
||||
printf " %-20s %8s %8s %8s %6s\n", r['nombre'], df[1], df[2], df[3], df[4]
|
||||
end
|
||||
else
|
||||
printf " %-20s %8s\n", r['nombre'], "#{Color::RED}✖ no montado#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Retención configurada
|
||||
retencion = @config.raw_config['retencion']
|
||||
if retencion
|
||||
puts "\n#{Color::BOLD}🗑️ Retención#{Color::RESET}"
|
||||
puts " Días default: #{retencion['dias_default']}"
|
||||
puts " Storage: #{retencion['storage']}"
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Subcomandos asimilados de backup.rb (F4) ────────────────
|
||||
|
||||
def cmd_estados
|
||||
@logger.info("Consultando estado de backups en cluster Proxmox")
|
||||
|
||||
hosts = [
|
||||
{ nombre: 'srv-pmox1', ip: '10.0.10.201' },
|
||||
{ nombre: 'srv-dasu', ip: '100.116.210.36' },
|
||||
]
|
||||
|
||||
puts "\n#{Color::BOLD}🔍 Estado de Backups en Tiempo Real (Proxmox)#{Color::RESET}"
|
||||
puts "─" * 60
|
||||
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb')
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
hosts.each do |h|
|
||||
print "📡 #{h[:nombre]} (#{h[:ip]})... "
|
||||
|
||||
cmd = "/usr/bin/pvesh get /cluster/tasks --output-format json"
|
||||
cmd_seguro = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{h[:ip]} '#{cmd}'"
|
||||
clave = (h[:nombre] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
output = `ruby #{candados_path} run #{clave} SSHPASS '#{cmd_seguro}' 2>&1`
|
||||
|
||||
if output.include?('[')
|
||||
begin
|
||||
json_str = output[output.index('[')..output.rindex(']')]
|
||||
tasks = JSON.parse(json_str)
|
||||
activas = tasks.select { |t| t['type'] == 'vzdump' && t['endtime'].nil? }
|
||||
|
||||
puts "#{Color::GREEN}OK#{Color::RESET}"
|
||||
if activas.empty?
|
||||
puts " #{Color::DIM}Sin tareas de backup activas.#{Color::RESET}"
|
||||
else
|
||||
activas.each do |t|
|
||||
puts " ▶ #{Color::CYAN}#{t['id']}#{Color::RESET} [#{t['status'] || 'ejecutando'}] (Inicio: #{Time.at(t['starttime']).strftime('%H:%M:%S')})"
|
||||
end
|
||||
end
|
||||
|
||||
# Últimos 5 vzdump completados
|
||||
recientes = tasks.select { |t| t['type'] == 'vzdump' && t['endtime'] }
|
||||
.sort_by { |t| -t['endtime'] }.first(5)
|
||||
unless recientes.empty?
|
||||
puts " #{Color::DIM}Últimos completados:#{Color::RESET}"
|
||||
recientes.each do |t|
|
||||
estado = t['status'] == 'OK' ? "#{Color::GREEN}✔#{Color::RESET}" : "#{Color::RED}✖#{Color::RESET}"
|
||||
puts " #{estado} #{Time.at(t['starttime']).strftime('%H:%M')}→#{Time.at(t['endtime']).strftime('%H:%M')} #{t['id']}"
|
||||
end
|
||||
end
|
||||
rescue => e
|
||||
puts "#{Color::YELLOW}JSON ERROR: #{e.message}#{Color::RESET}"
|
||||
end
|
||||
else
|
||||
puts "#{Color::RED}ERROR#{Color::RESET}"
|
||||
puts " #{Color::DIM}No se pudo conectar o sin tareas.#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
end
|
||||
|
||||
def cmd_sanear(args)
|
||||
retencion = @config.raw_config['retencion'] || {}
|
||||
options = { dias: retencion['dias_default'] || 6 }
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run bkps sanear [opciones]"
|
||||
opts.on("--dias N", Integer, "Días de retención (default: #{options[:dias]})") { |d| options[:dias] = d }
|
||||
opts.on("--tier N", Integer, "Solo tier N (1=proxmox, 2=local, 3=nube)") { |t| options[:tier] = t }
|
||||
opts.on("--dry-run", "Solo listar, no borrar") { options[:dry_run] = true }
|
||||
end.parse!(args)
|
||||
|
||||
tiers = retencion['tiers'] || []
|
||||
if tiers.empty?
|
||||
@logger.error("No hay tiers configurados en retencion.tiers")
|
||||
return
|
||||
end
|
||||
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb')
|
||||
vmid_map = cargar_mapa_vmid
|
||||
total_eliminados = 0
|
||||
total_protegidos = 0
|
||||
|
||||
tiers.each_with_index do |tier, idx|
|
||||
tier_num = idx + 1
|
||||
next if options[:tier] && options[:tier] != tier_num
|
||||
|
||||
puts "\n#{Color::BOLD}#{Color::CYAN}═══ Tier #{tier_num}: #{tier['nombre']} ═══#{Color::RESET}"
|
||||
|
||||
case tier['tipo']
|
||||
when 'proxmox'
|
||||
elim, prot = sanear_tier_proxmox(tier, options, candados_path, vmid_map)
|
||||
when 'local'
|
||||
elim, prot = sanear_tier_local(tier, options)
|
||||
when 'rclone'
|
||||
elim, prot = sanear_tier_rclone(tier, options)
|
||||
else
|
||||
@logger.error("Tipo de tier desconocido: #{tier['tipo']}")
|
||||
next
|
||||
end
|
||||
|
||||
total_eliminados += (elim || 0)
|
||||
total_protegidos += (prot || 0)
|
||||
end
|
||||
|
||||
puts "\n#{Color::BOLD}═══ Resumen ═══#{Color::RESET}"
|
||||
puts " Eliminados: #{total_eliminados} sets"
|
||||
puts " 🛡️ Protegidos: #{total_protegidos} nodo(s)"
|
||||
end
|
||||
|
||||
# ─── Tier 1: Proxmox (SSH) ─────────────────────────────────
|
||||
def sanear_tier_proxmox(tier, options, candados_path, vmid_map)
|
||||
dias = options[:dias]
|
||||
storage = tier['storage']
|
||||
hosts = tier['hosts'] || []
|
||||
ruta_dump = "/mnt/pve/#{storage}/dump"
|
||||
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
output_ls = ""
|
||||
hosts.each do |h|
|
||||
cmd_ls = "ls -1 --full-time #{ruta_dump}/vzdump-*"
|
||||
res = `ruby #{candados_path} run admindasu SSHPASS 'sshpass -e ssh -o StrictHostKeyChecking=no root@#{h['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')
|
||||
puts "#{Color::GREEN}✓ Sin backups en #{ruta_dump}#{Color::RESET}"
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
return [0, 0]
|
||||
end
|
||||
|
||||
# Parsear archivos vzdump
|
||||
todos = {}
|
||||
por_nodo = {}
|
||||
limite = Time.now - (dias * 86400)
|
||||
|
||||
output_ls.split("\n").each do |linea|
|
||||
next if linea.empty? || linea.include?('OPTIONS')
|
||||
partes = linea.split(/\s+/)
|
||||
next if partes.length < 9
|
||||
fecha_str = "#{partes[5]} #{partes[6]}"
|
||||
ruta = partes[8..-1].join(' ')
|
||||
nombre = File.basename(ruta)
|
||||
fp = nombre.sub(/\.(log|vma\.zst|tar\.zst|vma\.zst\.notes|tar\.zst\.notes)$/, '')
|
||||
begin
|
||||
mtime = Time.parse(fecha_str)
|
||||
vmid = fp.match(/vzdump-(?:qemu|lxc)-(\d+)-/)&.[](1)
|
||||
nodo = vmid_map[vmid] || "VM-#{vmid}"
|
||||
todos[fp] ||= { fecha: mtime, nodo: nodo, archivos: [] }
|
||||
todos[fp][:archivos] << ruta
|
||||
por_nodo[nodo] ||= []
|
||||
por_nodo[nodo] << fp unless por_nodo[nodo].include?(fp)
|
||||
rescue; next; end
|
||||
end
|
||||
|
||||
elim, prot = aplicar_retencion(todos, por_nodo, limite, options)
|
||||
|
||||
# Borrar via SSH
|
||||
if elim > 0 && !options[:dry_run]
|
||||
todos.select { |fp, _| !protegido?(fp, por_nodo, todos) && todos[fp][:fecha] < limite }.each_value do |info|
|
||||
info[:archivos].each do |ruta|
|
||||
system("ruby #{candados_path} run admindasu SSHPASS 'sshpass -e ssh -o StrictHostKeyChecking=no root@#{hosts.first['ip']} \"rm -f '#{ruta}'\"' > /dev/null 2>&1")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
[elim, prot]
|
||||
end
|
||||
|
||||
# ─── Tier 2: Local (filesystem) ────────────────────────────
|
||||
def sanear_tier_local(tier, options)
|
||||
dias = options[:dias]
|
||||
ruta_base = tier['ruta']
|
||||
|
||||
unless File.directory?(ruta_base)
|
||||
puts "#{Color::RED}✗ Ruta no encontrada: #{ruta_base}#{Color::RESET}"
|
||||
return [0, 0]
|
||||
end
|
||||
|
||||
todos = {}
|
||||
por_nodo = {}
|
||||
limite = Time.now - (dias * 86400)
|
||||
|
||||
Dir.glob("#{ruta_base}/*").select { |d| File.directory?(d) }.each do |dir_nodo|
|
||||
nodo = File.basename(dir_nodo)
|
||||
Dir.glob("#{dir_nodo}/*.{tar.gz,tar.zst,vma.zst}").each do |archivo|
|
||||
nombre = File.basename(archivo)
|
||||
mtime = File.mtime(archivo)
|
||||
todos[nombre] ||= { fecha: mtime, nodo: nodo, archivos: [] }
|
||||
todos[nombre][:archivos] << archivo
|
||||
por_nodo[nodo] ||= []
|
||||
por_nodo[nodo] << nombre unless por_nodo[nodo].include?(nombre)
|
||||
end
|
||||
end
|
||||
|
||||
elim, prot = aplicar_retencion(todos, por_nodo, limite, options)
|
||||
|
||||
# Borrar archivos locales
|
||||
if elim > 0 && !options[:dry_run]
|
||||
candidatos = calcular_candidatos(todos, por_nodo, limite)
|
||||
candidatos.each_value do |info|
|
||||
info[:archivos].each { |f| File.delete(f) rescue nil }
|
||||
end
|
||||
end
|
||||
|
||||
[elim, prot]
|
||||
end
|
||||
|
||||
# ─── Tier 3: Nube (rclone) ─────────────────────────────────
|
||||
def sanear_tier_rclone(tier, options)
|
||||
dias = options[:dias]
|
||||
remote = tier['remote']
|
||||
|
||||
output = `rclone lsl '#{remote}' --max-depth 2 2>&1`
|
||||
if output.include?('ERROR') || output.strip.empty?
|
||||
puts "#{Color::GREEN}✓ Sin backups en #{remote}#{Color::RESET}"
|
||||
return [0, 0]
|
||||
end
|
||||
|
||||
todos = {}
|
||||
por_nodo = {}
|
||||
limite = Time.now - (dias * 86400)
|
||||
|
||||
output.split("\n").each do |linea|
|
||||
# rclone lsl format: SIZE DATE TIME PATH
|
||||
partes = linea.strip.split(/\s+/, 4)
|
||||
next if partes.length < 4
|
||||
fecha_str = "#{partes[1]} #{partes[2]}"
|
||||
ruta_rel = partes[3]
|
||||
next unless ruta_rel.include?('/')
|
||||
|
||||
nodo = ruta_rel.split('/').first
|
||||
nombre = File.basename(ruta_rel)
|
||||
next unless nombre.match?(/\.(tar\.gz|tar\.zst|vma\.zst)$/)
|
||||
|
||||
begin
|
||||
mtime = Time.parse(fecha_str)
|
||||
ruta_full = "#{remote}/#{ruta_rel}"
|
||||
todos[nombre] ||= { fecha: mtime, nodo: nodo, archivos: [] }
|
||||
todos[nombre][:archivos] << ruta_full
|
||||
por_nodo[nodo] ||= []
|
||||
por_nodo[nodo] << nombre unless por_nodo[nodo].include?(nombre)
|
||||
rescue; next; end
|
||||
end
|
||||
|
||||
elim, prot = aplicar_retencion(todos, por_nodo, limite, options)
|
||||
|
||||
# Borrar via rclone
|
||||
if elim > 0 && !options[:dry_run]
|
||||
candidatos = calcular_candidatos(todos, por_nodo, limite)
|
||||
candidatos.each_value do |info|
|
||||
info[:archivos].each do |ruta|
|
||||
system("rclone deletefile '#{ruta}' > /dev/null 2>&1")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
[elim, prot]
|
||||
end
|
||||
|
||||
# ─── Lógica compartida de retención ────────────────────────
|
||||
def aplicar_retencion(todos, por_nodo, limite, options)
|
||||
protegidos_fps = {}
|
||||
por_nodo.each do |nodo, nombres|
|
||||
ordenados = nombres.sort_by { |n| -(todos[n][:fecha].to_i) }
|
||||
protegidos_fps[ordenados.first] = true
|
||||
end
|
||||
|
||||
candidatos = calcular_candidatos(todos, por_nodo, limite)
|
||||
protegidos = protegidos_fps.size
|
||||
|
||||
# Mostrar protegidos
|
||||
protegidos_fps.each do |nombre, _|
|
||||
info = todos[nombre]
|
||||
edad = ((Time.now - info[:fecha]) / 86400).to_i
|
||||
@logger.info("🛡️ #{info[:nodo]} (#{edad}d) — protegido")
|
||||
end
|
||||
|
||||
if candidatos.empty?
|
||||
puts "#{Color::GREEN}✓ Nada que limpiar.#{Color::RESET}"
|
||||
puts "#{Color::CYAN}🛡️ #{protegidos} nodo(s) protegido(s)#{Color::RESET}" if protegidos > 0
|
||||
return [0, protegidos]
|
||||
end
|
||||
|
||||
lista = candidatos.values.sort_by { |b| b[:fecha] }
|
||||
puts "\n#{Color::BOLD}🗑️ Candidatos a limpieza#{Color::RESET} (> #{((Time.now - limite) / 86400).to_i}d)"
|
||||
puts "─" * 70
|
||||
lista.each_with_index do |b, i|
|
||||
edad = ((Time.now - b[:fecha]) / 86400).to_i
|
||||
puts " #{(i+1).to_s.rjust(2)} #{Color::CYAN}#{b[:nodo]}#{Color::RESET} #{b[:fecha].strftime('%Y-%m-%d')} (#{edad}d, #{b[:archivos].length} arch.)"
|
||||
end
|
||||
puts "─" * 70
|
||||
total_arch = candidatos.values.sum { |b| b[:archivos].length }
|
||||
puts "Total: #{lista.length} sets (#{total_arch} archivos)"
|
||||
puts "#{Color::CYAN}🛡️ #{protegidos} nodo(s) protegido(s)#{Color::RESET}" if protegidos > 0
|
||||
|
||||
if options[:dry_run]
|
||||
puts "\n#{Color::YELLOW}[DRY-RUN] No se borró nada.#{Color::RESET}"
|
||||
return [0, protegidos]
|
||||
end
|
||||
|
||||
print "\n¿Borrar todos? (S/N): "
|
||||
resp = STDIN.gets&.chomp&.strip&.downcase
|
||||
unless resp == 's'
|
||||
puts "Cancelado."
|
||||
return [0, protegidos]
|
||||
end
|
||||
|
||||
lista.each_with_index do |b, i|
|
||||
puts "[#{i+1}/#{lista.length}] Limpiando #{b[:nodo]}..."
|
||||
end
|
||||
|
||||
@logger.info("✔ #{lista.length} sets eliminados.")
|
||||
[lista.length, protegidos]
|
||||
end
|
||||
|
||||
def calcular_candidatos(todos, por_nodo, limite)
|
||||
protegidos_fps = {}
|
||||
por_nodo.each do |nodo, nombres|
|
||||
ordenados = nombres.sort_by { |n| -(todos[n][:fecha].to_i) }
|
||||
protegidos_fps[ordenados.first] = true
|
||||
end
|
||||
|
||||
candidatos = {}
|
||||
todos.each do |nombre, info|
|
||||
next if protegidos_fps[nombre]
|
||||
next unless info[:fecha] < limite
|
||||
candidatos[nombre] = info
|
||||
end
|
||||
candidatos
|
||||
end
|
||||
|
||||
def cmd_backup(args)
|
||||
options = { modo: 'stop', storage: 'zfsDISCO1', compress: 'zstd' }
|
||||
OptionParser.new do |opts|
|
||||
opts.banner = "Uso: ./adn/tools/run bkps backup <nodo>"
|
||||
opts.on("--mode MODO", "Modo vzdump (stop|snapshot|suspend)") { |m| options[:modo] = m }
|
||||
opts.on("--storage S", "Storage Proxmox") { |s| options[:storage] = s }
|
||||
end.parse!(args)
|
||||
|
||||
nodo = args.shift
|
||||
unless nodo
|
||||
puts "#{Color::RED}✗ Falta nombre de nodo#{Color::RESET}"
|
||||
puts "Uso: ./adn/tools/run bkps backup <nodo> [--mode stop] [--storage local]"
|
||||
return
|
||||
end
|
||||
|
||||
datos = obtener_contexto_nodo(nodo)
|
||||
unless datos
|
||||
@logger.error("No se pudo determinar contexto para #{nodo}")
|
||||
return
|
||||
end
|
||||
|
||||
@logger.info("Backup de #{nodo}: VM #{datos[:vmid]} en #{datos[:host_name]} (#{datos[:host_ip]})")
|
||||
|
||||
candados_path = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'candados', 'candados.rb')
|
||||
system("ruby #{candados_path} authorize > /dev/null 2>&1")
|
||||
|
||||
cmd_vzdump = "vzdump #{datos[:vmid]} --mode #{options[:modo]} --storage #{options[:storage]} --compress #{options[:compress]}"
|
||||
clave = (datos[:host_name] == 'srv-dasu' ? 'srv-dasu:rmonla' : 'admindasu')
|
||||
cmd_ssh = "sshpass -e ssh -o StrictHostKeyChecking=no root@#{datos[:host_ip]} \"#{cmd_vzdump}\""
|
||||
|
||||
@logger.info("Ejecutando vzdump remoto...")
|
||||
IO.popen("ruby #{candados_path} run #{clave} SSHPASS '#{cmd_ssh}' 2>&1") do |pf|
|
||||
pf.each_line { |line| puts " #{Color::DIM}#{line.strip}#{Color::RESET}" }
|
||||
end
|
||||
|
||||
exito = $?.success?
|
||||
# Detectar errores de vzdump en el output capturado
|
||||
system("ruby #{candados_path} cerrar > /dev/null 2>&1")
|
||||
|
||||
if exito
|
||||
@logger.info("✔ Backup de #{nodo} completado exitosamente.")
|
||||
else
|
||||
@logger.error("Fallo en backup de #{nodo}.")
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
def obtener_contexto_nodo(nombre)
|
||||
ruta = File.join(ADN::NODOS_DIR, "#{nombre}.md")
|
||||
return nil unless File.exist?(ruta)
|
||||
|
||||
contenido = File.read(ruta)
|
||||
# VMID: soporta formato lista (- **VMID**: `107`) y tabla (| **VMID** | `116` |)
|
||||
vmid = contenido.match(/(?:VM ID|vmid|VMID)\*\*?:?\s*`?(\d+)`?/i)&.[](1) ||
|
||||
contenido.match(/\|\s*\*\*VMID\*\*\s*\|\s*`?(\d+)`?\s*\|/i)&.[](1)
|
||||
# Host: soporta formato lista y tabla
|
||||
host_name = contenido.match(/(?:Padre\/Host|Host Anfitrión|Anfitrión|Host)\*\*?:?\s*`([^`]+)`/i)&.[](1) ||
|
||||
contenido.match(/\|\s*\*\*Host\*\*\s*\|\s*`([^`]+)`\s*\|/i)&.[](1)
|
||||
return nil unless vmid && host_name
|
||||
|
||||
ruta_host = File.join(ADN::NODOS_DIR, "#{host_name}.md")
|
||||
host_ip = nil
|
||||
if File.exist?(ruta_host)
|
||||
c = File.read(ruta_host)
|
||||
host_ip = c.match(/\|\s*IP[^\|]*\|\s*`([^`]+)`\s*\|/)&.[](1) ||
|
||||
c.match(/IP\*\*:?\s*`([^`]+)`/)&.[](1) ||
|
||||
c.match(/IP:\s*([\d\.]+)/)&.[](1)
|
||||
end
|
||||
return nil unless host_ip
|
||||
|
||||
host_ip = host_ip.split('/')[0] if host_ip.include?('/')
|
||||
{ vmid: vmid, host_name: host_name, host_ip: host_ip }
|
||||
end
|
||||
|
||||
def cargar_mapa_vmid
|
||||
mapa = {}
|
||||
Dir.glob(File.join(ADN::NODOS_DIR, '*.md')).each do |f|
|
||||
begin
|
||||
contenido = File.read(f)
|
||||
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
|
||||
|
||||
# ─── Dispatcher ──────────────────────────────────────────────
|
||||
|
||||
public
|
||||
|
||||
def ejecutar_tarea(tarea, options = {})
|
||||
tipo = tarea[:tipo].to_s
|
||||
factory = TIPOS[tipo]
|
||||
unless factory
|
||||
@logger.error("Tipo desconocido: #{tipo}")
|
||||
return false
|
||||
end
|
||||
|
||||
if options[:dry_run]
|
||||
puts "#{Color::YELLOW}[DRY-RUN] Ejecutaría: #{tarea[:texto]} (#{tipo})#{Color::RESET}"
|
||||
return true
|
||||
end
|
||||
|
||||
t_inicio = Time.now
|
||||
@logger.info("▶ #{tarea[:texto]} (#{tipo})")
|
||||
procesador = factory.call(@logger)
|
||||
resultado = procesador.ejecutar(tarea)
|
||||
duracion = (Time.now - t_inicio).to_i
|
||||
mins = duracion / 60
|
||||
segs = duracion % 60
|
||||
@logger.info("✔ #{tarea[:texto]} — #{mins}m#{segs}s")
|
||||
resultado
|
||||
end
|
||||
|
||||
def ejecutar_comando(comando, options = {})
|
||||
t_inicio = Time.now
|
||||
@logger.info("=== #{comando[:texto]} ===")
|
||||
tareas_ids = comando[:tareas] || []
|
||||
fallos = 0
|
||||
tareas_ids.each_with_index do |id, i|
|
||||
tarea = @config.find_tarea(id)
|
||||
if tarea
|
||||
@logger.info("Paso #{i + 1}/#{tareas_ids.length}: #{tarea[:texto]}")
|
||||
resultado = ejecutar_tarea(tarea, options)
|
||||
fallos += 1 unless resultado
|
||||
else
|
||||
@logger.error("Tarea '#{id}' no encontrada en comando '#{comando[:id]}'.")
|
||||
fallos += 1
|
||||
end
|
||||
end
|
||||
duracion = (Time.now - t_inicio).to_i
|
||||
mins = duracion / 60
|
||||
segs = duracion % 60
|
||||
if fallos == 0
|
||||
@logger.info("✔ '#{comando[:texto]}' completado exitosamente (#{mins}m#{segs}s)")
|
||||
else
|
||||
@logger.error("✖ '#{comando[:texto]}' completado con #{fallos} fallo(s) (#{mins}m#{segs}s)")
|
||||
exit 1 if options[:batch]
|
||||
end
|
||||
|
||||
# Post-sanear automático si el comando lo tiene configurado
|
||||
if comando[:post_sanear] && fallos == 0
|
||||
retencion = @config.raw_config['retencion'] || {}
|
||||
dias = retencion['dias_default'] || 6
|
||||
@logger.info("🧹 Post-sanear automático (retención: #{dias} días)")
|
||||
cmd_sanear(["--dias", dias.to_s, "--dry-run"])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def auto_screen!
|
||||
puts "\n#{Color::CYAN}ℹ Relanzando en sesión screen para permitir desatención...#{Color::RESET}"
|
||||
cmd = ([$PROGRAM_NAME] + ['bkps', 'menu']).map { |a| Shellwords.escape(a) }.join(' ')
|
||||
nombre = "bkps_#{Time.now.strftime('%H%M%S')}"
|
||||
puts "Para reconectarte: #{Color::YELLOW}screen -r #{nombre}#{Color::RESET}"
|
||||
sleep 1
|
||||
system('screen', '-dmS', nombre, 'bash', '-c', "#{cmd}; echo ''; read -p 'Enter para cerrar...'")
|
||||
exit 0
|
||||
end
|
||||
|
||||
def mostrar_ayuda
|
||||
puts <<~HELP
|
||||
#{Color::BOLD}#{Color::CYAN}Herramienta ADN — Gestión de Backups (dtic-BKPs)#{Color::RESET}
|
||||
|
||||
#{Color::YELLOW}Uso:#{Color::RESET}
|
||||
./adn/tools/run bkps list Listar tareas y comandos
|
||||
./adn/tools/run bkps run <T1|C1|ID> Ejecutar tarea o comando
|
||||
./adn/tools/run bkps run C4 --batch Modo batch (sin interacción)
|
||||
./adn/tools/run bkps run T1 --dry-run Simulación sin ejecutar
|
||||
./adn/tools/run bkps menu Menú interactivo (auto-screen)
|
||||
./adn/tools/run bkps status Estado del sistema
|
||||
./adn/tools/run bkps estados Estado Proxmox en tiempo real
|
||||
./adn/tools/run bkps backup <nodo> Backup remoto individual (vzdump)
|
||||
./adn/tools/run bkps sanear --dias 6 Limpiar backups antiguos
|
||||
./adn/tools/run bkps help Esta ayuda
|
||||
|
||||
#{Color::YELLOW}Tareas configuradas:#{Color::RESET}
|
||||
HELP
|
||||
|
||||
@config.tareas.each_with_index do |t, i|
|
||||
puts " #{Color::GREEN}T#{i+1}#{Color::RESET} #{t[:texto]} #{Color::DIM}(#{t[:tipo]})#{Color::RESET}"
|
||||
end
|
||||
|
||||
puts "\n #{Color::YELLOW}Comandos configurados:#{Color::RESET}"
|
||||
@config.comandos.each_with_index do |c, i|
|
||||
puts " #{Color::GREEN}C#{i+1}#{Color::RESET} #{c[:texto]}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,104 @@
|
||||
# Configuración dtic-BKPs — Tareas y Comandos de Backup
|
||||
# Migrado desde dtic-BKPs_tasks.rb
|
||||
|
||||
auto_avanzar: true
|
||||
|
||||
tareas:
|
||||
- id: proc_syncXen1
|
||||
texto: "⚙️ Procesar archivos de BKPs de VMs: syncs-XEN01"
|
||||
tipo: xva
|
||||
origen: "/mnt/ns8Disco2/syncs-XEN01/"
|
||||
destino: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-SERVIDORes/"
|
||||
eliminar_origen: true
|
||||
sobrescribir: false
|
||||
|
||||
- id: proc_syncPmox
|
||||
texto: "⚙️ Procesar archivos de BKPs de VMs desde zfsDISCO1"
|
||||
tipo: proxmox
|
||||
origen: "pve_PMOX3:/mnt/pve/zfsDISCO1/dump/"
|
||||
destino: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-SERVIDORes/"
|
||||
eliminar_origen: false
|
||||
sobrescribir: false
|
||||
|
||||
- id: sinc_pmox_ns8
|
||||
texto: "📥 Descargar archivos de BKPs de VMs: zfsDISCO1"
|
||||
tipo: rclone
|
||||
origen: "pve_PMOX3:/mnt/pve/zfsDISCO1/dump/"
|
||||
destino: "/mnt/ns8Disco2/syncs-PMOX/"
|
||||
eliminar_origen: false
|
||||
sobrescribir: false
|
||||
|
||||
- id: sync_BkpAntiguos_nube
|
||||
texto: "☁️ Upload de BKPs a rmOneDrive: bkps-ANTIGUOS"
|
||||
tipo: rclone
|
||||
origen: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-ANTIGUOS/"
|
||||
destino: "rmOneDrive:/dtic-BACKUPS/bkps-ANTIGUOS/"
|
||||
eliminar_origen: false
|
||||
sobrescribir: false
|
||||
|
||||
- id: sync_BkpsSERVIDORes_nube
|
||||
texto: "☁️ Upload de bkps-SERVIDORes a rmOneDrive"
|
||||
tipo: rclone
|
||||
origen: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-SERVIDORes/"
|
||||
destino: "rmOneDrive:/dtic-BACKUPS/bkps-SERVIDORes/"
|
||||
eliminar_origen: false
|
||||
sobrescribir: false
|
||||
|
||||
- id: sync_BkpUsers_nube
|
||||
texto: "☁️ Upload de bkps-USERs a rmOneDrive"
|
||||
tipo: rclone
|
||||
origen: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-USERs/"
|
||||
destino: "rmOneDrive:/dtic-BACKUPS/bkps-USERs/"
|
||||
eliminar_origen: false
|
||||
sobrescribir: false
|
||||
|
||||
comandos:
|
||||
- id: descargar_y_procesar_pmox
|
||||
texto: "📥⚙️ Descargar y Procesar BKPs de PMOXs"
|
||||
tareas: [sinc_pmox_ns8, proc_syncPmox]
|
||||
|
||||
- id: full_procesamiento
|
||||
texto: "⚙️ Procesar TODOS los BKPs de NS8"
|
||||
tareas: [proc_syncXen1, proc_syncPmox]
|
||||
|
||||
- id: full_upload
|
||||
texto: "🚀 Subir a la Nube todos los BKPs"
|
||||
tareas: [sync_BkpAntiguos_nube, sync_BkpsSERVIDORes_nube, sync_BkpUsers_nube]
|
||||
|
||||
- id: full_full
|
||||
texto: "🚀 Procesar todo y Subir todo"
|
||||
tareas: [proc_syncXen1, proc_syncPmox, sync_BkpsSERVIDORes_nube, sync_BkpAntiguos_nube, sync_BkpUsers_nube]
|
||||
|
||||
- id: full_full_sanear
|
||||
texto: "🚀🧹 Procesar, Subir y Sanear"
|
||||
tareas: [proc_syncXen1, proc_syncPmox, sync_BkpsSERVIDORes_nube, sync_BkpAntiguos_nube, sync_BkpUsers_nube]
|
||||
post_sanear: true
|
||||
|
||||
# ─── Retención (3 tiers) ───────────────────────────────────
|
||||
retencion:
|
||||
dias_default: 6
|
||||
tiers:
|
||||
- nombre: "Proxmox (zfsDISCO1)"
|
||||
tipo: proxmox
|
||||
storage: zfsDISCO1
|
||||
hosts:
|
||||
- { nombre: srv-pmox1, ip: "10.0.10.201" }
|
||||
- { nombre: srv-pmox2, ip: "10.0.10.202" }
|
||||
- { nombre: srv-pmox3, ip: "10.0.10.203" }
|
||||
|
||||
- nombre: "Local (ns8Disco3)"
|
||||
tipo: local
|
||||
ruta: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-SERVIDORes"
|
||||
|
||||
- nombre: "Nube (rmOneDrive)"
|
||||
tipo: rclone
|
||||
remote: "rmOneDrive:/dtic-BACKUPS/bkps-SERVIDORes"
|
||||
|
||||
# ─── Monitoreo ─────────────────────────────────────────────
|
||||
monitoreo:
|
||||
rutas_espacio:
|
||||
- { nombre: ns8Disco2, ruta: "/mnt/ns8Disco2" }
|
||||
- { nombre: ns8Disco3, ruta: "/mnt/ns8Disco3" }
|
||||
- { nombre: bkps-SERVIDORes, ruta: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-SERVIDORes" }
|
||||
- { nombre: bkps-ANTIGUOS, ruta: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-ANTIGUOS" }
|
||||
- { nombre: bkps-USERs, ruta: "/mnt/ns8Disco3/dtic-BACKUPS/bkps-USERs" }
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# ==========================================================
|
||||
# adn/tools/bkps/cron-bkps.sh — Wrapper cron para backups
|
||||
# ----------------------------------------------------------
|
||||
# Ejecuta un comando bkps con:
|
||||
# - Lockfile (evita ejecuciones simultáneas)
|
||||
# - Bitácora automática vía dron
|
||||
# - Logging de salida
|
||||
#
|
||||
# Uso en crontab:
|
||||
# 0 2 * * * /ruta/adn/tools/bkps/cron-bkps.sh C4
|
||||
# 0 3 * * * /ruta/adn/tools/bkps/cron-bkps.sh T3
|
||||
#
|
||||
# Ejemplo manual:
|
||||
# ./adn/tools/bkps/cron-bkps.sh C4 "Pipeline nocturno C4"
|
||||
# ==========================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Configuración ─────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
ADN_RUN="$PROJECT_ROOT/adn/tools/run"
|
||||
LOCK_DIR="$PROJECT_ROOT/tmp/locks"
|
||||
LOG_DIR="$PROJECT_ROOT/tmp/logs/bkps"
|
||||
|
||||
mkdir -p "$LOCK_DIR" "$LOG_DIR"
|
||||
|
||||
# ─── Argumentos ────────────────────────────────────────────
|
||||
REF="${1:-}"
|
||||
NOTA="${2:-Cron: bkps run $REF --batch}"
|
||||
AMBITO="dtic-BKPs"
|
||||
NODO="srv-ns8"
|
||||
|
||||
if [ -z "$REF" ]; then
|
||||
echo "✗ Uso: cron-bkps.sh <T1|C1|ID> [nota]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── Lockfile ──────────────────────────────────────────────
|
||||
LOCKFILE="$LOCK_DIR/bkps_${REF}.lock"
|
||||
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
PID_LOCK=$(cat "$LOCKFILE")
|
||||
if kill -0 "$PID_LOCK" 2>/dev/null; then
|
||||
echo "⚠ bkps $REF ya está corriendo (PID: $PID_LOCK, lock: $LOCKFILE)"
|
||||
exit 0 # No es error, simplemente ya está corriendo
|
||||
else
|
||||
echo "ℹ Lock stale removido (PID $PID_LOCK ya no existe)"
|
||||
rm -f "$LOCKFILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo $$ > "$LOCKFILE"
|
||||
trap 'rm -f "$LOCKFILE"' EXIT
|
||||
|
||||
# ─── Log ───────────────────────────────────────────────────
|
||||
FECHA=$(date +%Y-%m-%d)
|
||||
HORA=$(date +%H:%M)
|
||||
LOGFILE="$LOG_DIR/bkps_${REF}_${FECHA}_${HORA//:/}.log"
|
||||
|
||||
echo "[$(date)] cron-bkps: Iniciando $REF" >> "$LOGFILE"
|
||||
|
||||
# ─── Bitácora + Ejecución via Dron ──────────────────────
|
||||
# Crear evento
|
||||
EVENTO_ID=$("$ADN_RUN" db evento:crear \
|
||||
--ambito "$AMBITO" \
|
||||
--nodo "$NODO" \
|
||||
--descripcion "$NOTA" \
|
||||
--inicio "$HORA" \
|
||||
--modo R --ia 2>&1 | grep -oP 'ID \K\d+' | tail -1)
|
||||
|
||||
if [ -z "$EVENTO_ID" ]; then
|
||||
echo "[$(date)] cron-bkps: ⚠ No se pudo crear evento, ejecutando sin bitácora" >> "$LOGFILE"
|
||||
"$ADN_RUN" bkps run "$REF" --batch >> "$LOGFILE" 2>&1
|
||||
EXIT_CODE=$?
|
||||
else
|
||||
echo "[$(date)] cron-bkps: Evento #$EVENTO_ID creado, delegando al dron" >> "$LOGFILE"
|
||||
"$ADN_RUN" dron lanzar \
|
||||
--evento "$EVENTO_ID" \
|
||||
--nota "$NOTA" \
|
||||
-- "$ADN_RUN" bkps run "$REF" --batch >> "$LOGFILE" 2>&1
|
||||
EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
echo "[$(date)] cron-bkps: Finalizado con código $EXIT_CODE" >> "$LOGFILE"
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,71 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/bkps/lib/config.rb — Carga de config YAML
|
||||
# Migrado desde dtic-BKPs v6.0 → ADN::BKPs
|
||||
# ==========================================================
|
||||
|
||||
require 'yaml'
|
||||
require 'fileutils'
|
||||
|
||||
module ADN
|
||||
module BKPs
|
||||
class Config
|
||||
attr_reader :tareas, :comandos, :auto_avanzar, :raw_config
|
||||
|
||||
def initialize(path)
|
||||
@path = path
|
||||
reload!
|
||||
end
|
||||
|
||||
def reload!
|
||||
unless File.exist?(@path)
|
||||
@tareas = []
|
||||
@comandos = []
|
||||
@auto_avanzar = true
|
||||
return
|
||||
end
|
||||
|
||||
data = YAML.safe_load(File.read(@path), permitted_classes: [Symbol], symbolize_names: true) || {}
|
||||
@raw_config = YAML.safe_load(File.read(@path)) || {} # String keys for raw access
|
||||
@auto_avanzar = data[:auto_avanzar] != false
|
||||
@tareas = (data[:tareas] || []).map { |t| simbolizar(t) }
|
||||
@comandos = (data[:comandos] || []).map { |c| simbolizar(c) }
|
||||
end
|
||||
|
||||
def find_tarea(id)
|
||||
@tareas.find { |t| t[:id].to_s == id.to_s }
|
||||
end
|
||||
|
||||
def save!
|
||||
data = {
|
||||
'auto_avanzar' => @auto_avanzar,
|
||||
'tareas' => @tareas.map { |t| stringify(t) },
|
||||
'comandos' => @comandos.map { |c| stringify(c) }
|
||||
}
|
||||
File.write(@path, YAML.dump(data))
|
||||
FileUtils.chmod(0644, @path)
|
||||
end
|
||||
|
||||
def add_tarea(tarea)
|
||||
@tareas << simbolizar(tarea)
|
||||
end
|
||||
|
||||
def remove_tarea(index)
|
||||
@tareas.delete_at(index)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def simbolizar(hash)
|
||||
hash.transform_keys(&:to_sym)
|
||||
end
|
||||
|
||||
def stringify(hash)
|
||||
hash.transform_keys(&:to_s).tap do |h|
|
||||
h['tareas'] = h['tareas'].map(&:to_s) if h['tareas']
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/bkps/lib/menu.rb — Menú TUI interactivo
|
||||
# Migrado desde dtic-BKPs v6.0 → ADN::BKPs
|
||||
# ==========================================================
|
||||
|
||||
module ADN
|
||||
module BKPs
|
||||
class Menu
|
||||
def initialize(config, dispatcher, log)
|
||||
@config = config
|
||||
@dispatcher = dispatcher
|
||||
@log = log
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
loop do
|
||||
system('clear') || system('cls')
|
||||
mostrar_encabezado
|
||||
opciones = construir_opciones
|
||||
|
||||
opciones.each { |op| puts op[:linea] }
|
||||
puts "#{Color::CYAN}────────────────────────#{Color::RESET}"
|
||||
|
||||
print "\n#{Color::YELLOW}Selecciona > #{Color::RESET}"
|
||||
$stdout.flush
|
||||
entrada = $stdin.gets&.chomp&.strip&.upcase
|
||||
next if entrada.nil? || entrada.empty?
|
||||
|
||||
op = opciones.find { |o| o[:id] == entrada }
|
||||
unless op
|
||||
@log.error("Opción no válida: #{entrada}")
|
||||
sleep 1
|
||||
next
|
||||
end
|
||||
|
||||
case op[:tipo]
|
||||
when :tarea
|
||||
@dispatcher.ejecutar_tarea(op[:config])
|
||||
when :comando
|
||||
@dispatcher.ejecutar_comando(op[:config])
|
||||
when :salir
|
||||
puts "Saliendo..."
|
||||
return
|
||||
end
|
||||
|
||||
unless op[:tipo] == :salir || @config.auto_avanzar
|
||||
print "\nPresiona Enter para continuar..."
|
||||
$stdin.gets
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mostrar_encabezado
|
||||
puts "#{Color::BOLD}#{Color::CYAN}╔══════════════════════════════════════════╗#{Color::RESET}"
|
||||
puts "#{Color::BOLD}#{Color::CYAN}║ dtic-BKPs — ADN Backups Manager ║#{Color::RESET}"
|
||||
puts "#{Color::BOLD}#{Color::CYAN}╚══════════════════════════════════════════╝#{Color::RESET}"
|
||||
end
|
||||
|
||||
def construir_opciones
|
||||
opciones = []
|
||||
|
||||
puts "\n#{Color::BOLD}Tareas Individuales:#{Color::RESET}"
|
||||
@config.tareas.each_with_index do |t, i|
|
||||
id = "T#{i + 1}"
|
||||
opciones << { id: id, tipo: :tarea, config: t, linea: " #{Color::YELLOW}#{id}.#{Color::RESET} #{t[:texto]}" }
|
||||
end
|
||||
|
||||
puts "\n#{Color::BOLD}Comandos (Secuencias):#{Color::RESET}"
|
||||
@config.comandos.each_with_index do |c, i|
|
||||
id = "C#{i + 1}"
|
||||
opciones << { id: id, tipo: :comando, config: c, linea: " #{Color::YELLOW}#{id}.#{Color::RESET} #{c[:texto]}" }
|
||||
end
|
||||
|
||||
puts "\n#{Color::CYAN}────────────────────────#{Color::RESET}"
|
||||
opciones << { id: 'S', tipo: :salir, linea: " #{Color::YELLOW}S.#{Color::RESET} Salir" }
|
||||
|
||||
opciones.select { |op| op[:tipo] == :salir }.each { |op| puts op[:linea] }
|
||||
opciones
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,150 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/bkps/lib/proc_proxmox.rb — Procesador Proxmox
|
||||
# Migrado desde dtic-BKPs v6.0 → ADN::BKPs
|
||||
# ==========================================================
|
||||
|
||||
require 'tmpdir'
|
||||
require 'shellwords'
|
||||
require 'open3'
|
||||
require 'fileutils'
|
||||
|
||||
module ADN
|
||||
module BKPs
|
||||
module Procesador
|
||||
class Proxmox
|
||||
def initialize(log)
|
||||
@log = log
|
||||
@pv = system('which pv > /dev/null 2>&1')
|
||||
end
|
||||
|
||||
def ejecutar(tarea)
|
||||
@log.info("--- #{tarea[:texto]} ---")
|
||||
@log.info("#{tarea[:id]}: Origen remoto: #{tarea[:origen]}")
|
||||
|
||||
dir_tmp = montar(tarea[:origen], tarea[:id])
|
||||
return false unless dir_tmp
|
||||
|
||||
procesados = 0
|
||||
|
||||
begin
|
||||
logs = Dir.glob(File.join(dir_tmp, '*.log'))
|
||||
if logs.empty?
|
||||
@log.info("#{tarea[:id]}: Sin archivos .log en origen.")
|
||||
return true
|
||||
end
|
||||
|
||||
@log.info("Encontrados #{logs.length} set(s) de backup Proxmox.")
|
||||
|
||||
logs.each do |ruta_log|
|
||||
base = File.basename(ruta_log, '.log')
|
||||
begin
|
||||
fuentes = Dir.glob(File.join(dir_tmp, "#{base}*"))
|
||||
nombre_vm = extraer_nombre_vm(fuentes, ruta_log, base, tarea[:id])
|
||||
next unless nombre_vm
|
||||
|
||||
nombre_tar = "#{nombre_vm}_#{File.mtime(ruta_log).strftime('%Y%m%d_%H%M%S')}.tar.gz"
|
||||
dir_dest = File.join(tarea[:destino], nombre_vm)
|
||||
FileUtils.mkdir_p(dir_dest)
|
||||
ruta_tar = File.join(dir_dest, nombre_tar)
|
||||
|
||||
if File.exist?(ruta_tar)
|
||||
if tarea[:sobrescribir]
|
||||
@log.info("#{File.basename(ruta_tar)} existe. Sobrescribiendo.")
|
||||
else
|
||||
@log.info("#{File.basename(ruta_tar)} existe. Saltando.")
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
@log.info("Comprimiendo #{base} (VM: #{nombre_vm})")
|
||||
comprimir(dir_tmp, fuentes, ruta_tar)
|
||||
@log.info("✔ Compresión OK: #{File.basename(ruta_tar)}")
|
||||
|
||||
if tarea[:eliminar_origen]
|
||||
fuentes.each { |f| FileUtils.rm_f(f) }
|
||||
@log.info("Fuentes eliminadas de origen.")
|
||||
end
|
||||
|
||||
procesados += 1
|
||||
rescue => e
|
||||
@log.error("#{tarea[:id]}: Error procesando '#{base}': #{e.message}")
|
||||
FileUtils.rm_f(ruta_tar) if ruta_tar && File.exist?(ruta_tar)
|
||||
end
|
||||
end
|
||||
|
||||
@log.info("✔ #{procesados} sets Proxmox procesados.")
|
||||
true
|
||||
ensure
|
||||
desmontar(dir_tmp, tarea[:id])
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def extraer_nombre_vm(fuentes, ruta_log, base, tarea_id)
|
||||
notes = fuentes.find { |f| f.end_with?('.notes') }
|
||||
nombre = notes ? File.read(notes).strip : nil
|
||||
|
||||
if nombre.nil? || nombre.empty?
|
||||
File.foreach(ruta_log) do |line|
|
||||
if line =~ /INFO: VM Name:\s*(.+)$/
|
||||
nombre = $1.strip
|
||||
break
|
||||
end
|
||||
end rescue nil
|
||||
end
|
||||
|
||||
if nombre.nil? || nombre.empty?
|
||||
match = base.match(/vzdump-(?:qemu|lxc)-(\d+)-/)
|
||||
nombre = match ? "VM-#{match[1]}" : nil
|
||||
end
|
||||
|
||||
@log.error("#{tarea_id}: Sin nombre de VM para '#{base}'. Saltando.") unless nombre
|
||||
nombre
|
||||
end
|
||||
|
||||
def comprimir(dir_origen, fuentes, ruta_salida)
|
||||
nombres = fuentes.map { |f| File.basename(f) }
|
||||
if @pv
|
||||
cmd_tar = ['tar', '-cf', '-', '-C', dir_origen] + nombres
|
||||
cmd_pv = ['pv', '-s', fuentes.sum { |f| File.size(f) rescue 0 }.to_s]
|
||||
cmd_gz = ['gzip']
|
||||
File.open(ruta_salida, 'wb') do |f|
|
||||
Open3.pipeline(cmd_tar, cmd_pv, cmd_gz, out: f).each_with_index do |s, i|
|
||||
raise "Falló #{%w[tar pv gzip][i]} (#{s.exitstatus})" unless s.success?
|
||||
end
|
||||
end
|
||||
else
|
||||
args = nombres.map { |f| Shellwords.escape(f) }.join(' ')
|
||||
cmd = "tar -czf #{Shellwords.escape(ruta_salida)} -C #{Shellwords.escape(dir_origen)} #{args}"
|
||||
system(cmd) or raise "tar falló (#{$?.exitstatus})"
|
||||
end
|
||||
end
|
||||
|
||||
def montar(remoto, tarea_id)
|
||||
dir = Dir.mktmpdir("bkps_#{tarea_id}_")
|
||||
@log.info("Montando #{remoto} en #{dir}")
|
||||
pid = spawn("rclone mount #{Shellwords.escape(remoto)} #{dir} --daemon --vfs-cache-mode writes")
|
||||
Process.detach(pid)
|
||||
sleep 2
|
||||
|
||||
if Dir.exist?(dir) && !(Dir.entries(dir) - %w[. ..]).empty?
|
||||
dir
|
||||
else
|
||||
@log.error("Error montando #{remoto}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def desmontar(dir, tarea_id)
|
||||
return unless dir && Dir.exist?(dir)
|
||||
@log.info("Desmontando #{dir}")
|
||||
system("fusermount -u #{dir} 2>/dev/null || umount #{dir} 2>/dev/null")
|
||||
FileUtils.remove_entry(dir) rescue nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/bkps/lib/proc_xen.rb — Procesador XEN (.xva)
|
||||
# Migrado desde dtic-BKPs v6.0 → ADN::BKPs
|
||||
# ==========================================================
|
||||
|
||||
require 'shellwords'
|
||||
require 'open3'
|
||||
require 'fileutils'
|
||||
|
||||
module ADN
|
||||
module BKPs
|
||||
module Procesador
|
||||
class Xen
|
||||
def initialize(log)
|
||||
@log = log
|
||||
@pv = system('which pv > /dev/null 2>&1')
|
||||
end
|
||||
|
||||
def ejecutar(tarea)
|
||||
@log.info("--- #{tarea[:texto]} ---")
|
||||
@log.info("#{tarea[:id]}: Origen: #{tarea[:origen]}")
|
||||
|
||||
archivos = Dir.glob(File.join(tarea[:origen], '*.xva'))
|
||||
if archivos.empty?
|
||||
@log.info("#{tarea[:id]}: Sin archivos .xva en origen.")
|
||||
return true
|
||||
end
|
||||
|
||||
@log.info("Encontrados #{archivos.length} archivo(s) .xva.")
|
||||
procesados = 0
|
||||
|
||||
archivos.each do |ruta_xva|
|
||||
nombre_xva = File.basename(ruta_xva)
|
||||
nombre_vm = nombre_xva.split('_').first
|
||||
dir_dest = File.join(tarea[:destino], nombre_vm)
|
||||
FileUtils.mkdir_p(dir_dest)
|
||||
|
||||
nombre_sin_ext = File.basename(nombre_xva, '.xva')
|
||||
ruta_tar = File.join(dir_dest, "#{nombre_sin_ext}.tar.gz")
|
||||
|
||||
if File.exist?(ruta_tar)
|
||||
if tarea[:sobrescribir]
|
||||
@log.info("#{File.basename(ruta_tar)} existe. Sobrescribiendo.")
|
||||
else
|
||||
@log.info("#{File.basename(ruta_tar)} existe. Saltando.")
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
begin
|
||||
@log.info("Comprimiendo #{nombre_xva}")
|
||||
|
||||
if @pv
|
||||
cmd_tar = ['tar', '-czf', '-', '-C', tarea[:origen], nombre_xva]
|
||||
cmd_pv = ['pv', '-s', File.size(ruta_xva).to_s]
|
||||
File.open(ruta_tar, 'wb') do |f|
|
||||
Open3.pipeline(cmd_tar, cmd_pv, out: f).each_with_index do |s, i|
|
||||
raise "Falló #{%w[tar pv][i]} (#{s.exitstatus})" unless s.success?
|
||||
end
|
||||
end
|
||||
else
|
||||
cmd = "tar -czf #{Shellwords.escape(ruta_tar)} -C #{Shellwords.escape(tarea[:origen])} #{Shellwords.escape(nombre_xva)}"
|
||||
system(cmd) or raise "tar falló (#{$?.exitstatus})"
|
||||
end
|
||||
|
||||
@log.info("✔ Compresión OK: #{File.basename(ruta_tar)}")
|
||||
|
||||
if tarea[:eliminar_origen]
|
||||
FileUtils.rm_f(ruta_xva)
|
||||
@log.info("XVA original eliminado.")
|
||||
end
|
||||
|
||||
procesados += 1
|
||||
rescue => e
|
||||
@log.error("#{tarea[:id]}: Error comprimiendo #{nombre_xva}: #{e.message}")
|
||||
FileUtils.rm_f(ruta_tar)
|
||||
end
|
||||
end
|
||||
|
||||
@log.info("✔ #{procesados} archivos .xva procesados.")
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,54 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/bkps/lib/sync_rclone.rb — Sincronización rclone
|
||||
# Migrado desde dtic-BKPs v6.0 → ADN::BKPs
|
||||
# ==========================================================
|
||||
|
||||
require 'shellwords'
|
||||
|
||||
module ADN
|
||||
module BKPs
|
||||
module Procesador
|
||||
class Rclone
|
||||
RCLONE_OPTS = %w[
|
||||
--delete-before
|
||||
--retries 5
|
||||
--retries-sleep 10s
|
||||
--timeout 30m
|
||||
--contimeout 10m
|
||||
--tpslimit 10
|
||||
--progress
|
||||
-v
|
||||
].join(' ').freeze
|
||||
|
||||
def initialize(log)
|
||||
@log = log
|
||||
end
|
||||
|
||||
def ejecutar(tarea)
|
||||
@log.info("--- #{tarea[:texto]} ---")
|
||||
origen = tarea[:origen]
|
||||
destino = tarea[:destino]
|
||||
|
||||
@log.info("Origen: #{origen}")
|
||||
@log.info("Destino: #{destino}")
|
||||
|
||||
cmd = "rclone sync #{Shellwords.escape(origen)} #{Shellwords.escape(destino)} #{RCLONE_OPTS}"
|
||||
@log.info("#{tarea[:id]}: Ejecutando → #{cmd}")
|
||||
|
||||
exito = system(cmd)
|
||||
codigo = $?.exitstatus
|
||||
|
||||
if exito
|
||||
@log.info("✔ Sincronización rclone completada.")
|
||||
else
|
||||
@log.error("#{tarea[:id]}: Fallo en sincronización (código: #{codigo}).")
|
||||
end
|
||||
|
||||
exito
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1 +1 @@
|
||||
1774987932
|
||||
1775509864
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/cli/copiloto.rb — Vigía de tareas largas
|
||||
# ----------------------------------------------------------
|
||||
# El "copiloto" se encarga de tareas de bajo nivel:
|
||||
# - Lanzar un comando y vigilar hasta que termine
|
||||
# - Cerrar automáticamente el evento de bitácora al finalizar
|
||||
# - Registrar resultado (éxito/fallo) en la descripción
|
||||
#
|
||||
# Principio: Menos es Más — una tool reutilizable para
|
||||
# cualquier ámbito que necesite delegar tareas largas.
|
||||
#
|
||||
# Uso:
|
||||
# ./adn/tools/run copiloto lanzar --evento 1379 -- ./adn/tools/run bkps run C4 --batch
|
||||
# ./adn/tools/run copiloto vigilar --evento 1379 --pid 12345
|
||||
# ./adn/tools/run copiloto estado
|
||||
# ==========================================================
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'time'
|
||||
require 'json'
|
||||
|
||||
module ADN
|
||||
class SubcomandoCopiloto
|
||||
COPILOTO_DIR = File.join(ADN::PROJECT_ROOT, 'tmp', 'copiloto')
|
||||
ADN_RUN = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'run')
|
||||
|
||||
def initialize(args, logger = nil)
|
||||
@args = args
|
||||
@logger = logger
|
||||
FileUtils.mkdir_p(COPILOTO_DIR)
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || @args.first == 'help' || @args.first == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
accion = @args.shift
|
||||
case accion
|
||||
when 'lanzar'
|
||||
cmd_lanzar(@args)
|
||||
when 'vigilar'
|
||||
cmd_vigilar(@args)
|
||||
when 'estado'
|
||||
cmd_estado
|
||||
when 'limpiar'
|
||||
cmd_limpiar
|
||||
else
|
||||
puts "#{Color::RED}✗ Acción desconocida: #{accion}#{Color::RESET}"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ─── lanzar: ejecutar comando en background y vigilar ────────
|
||||
def cmd_lanzar(args)
|
||||
evento_id = nil
|
||||
nota = nil
|
||||
|
||||
# Separar opciones del copiloto del comando a ejecutar
|
||||
separador = args.index('--')
|
||||
unless separador
|
||||
puts "#{Color::RED}✗ Falta separador '--' antes del comando a ejecutar#{Color::RESET}"
|
||||
puts "Uso: ./adn/tools/run copiloto lanzar --evento 1379 -- <comando>"
|
||||
return
|
||||
end
|
||||
|
||||
copiloto_args = args[0...separador]
|
||||
comando_args = args[(separador + 1)..-1]
|
||||
|
||||
OptionParser.new do |opts|
|
||||
opts.on("--evento ID", "ID del evento (o AUTO para crear uno)") { |e| evento_id = e }
|
||||
opts.on("--nota TEXTO", "Nota adicional para el cierre") { |n| nota = n }
|
||||
end.parse!(copiloto_args)
|
||||
|
||||
# AUTO: crear evento de bitácora automáticamente
|
||||
if evento_id == 'AUTO'
|
||||
cmd_crear = "#{ADN_RUN} db evento:crear --ambito dtic-BKPs --nodo srv-ns8"
|
||||
cmd_crear += " --descripcion \"#{(nota || comando_args.join(' ')).gsub('"', '\\"')}\""
|
||||
cmd_crear += " --inicio #{Time.now.strftime('%H:%M')} --modo R --ia"
|
||||
output = `#{cmd_crear} 2>&1`
|
||||
# Extraer ID del evento creado
|
||||
if output.match(/ID\s+(\d+)/)
|
||||
evento_id = output.match(/ID\s+(\d+)/)[1].to_i
|
||||
@logger&.info("Copiloto: Evento AUTO ##{evento_id} creado")
|
||||
else
|
||||
@logger&.info("Copiloto: No se pudo crear evento AUTO, continuando sin evento")
|
||||
evento_id = nil
|
||||
end
|
||||
elsif evento_id
|
||||
evento_id = evento_id.to_i
|
||||
end
|
||||
|
||||
if comando_args.empty?
|
||||
puts "#{Color::RED}✗ Falta el comando a ejecutar después de '--'#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
comando_str = comando_args.join(' ')
|
||||
tarea_id = "copiloto_#{Time.now.strftime('%H%M%S')}_#{$$}"
|
||||
log_file = File.join(COPILOTO_DIR, "#{tarea_id}.log")
|
||||
meta_file = File.join(COPILOTO_DIR, "#{tarea_id}.json")
|
||||
|
||||
# Guardar metadata
|
||||
meta = {
|
||||
tarea_id: tarea_id,
|
||||
evento_id: evento_id,
|
||||
comando: comando_str,
|
||||
nota: nota,
|
||||
inicio: Time.now.iso8601,
|
||||
pid: nil,
|
||||
estado: 'lanzando'
|
||||
}
|
||||
File.write(meta_file, JSON.pretty_generate(meta))
|
||||
|
||||
# Lanzar el proceso vigía en background
|
||||
vigia_script = <<~BASH
|
||||
#!/bin/bash
|
||||
# Copiloto vigía — auto-generado
|
||||
LOGFILE="#{log_file}"
|
||||
METAFILE="#{meta_file}"
|
||||
EVENTO_ID="#{evento_id}"
|
||||
ADN_RUN="#{ADN_RUN}"
|
||||
|
||||
echo "[$(date)] Copiloto: Iniciando comando..." >> "$LOGFILE"
|
||||
#{comando_str} >> "$LOGFILE" 2>&1
|
||||
EXIT_CODE=$?
|
||||
echo "[$(date)] Copiloto: Comando terminó con código $EXIT_CODE" >> "$LOGFILE"
|
||||
|
||||
# Actualizar metadata
|
||||
ruby -rjson -e '
|
||||
f = "'"$METAFILE"'"
|
||||
m = JSON.parse(File.read(f))
|
||||
m["fin"] = Time.now.iso8601
|
||||
m["exit_code"] = #{'"$EXIT_CODE".to_i'}
|
||||
m["estado"] = #{'"$EXIT_CODE".to_i'} == 0 ? "completado" : "fallido"
|
||||
File.write(f, JSON.pretty_generate(m))
|
||||
'
|
||||
|
||||
# Cerrar evento en bitácora si hay evento_id
|
||||
if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then
|
||||
HORA_FIN=$(date +%H:%M)
|
||||
DURACION=$(( ($(date +%s) - #{Time.now.to_i}) / 60 ))
|
||||
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
RESULTADO="- 🤖 Copiloto: ✔ Completado exitosamente (${DURACION}min)"
|
||||
else
|
||||
RESULTADO="- 🤖 Copiloto: ✖ Falló con código $EXIT_CODE (${DURACION}min)"
|
||||
fi
|
||||
|
||||
NOTA_BASE="#{(nota || '').gsub('"', '\\"')}"
|
||||
if [ -n "$NOTA_BASE" ]; then
|
||||
DESC_FINAL="${NOTA_BASE}
|
||||
${RESULTADO}"
|
||||
else
|
||||
DESC_FINAL="#{comando_str}
|
||||
${RESULTADO}"
|
||||
fi
|
||||
|
||||
"$ADN_RUN" db evento:actualizar "$EVENTO_ID" --fin "$HORA_FIN" --descripcion "$DESC_FINAL" >> "$LOGFILE" 2>&1
|
||||
echo "[$(date)] Copiloto: Evento $EVENTO_ID cerrado a las $HORA_FIN ($RESULTADO)" >> "$LOGFILE"
|
||||
fi
|
||||
BASH
|
||||
|
||||
vigia_path = File.join(COPILOTO_DIR, "#{tarea_id}.sh")
|
||||
File.write(vigia_path, vigia_script)
|
||||
FileUtils.chmod(0755, vigia_path)
|
||||
|
||||
pid = spawn("nohup bash #{vigia_path} &", [:out, :err] => '/dev/null')
|
||||
Process.detach(pid)
|
||||
|
||||
# Actualizar meta con PID
|
||||
meta[:pid] = pid
|
||||
meta[:estado] = 'vigilando'
|
||||
File.write(meta_file, JSON.pretty_generate(meta))
|
||||
|
||||
puts "#{Color::GREEN}✔ Copiloto lanzado#{Color::RESET}"
|
||||
puts " Tarea: #{tarea_id}"
|
||||
puts " PID: #{pid}"
|
||||
puts " Evento: #{evento_id || '(sin evento)'}"
|
||||
puts " Log: #{log_file}"
|
||||
puts " Comando: #{comando_str}"
|
||||
puts ""
|
||||
puts "#{Color::DIM}El copiloto cerrará el evento ##{evento_id} cuando termine.#{Color::RESET}" if evento_id
|
||||
puts "#{Color::DIM}Consultar: ./adn/tools/run copiloto estado#{Color::RESET}"
|
||||
end
|
||||
|
||||
# ─── vigilar: adjuntarse a un PID existente ──────────────────
|
||||
def cmd_vigilar(args)
|
||||
evento_id = nil
|
||||
pid = nil
|
||||
|
||||
OptionParser.new do |opts|
|
||||
opts.on("--evento ID", Integer, "ID del evento") { |e| evento_id = e }
|
||||
opts.on("--pid PID", Integer, "PID del proceso a vigilar") { |p| pid = p }
|
||||
end.parse!(args)
|
||||
|
||||
unless pid
|
||||
puts "#{Color::RED}✗ Falta --pid#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
tarea_id = "vigil_#{pid}_#{Time.now.strftime('%H%M%S')}"
|
||||
log_file = File.join(COPILOTO_DIR, "#{tarea_id}.log")
|
||||
|
||||
# Lanzar vigía para PID existente
|
||||
vigia_cmd = <<~CMD
|
||||
nohup bash -c '
|
||||
tail --pid=#{pid} -f /dev/null 2>/dev/null
|
||||
HORA_FIN=$(date +%%H:%%M)
|
||||
echo "[$(date)] Copiloto: PID #{pid} terminó" >> #{log_file}
|
||||
#{evento_id ? "\"#{ADN_RUN}\" db evento:actualizar #{evento_id} --fin $HORA_FIN >> #{log_file} 2>&1" : ""}
|
||||
echo "[$(date)] Copiloto: Evento #{evento_id} cerrado a las $HORA_FIN" >> #{log_file}
|
||||
' > /dev/null 2>&1 &
|
||||
CMD
|
||||
|
||||
system(vigia_cmd)
|
||||
|
||||
meta = {
|
||||
tarea_id: tarea_id,
|
||||
evento_id: evento_id,
|
||||
pid_vigilado: pid,
|
||||
inicio: Time.now.iso8601,
|
||||
estado: 'vigilando'
|
||||
}
|
||||
File.write(File.join(COPILOTO_DIR, "#{tarea_id}.json"), JSON.pretty_generate(meta))
|
||||
|
||||
puts "#{Color::GREEN}✔ Copiloto vigilando PID #{pid}#{Color::RESET}"
|
||||
puts " Evento: #{evento_id || '(sin evento)'}"
|
||||
puts " Log: #{log_file}"
|
||||
end
|
||||
|
||||
# ─── estado: mostrar tareas del copiloto ───────────────────────
|
||||
def cmd_estado
|
||||
metas = Dir.glob(File.join(COPILOTO_DIR, '*.json')).sort
|
||||
if metas.empty?
|
||||
puts "#{Color::DIM}Sin tareas de copiloto activas.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::BOLD}📋 Tareas del Copiloto#{Color::RESET}\n\n"
|
||||
metas.each do |f|
|
||||
m = JSON.parse(File.read(f))
|
||||
icono = case m['estado']
|
||||
when 'completado' then "#{Color::GREEN}✔#{Color::RESET}"
|
||||
when 'fallido' then "#{Color::RED}✖#{Color::RESET}"
|
||||
when 'vigilando' then "#{Color::YELLOW}⏳#{Color::RESET}"
|
||||
else "#{Color::DIM}?#{Color::RESET}"
|
||||
end
|
||||
|
||||
pid_info = m['pid'] || m['pid_vigilado']
|
||||
vivo = pid_info && system("kill -0 #{pid_info} 2>/dev/null")
|
||||
|
||||
puts " #{icono} #{m['tarea_id']}"
|
||||
puts " Evento: #{m['evento_id'] || '-'} PID: #{pid_info || '-'}#{vivo ? " #{Color::GREEN}(vivo)#{Color::RESET}" : ''}"
|
||||
puts " #{m['comando'] || "Vigilando PID #{m['pid_vigilado']}"}"
|
||||
puts " Inicio: #{m['inicio']}#{m['fin'] ? " Fin: #{m['fin']}" : ''}"
|
||||
puts ""
|
||||
end
|
||||
end
|
||||
|
||||
# ─── limpiar: borrar tareas completadas ──────────────────────
|
||||
def cmd_limpiar
|
||||
Dir.glob(File.join(COPILOTO_DIR, '*.json')).each do |f|
|
||||
m = JSON.parse(File.read(f))
|
||||
if ['completado', 'fallido'].include?(m['estado'])
|
||||
base = File.basename(f, '.json')
|
||||
['.json', '.log', '.sh'].each do |ext|
|
||||
FileUtils.rm_f(File.join(COPILOTO_DIR, "#{base}#{ext}"))
|
||||
end
|
||||
end
|
||||
end
|
||||
puts "#{Color::GREEN}✔ Tareas completadas limpiadas.#{Color::RESET}"
|
||||
end
|
||||
|
||||
def mostrar_ayuda
|
||||
puts <<~HELP
|
||||
#{Color::BOLD}#{Color::CYAN}Copiloto — Vigía de Tareas Largas#{Color::RESET}
|
||||
|
||||
El copiloto se encarga de las tareas de bajo nivel: ejecutar comandos
|
||||
largos en background y cerrar automáticamente eventos de bitácora
|
||||
cuando terminan. Delegar sin desperdiciar recursos.
|
||||
|
||||
#{Color::YELLOW}Uso:#{Color::RESET}
|
||||
copiloto lanzar --evento ID -- <comando> Lanzar y vigilar
|
||||
copiloto vigilar --evento ID --pid PID Vigilar PID existente
|
||||
copiloto estado Ver tareas activas
|
||||
copiloto limpiar Limpiar completadas
|
||||
|
||||
#{Color::YELLOW}Ejemplos:#{Color::RESET}
|
||||
./adn/tools/run copiloto lanzar --evento 1379 -- ./adn/tools/run bkps run C4 --batch
|
||||
./adn/tools/run copiloto vigilar --evento 1379 --pid 12345
|
||||
./adn/tools/run copiloto estado
|
||||
HELP
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,509 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/cli/dron.rb — Sistema de Drones Autónomos
|
||||
# ----------------------------------------------------------
|
||||
# Flota de drones que operan de forma autónoma:
|
||||
# - Lanzar tareas en background con auto-bitácora
|
||||
# - Vigilar procesos existentes
|
||||
# - Orquestar flota: detectar drones caídos, estancados
|
||||
# - Reportar salud del sistema en tiempo real
|
||||
#
|
||||
# Principio: Sistema Vivo — los drones operan, reportan
|
||||
# y se auto-gestionan sin intervención humana.
|
||||
#
|
||||
# Uso:
|
||||
# ./adn/tools/run dron lanzar --evento AUTO -- <comando>
|
||||
# ./adn/tools/run dron flota # Dashboard de la flota
|
||||
# ./adn/tools/run dron salud # Health check activo
|
||||
# ./adn/tools/run dron estado # Estado detallado
|
||||
# ./adn/tools/run dron limpiar # Limpiar completados
|
||||
# ==========================================================
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'time'
|
||||
require 'json'
|
||||
|
||||
module ADN
|
||||
class SubcomandoDron
|
||||
DRON_DIR = File.join(ADN::PROJECT_ROOT, 'tmp', 'dron')
|
||||
ADN_RUN = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'run')
|
||||
STALE_MINUTES = 120 # Dron sin actividad > 2h = posible problema
|
||||
|
||||
def initialize(args, logger = nil)
|
||||
@args = args
|
||||
@logger = logger
|
||||
FileUtils.mkdir_p(DRON_DIR)
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || @args.first == 'help' || @args.first == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
accion = @args.shift
|
||||
case accion
|
||||
when 'lanzar'
|
||||
cmd_lanzar(@args)
|
||||
when 'vigilar'
|
||||
cmd_vigilar(@args)
|
||||
when 'estado'
|
||||
cmd_estado
|
||||
when 'flota'
|
||||
cmd_flota
|
||||
when 'salud'
|
||||
cmd_salud
|
||||
when 'limpiar'
|
||||
cmd_limpiar
|
||||
else
|
||||
puts "#{Color::RED}✗ Acción desconocida: #{accion}#{Color::RESET}"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ─── lanzar: ejecutar comando en background y vigilar ────────
|
||||
def cmd_lanzar(args)
|
||||
evento_id = nil
|
||||
nota = nil
|
||||
|
||||
# Separar opciones del dron del comando a ejecutar
|
||||
separador = args.index('--')
|
||||
unless separador
|
||||
puts "#{Color::RED}✗ Falta separador '--' antes del comando a ejecutar#{Color::RESET}"
|
||||
puts "Uso: ./adn/tools/run dron lanzar --evento 1379 -- <comando>"
|
||||
return
|
||||
end
|
||||
|
||||
dron_args = args[0...separador]
|
||||
comando_args = args[(separador + 1)..-1]
|
||||
|
||||
OptionParser.new do |opts|
|
||||
opts.on("--evento ID", "ID del evento (o AUTO para crear uno)") { |e| evento_id = e }
|
||||
opts.on("--nota TEXTO", "Nota adicional para el cierre") { |n| nota = n }
|
||||
end.parse!(dron_args)
|
||||
|
||||
# AUTO: crear evento de bitácora automáticamente
|
||||
if evento_id == 'AUTO'
|
||||
# Primero generamos el tarea_id para incluirlo en la descripción del evento
|
||||
tarea_id = "dron_#{Time.now.strftime('%H%M%S')}_#{$$}"
|
||||
cmd_crear = "#{ADN_RUN} db evento:crear --ambito dtic-BKPs --nodo srv-ns8"
|
||||
cmd_crear += " --descripcion \"🛸 #{tarea_id}: #{(nota || comando_args.join(' ')).gsub('"', '\\"')}\""
|
||||
cmd_crear += " --inicio #{Time.now.strftime('%H:%M')} --modo R --ia"
|
||||
output = `#{cmd_crear} 2>&1`
|
||||
# El evento recién creado aparece como último en la lista de pendientes
|
||||
# Output: "⏳ [33mID 1413 [0m 17:12:00 srv-ns8 ..."
|
||||
all_ids = output.scan(/ID\s+(\d+)/).flatten.map(&:to_i)
|
||||
if output.include?('Entrada creada exitosamente') || output.include?('✅ Entrada creada')
|
||||
if all_ids.any?
|
||||
# Tomamos el ID más alto (el último creado)
|
||||
evento_id = all_ids.max
|
||||
@logger&.info("Dron: Evento AUTO ##{evento_id} creado para #{tarea_id}")
|
||||
else
|
||||
@logger&.warn("Dron: Evento creado pero no se pudo extraer ID del output")
|
||||
evento_id = nil
|
||||
end
|
||||
else
|
||||
@logger&.error("Dron: Error al crear evento AUTO: #{output}")
|
||||
evento_id = nil
|
||||
end
|
||||
elsif evento_id
|
||||
evento_id = evento_id.to_i
|
||||
end
|
||||
|
||||
if comando_args.empty?
|
||||
puts "#{Color::RED}✗ Falta el comando a ejecutar después de '--'#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
comando_str = comando_args.join(' ')
|
||||
tarea_id ||= "dron_#{Time.now.strftime('%H%M%S')}_#{$$}"
|
||||
log_file = File.join(DRON_DIR, "#{tarea_id}.log")
|
||||
meta_file = File.join(DRON_DIR, "#{tarea_id}.json")
|
||||
|
||||
# Guardar metadata
|
||||
meta = {
|
||||
tarea_id: tarea_id,
|
||||
evento_id: evento_id,
|
||||
comando: comando_str,
|
||||
nota: nota,
|
||||
inicio: Time.now.iso8601,
|
||||
pid: nil,
|
||||
estado: 'lanzando'
|
||||
}
|
||||
File.write(meta_file, JSON.pretty_generate(meta))
|
||||
|
||||
# Lanzar el proceso vigía en background
|
||||
nota_safe = (nota || "").gsub(%q{"}, %q{\"})
|
||||
cmd_safe = comando_str.gsub(%q{"}, %q{\"})
|
||||
inicio_epoch = Time.now.to_i
|
||||
vigia_script = <<~BASH
|
||||
#!/bin/bash
|
||||
# Dron vigia — auto-generado
|
||||
LOGFILE="#{log_file}"
|
||||
METAFILE="#{meta_file}"
|
||||
EVENTO_ID="#{evento_id}"
|
||||
ADN_RUN="#{ADN_RUN}"
|
||||
TAREA_ID="#{tarea_id}"
|
||||
INICIO_EPOCH=#{inicio_epoch}
|
||||
|
||||
actualizar_evento() {
|
||||
if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then
|
||||
"$ADN_RUN" db evento:actualizar "$EVENTO_ID" --descripcion "$1" >> "$LOGFILE" 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
HORA_INICIO=$(date +%H:%M:%S)
|
||||
DIARIO="Dron: $TAREA_ID
|
||||
--
|
||||
$HORA_INICIO - Inicio de vuelo
|
||||
Mision: #{nota_safe.empty? ? cmd_safe : nota_safe}
|
||||
Ejecutando: #{cmd_safe}"
|
||||
|
||||
echo "[$HORA_INICIO] Dron $TAREA_ID: Inicio de vuelo" >> "$LOGFILE"
|
||||
actualizar_evento "$DIARIO"
|
||||
|
||||
#{comando_str} >> "$LOGFILE" 2>&1
|
||||
EXIT_CODE=$?
|
||||
HORA_FIN=$(date +%H:%M:%S)
|
||||
DURACION=$(( ($(date +%s) - $INICIO_EPOCH) / 60 ))
|
||||
echo "[$HORA_FIN] Dron $TAREA_ID: Termino con codigo $EXIT_CODE" >> "$LOGFILE"
|
||||
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
DIARIO="$DIARIO
|
||||
OK $HORA_FIN - Completado (${DURACION}min)
|
||||
Aterrizaje limpio"
|
||||
else
|
||||
DIARIO="$DIARIO
|
||||
FALLO $HORA_FIN - Codigo $EXIT_CODE (${DURACION}min)
|
||||
Aterrizaje de emergencia"
|
||||
fi
|
||||
|
||||
ruby -rjson -e '
|
||||
f = ARGV[0]
|
||||
m = JSON.parse(File.read(f))
|
||||
m["fin"] = Time.now.iso8601
|
||||
m["exit_code"] = ARGV[1].to_i
|
||||
m["estado"] = ARGV[1].to_i == 0 ? "completado" : "fallido"
|
||||
File.write(f, JSON.pretty_generate(m))
|
||||
' "$METAFILE" "$EXIT_CODE"
|
||||
|
||||
if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then
|
||||
HORA_FIN_HM=$(date +%H:%M)
|
||||
"$ADN_RUN" db evento:actualizar "$EVENTO_ID" --fin "$HORA_FIN_HM" --descripcion "$DIARIO" >> "$LOGFILE" 2>&1
|
||||
echo "[$HORA_FIN] Dron $TAREA_ID: Evento $EVENTO_ID cerrado" >> "$LOGFILE"
|
||||
fi
|
||||
BASH
|
||||
|
||||
vigia_path = File.join(DRON_DIR, "#{tarea_id}.sh")
|
||||
File.write(vigia_path, vigia_script)
|
||||
FileUtils.chmod(0755, vigia_path)
|
||||
|
||||
pid = spawn("nohup bash #{vigia_path} &", [:out, :err] => '/dev/null')
|
||||
Process.detach(pid)
|
||||
|
||||
# Actualizar meta con PID
|
||||
meta[:pid] = pid
|
||||
meta[:estado] = 'vigilando'
|
||||
File.write(meta_file, JSON.pretty_generate(meta))
|
||||
|
||||
puts "#{Color::GREEN}✔ Dron lanzado#{Color::RESET}"
|
||||
puts " Tarea: #{tarea_id}"
|
||||
puts " PID: #{pid}"
|
||||
puts " Evento: #{evento_id || '(sin evento)'}"
|
||||
puts " Log: #{log_file}"
|
||||
puts " Comando: #{comando_str}"
|
||||
puts ""
|
||||
puts "#{Color::DIM}El dron cerrará el evento ##{evento_id} cuando termine.#{Color::RESET}" if evento_id
|
||||
puts "#{Color::DIM}Consultar: ./adn/tools/run dron estado#{Color::RESET}"
|
||||
end
|
||||
|
||||
# ─── vigilar: adjuntarse a un PID existente ──────────────────
|
||||
def cmd_vigilar(args)
|
||||
evento_id = nil
|
||||
pid = nil
|
||||
|
||||
OptionParser.new do |opts|
|
||||
opts.on("--evento ID", Integer, "ID del evento") { |e| evento_id = e }
|
||||
opts.on("--pid PID", Integer, "PID del proceso a vigilar") { |p| pid = p }
|
||||
end.parse!(args)
|
||||
|
||||
unless pid
|
||||
puts "#{Color::RED}✗ Falta --pid#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
tarea_id = "vigil_#{pid}_#{Time.now.strftime('%H%M%S')}"
|
||||
log_file = File.join(DRON_DIR, "#{tarea_id}.log")
|
||||
|
||||
# Lanzar vigía para PID existente
|
||||
vigia_cmd = <<~CMD
|
||||
nohup bash -c '
|
||||
tail --pid=#{pid} -f /dev/null 2>/dev/null
|
||||
HORA_FIN=$(date +%%H:%%M)
|
||||
echo "[$(date)] Dron: PID #{pid} terminó" >> #{log_file}
|
||||
#{evento_id ? "\"#{ADN_RUN}\" db evento:actualizar #{evento_id} --fin $HORA_FIN >> #{log_file} 2>&1" : ""}
|
||||
echo "[$(date)] Dron: Evento #{evento_id} cerrado a las $HORA_FIN" >> #{log_file}
|
||||
' > /dev/null 2>&1 &
|
||||
CMD
|
||||
|
||||
system(vigia_cmd)
|
||||
|
||||
meta = {
|
||||
tarea_id: tarea_id,
|
||||
evento_id: evento_id,
|
||||
pid_vigilado: pid,
|
||||
inicio: Time.now.iso8601,
|
||||
estado: 'vigilando'
|
||||
}
|
||||
File.write(File.join(DRON_DIR, "#{tarea_id}.json"), JSON.pretty_generate(meta))
|
||||
|
||||
puts "#{Color::GREEN}✔ Dron vigilando PID #{pid}#{Color::RESET}"
|
||||
puts " Evento: #{evento_id || '(sin evento)'}"
|
||||
puts " Log: #{log_file}"
|
||||
end
|
||||
|
||||
# ─── estado: mostrar tareas del dron ───────────────────────
|
||||
def cmd_estado
|
||||
metas = Dir.glob(File.join(DRON_DIR, '*.json')).sort
|
||||
if metas.empty?
|
||||
puts "#{Color::DIM}Sin tareas de dron activas.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::BOLD}📋 Tareas del Dron#{Color::RESET}\n\n"
|
||||
metas.each do |f|
|
||||
m = JSON.parse(File.read(f))
|
||||
icono = case m['estado']
|
||||
when 'completado' then "#{Color::GREEN}✔#{Color::RESET}"
|
||||
when 'fallido' then "#{Color::RED}✖#{Color::RESET}"
|
||||
when 'vigilando' then "#{Color::YELLOW}⏳#{Color::RESET}"
|
||||
else "#{Color::DIM}?#{Color::RESET}"
|
||||
end
|
||||
|
||||
pid_info = m['pid'] || m['pid_vigilado']
|
||||
vivo = pid_info && system("kill -0 #{pid_info} 2>/dev/null")
|
||||
|
||||
puts " #{icono} #{m['tarea_id']}"
|
||||
puts " Evento: #{m['evento_id'] || '-'} PID: #{pid_info || '-'}#{vivo ? " #{Color::GREEN}(vivo)#{Color::RESET}" : ''}"
|
||||
puts " #{m['comando'] || "Vigilando PID #{m['pid_vigilado']}"}"
|
||||
puts " Inicio: #{m['inicio']}#{m['fin'] ? " Fin: #{m['fin']}" : ''}"
|
||||
puts ""
|
||||
end
|
||||
end
|
||||
|
||||
# ─── flota: dashboard compacto de la flota ─────────────────────
|
||||
def cmd_flota
|
||||
drones = cargar_todos
|
||||
|
||||
if drones.empty?
|
||||
puts "#{Color::DIM}🛸 Flota vacía — sin drones activos.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
activos = drones.select { |d| d['estado'] == 'vigilando' }
|
||||
completados = drones.select { |d| d['estado'] == 'completado' }
|
||||
fallidos = drones.select { |d| d['estado'] == 'fallido' }
|
||||
|
||||
puts "\n#{Color::BOLD}#{Color::CYAN}🛸 Flota de Drones#{Color::RESET}\n"
|
||||
puts "─" * 70
|
||||
|
||||
# Activos
|
||||
if activos.any?
|
||||
puts "#{Color::YELLOW}⏳ En vuelo (#{activos.length})#{Color::RESET}"
|
||||
activos.each do |d|
|
||||
pid = d['pid'] || d['pid_vigilado']
|
||||
vivo = pid && system("kill -0 #{pid} 2>/dev/null")
|
||||
elapsed = tiempo_transcurrido(d['inicio'])
|
||||
comando_corto = (d['comando'] || d['nota'] || '?')[0..55]
|
||||
estado_pid = vivo ? "#{Color::GREEN}●#{Color::RESET}" : "#{Color::RED}●#{Color::RESET}"
|
||||
puts " #{estado_pid} #{d['tarea_id']} #{elapsed.rjust(8)} #{comando_corto}"
|
||||
end
|
||||
puts ""
|
||||
end
|
||||
|
||||
# Completados
|
||||
if completados.any?
|
||||
puts "#{Color::GREEN}✔ Completados (#{completados.length})#{Color::RESET}"
|
||||
completados.last(5).each do |d|
|
||||
duracion = calcular_duracion(d['inicio'], d['fin'])
|
||||
comando_corto = (d['nota'] || d['comando'] || '?')[0..55]
|
||||
puts " ✔ #{d['tarea_id']} #{duracion.rjust(8)} #{comando_corto}"
|
||||
end
|
||||
puts ""
|
||||
end
|
||||
|
||||
# Fallidos
|
||||
if fallidos.any?
|
||||
puts "#{Color::RED}✖ Fallidos (#{fallidos.length})#{Color::RESET}"
|
||||
fallidos.each do |d|
|
||||
comando_corto = (d['nota'] || d['comando'] || '?')[0..55]
|
||||
puts " ✖ #{d['tarea_id']} exit=#{d['exit_code']} #{comando_corto}"
|
||||
end
|
||||
puts ""
|
||||
end
|
||||
|
||||
puts "─" * 70
|
||||
puts "#{Color::BOLD}Total: #{drones.length} drones | " \
|
||||
"#{Color::YELLOW}⏳#{activos.length}#{Color::RESET} | " \
|
||||
"#{Color::GREEN}✔#{completados.length}#{Color::RESET} | " \
|
||||
"#{Color::RED}✖#{fallidos.length}#{Color::RESET}"
|
||||
end
|
||||
|
||||
# ─── salud: health check activo ────────────────────────────────
|
||||
def cmd_salud
|
||||
drones = cargar_todos
|
||||
problemas = []
|
||||
|
||||
if drones.empty?
|
||||
puts "#{Color::GREEN}✔ Sin drones — nada que verificar.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "\n#{Color::BOLD}🏥 Diagnóstico de Salud#{Color::RESET}\n"
|
||||
puts "─" * 70
|
||||
|
||||
drones.each do |d|
|
||||
next unless d['estado'] == 'vigilando'
|
||||
|
||||
pid = d['pid'] || d['pid_vigilado']
|
||||
vivo = pid && system("kill -0 #{pid} 2>/dev/null")
|
||||
elapsed_min = minutos_transcurridos(d['inicio'])
|
||||
|
||||
# Detectar zombie: meta dice vigilando pero PID muerto
|
||||
if !vivo
|
||||
problemas << { dron: d['tarea_id'], tipo: '💀 Zombie', detalle: "PID #{pid} muerto pero estado=vigilando", meta: d }
|
||||
puts " #{Color::RED}💀 ZOMBIE#{Color::RESET} #{d['tarea_id']} — PID #{pid} muerto"
|
||||
|
||||
# Auto-reparar: actualizar metadata
|
||||
meta_path = File.join(DRON_DIR, "#{d['tarea_id']}.json")
|
||||
if File.exist?(meta_path)
|
||||
d['estado'] = 'fallido'
|
||||
d['fin'] = Time.now.iso8601
|
||||
d['exit_code'] = -1
|
||||
d['nota_salud'] = 'Auto-detectado como zombie por dron salud'
|
||||
File.write(meta_path, JSON.pretty_generate(d))
|
||||
puts " #{Color::DIM}→ Auto-reparado: marcado como fallido#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# Detectar estancado: lleva mucho tiempo
|
||||
if vivo && elapsed_min > STALE_MINUTES
|
||||
problemas << { dron: d['tarea_id'], tipo: '🐌 Estancado', detalle: "#{elapsed_min}min sin finalizar" }
|
||||
puts " #{Color::YELLOW}🐌 ESTANCADO#{Color::RESET} #{d['tarea_id']} — #{elapsed_min}min activo"
|
||||
|
||||
# Verificar si el log sigue creciendo
|
||||
log_path = File.join(DRON_DIR, "#{d['tarea_id']}.log")
|
||||
if File.exist?(log_path)
|
||||
log_age = ((Time.now - File.mtime(log_path)) / 60).to_i
|
||||
if log_age > 30
|
||||
puts " #{Color::DIM}→ Log sin actualizar hace #{log_age}min#{Color::RESET}"
|
||||
else
|
||||
puts " #{Color::DIM}→ Log activo (última escritura hace #{log_age}min)#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Sano
|
||||
if vivo && elapsed_min <= STALE_MINUTES
|
||||
puts " #{Color::GREEN}💚 SANO#{Color::RESET} #{d['tarea_id']} — #{elapsed_min}min, PID #{pid} activo"
|
||||
end
|
||||
end
|
||||
|
||||
# Eventos huérfanos: drones con evento_id pero sin PID vivo
|
||||
eventos_pendientes = drones.select { |d| d['estado'] == 'fallido' && d['evento_id'] }
|
||||
if eventos_pendientes.any?
|
||||
puts "\n#{Color::YELLOW}⚠ Eventos con drones fallidos:#{Color::RESET}"
|
||||
eventos_pendientes.each do |d|
|
||||
puts " Evento ##{d['evento_id']} — #{d['tarea_id']} (exit=#{d['exit_code']})"
|
||||
end
|
||||
end
|
||||
|
||||
puts ""
|
||||
puts "─" * 70
|
||||
if problemas.empty?
|
||||
puts "#{Color::GREEN}#{Color::BOLD}✔ Flota saludable — sin problemas detectados.#{Color::RESET}"
|
||||
else
|
||||
puts "#{Color::RED}#{Color::BOLD}⚠ #{problemas.length} problema(s) detectado(s).#{Color::RESET}"
|
||||
end
|
||||
end
|
||||
|
||||
# ─── limpiar: borrar tareas completadas ──────────────────────
|
||||
def cmd_limpiar
|
||||
Dir.glob(File.join(DRON_DIR, '*.json')).each do |f|
|
||||
m = JSON.parse(File.read(f))
|
||||
if ['completado', 'fallido'].include?(m['estado'])
|
||||
base = File.basename(f, '.json')
|
||||
['.json', '.log', '.sh'].each do |ext|
|
||||
FileUtils.rm_f(File.join(DRON_DIR, "#{base}#{ext}"))
|
||||
end
|
||||
end
|
||||
end
|
||||
puts "#{Color::GREEN}✔ Tareas completadas limpiadas.#{Color::RESET}"
|
||||
end
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────
|
||||
def cargar_todos
|
||||
Dir.glob(File.join(DRON_DIR, '*.json')).sort.map do |f|
|
||||
JSON.parse(File.read(f)) rescue nil
|
||||
end.compact
|
||||
end
|
||||
|
||||
def tiempo_transcurrido(inicio_str)
|
||||
return '?' unless inicio_str
|
||||
mins = ((Time.now - Time.parse(inicio_str)) / 60).to_i
|
||||
if mins < 60
|
||||
"#{mins}min"
|
||||
else
|
||||
"#{mins / 60}h#{mins % 60}m"
|
||||
end
|
||||
end
|
||||
|
||||
def calcular_duracion(inicio_str, fin_str)
|
||||
return '?' unless inicio_str && fin_str
|
||||
mins = ((Time.parse(fin_str) - Time.parse(inicio_str)) / 60).to_i
|
||||
if mins < 60
|
||||
"#{mins}min"
|
||||
else
|
||||
"#{mins / 60}h#{mins % 60}m"
|
||||
end
|
||||
end
|
||||
|
||||
def minutos_transcurridos(inicio_str)
|
||||
return 0 unless inicio_str
|
||||
((Time.now - Time.parse(inicio_str)) / 60).to_i
|
||||
end
|
||||
|
||||
def mostrar_ayuda
|
||||
puts <<~HELP
|
||||
#{Color::BOLD}#{Color::CYAN}🛸 Dron — Sistema de Drones Autónomos#{Color::RESET}
|
||||
|
||||
Flota de drones que operan de forma autónoma: ejecutan tareas
|
||||
en background, registran en bitácora, detectan problemas y
|
||||
se auto-gestionan. Sistema vivo, no estático.
|
||||
|
||||
#{Color::YELLOW}Comandos:#{Color::RESET}
|
||||
dron lanzar --evento ID -- <cmd> Lanzar dron de trabajo
|
||||
dron flota Dashboard compacto de la flota
|
||||
dron salud Health check (detecta zombies/estancados)
|
||||
dron estado Estado detallado de cada dron
|
||||
dron vigilar --evento ID --pid PID Vigilar PID existente
|
||||
dron limpiar Limpiar completados/fallidos
|
||||
|
||||
#{Color::YELLOW}Evento AUTO:#{Color::RESET}
|
||||
--evento AUTO crea un evento de bitácora automáticamente.
|
||||
--nota "texto" define la descripción del evento.
|
||||
|
||||
#{Color::YELLOW}Ejemplos:#{Color::RESET}
|
||||
./adn/tools/run dron lanzar --evento AUTO --nota "Backup srvv-sitio" -- ./adn/tools/run bkps backup srvv-sitio
|
||||
./adn/tools/run dron flota
|
||||
./adn/tools/run dron salud
|
||||
HELP
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env ruby
|
||||
# frozen_string_literal: true
|
||||
|
||||
# ==========================================================
|
||||
# adn/tools/cli/novato.rb — Vigía de tareas largas
|
||||
# ----------------------------------------------------------
|
||||
# El "novato" se encarga de tareas de bajo nivel:
|
||||
# - Lanzar un comando y vigilar hasta que termine
|
||||
# - Cerrar automáticamente el evento de bitácora al finalizar
|
||||
# - Registrar resultado (éxito/fallo) en la descripción
|
||||
#
|
||||
# Principio: Menos es Más — una tool reutilizable para
|
||||
# cualquier ámbito que necesite delegar tareas largas.
|
||||
#
|
||||
# Uso:
|
||||
# ./adn/tools/run novato lanzar --evento 1379 -- ./adn/tools/run bkps run C4 --batch
|
||||
# ./adn/tools/run novato vigilar --evento 1379 --pid 12345
|
||||
# ./adn/tools/run novato estado
|
||||
# ==========================================================
|
||||
|
||||
require 'optparse'
|
||||
require 'fileutils'
|
||||
require 'time'
|
||||
require 'json'
|
||||
|
||||
module ADN
|
||||
class SubcomandoNovato
|
||||
NOVATO_DIR = File.join(ADN::PROJECT_ROOT, 'tmp', 'novato')
|
||||
ADN_RUN = File.join(ADN::PROJECT_ROOT, 'adn', 'tools', 'run')
|
||||
|
||||
def initialize(args, logger = nil)
|
||||
@args = args
|
||||
@logger = logger
|
||||
FileUtils.mkdir_p(NOVATO_DIR)
|
||||
end
|
||||
|
||||
def ejecutar
|
||||
if @args.empty? || @args.first == 'help' || @args.first == '--help'
|
||||
mostrar_ayuda
|
||||
return
|
||||
end
|
||||
|
||||
accion = @args.shift
|
||||
case accion
|
||||
when 'lanzar'
|
||||
cmd_lanzar(@args)
|
||||
when 'vigilar'
|
||||
cmd_vigilar(@args)
|
||||
when 'estado'
|
||||
cmd_estado
|
||||
when 'limpiar'
|
||||
cmd_limpiar
|
||||
else
|
||||
puts "#{Color::RED}✗ Acción desconocida: #{accion}#{Color::RESET}"
|
||||
mostrar_ayuda
|
||||
exit 1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# ─── lanzar: ejecutar comando en background y vigilar ────────
|
||||
def cmd_lanzar(args)
|
||||
evento_id = nil
|
||||
nota = nil
|
||||
|
||||
# Separar opciones del novato del comando a ejecutar
|
||||
separador = args.index('--')
|
||||
unless separador
|
||||
puts "#{Color::RED}✗ Falta separador '--' antes del comando a ejecutar#{Color::RESET}"
|
||||
puts "Uso: ./adn/tools/run novato lanzar --evento 1379 -- <comando>"
|
||||
return
|
||||
end
|
||||
|
||||
novato_args = args[0...separador]
|
||||
comando_args = args[(separador + 1)..-1]
|
||||
|
||||
OptionParser.new do |opts|
|
||||
opts.on("--evento ID", Integer, "ID del evento de bitácora a cerrar") { |e| evento_id = e }
|
||||
opts.on("--nota TEXTO", "Nota adicional para el cierre") { |n| nota = n }
|
||||
end.parse!(novato_args)
|
||||
|
||||
if comando_args.empty?
|
||||
puts "#{Color::RED}✗ Falta el comando a ejecutar después de '--'#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
comando_str = comando_args.join(' ')
|
||||
tarea_id = "novato_#{Time.now.strftime('%H%M%S')}_#{$$}"
|
||||
log_file = File.join(NOVATO_DIR, "#{tarea_id}.log")
|
||||
meta_file = File.join(NOVATO_DIR, "#{tarea_id}.json")
|
||||
|
||||
# Guardar metadata
|
||||
meta = {
|
||||
tarea_id: tarea_id,
|
||||
evento_id: evento_id,
|
||||
comando: comando_str,
|
||||
nota: nota,
|
||||
inicio: Time.now.iso8601,
|
||||
pid: nil,
|
||||
estado: 'lanzando'
|
||||
}
|
||||
File.write(meta_file, JSON.pretty_generate(meta))
|
||||
|
||||
# Lanzar el proceso vigía en background
|
||||
vigia_script = <<~BASH
|
||||
#!/bin/bash
|
||||
# Novato vigía — auto-generado
|
||||
LOGFILE="#{log_file}"
|
||||
METAFILE="#{meta_file}"
|
||||
EVENTO_ID="#{evento_id}"
|
||||
ADN_RUN="#{ADN_RUN}"
|
||||
|
||||
echo "[$(date)] Novato: Iniciando comando..." >> "$LOGFILE"
|
||||
#{comando_str} >> "$LOGFILE" 2>&1
|
||||
EXIT_CODE=$?
|
||||
echo "[$(date)] Novato: Comando terminó con código $EXIT_CODE" >> "$LOGFILE"
|
||||
|
||||
# Actualizar metadata
|
||||
ruby -rjson -e '
|
||||
f = "'"$METAFILE"'"
|
||||
m = JSON.parse(File.read(f))
|
||||
m["fin"] = Time.now.iso8601
|
||||
m["exit_code"] = #{'"$EXIT_CODE".to_i'}
|
||||
m["estado"] = #{'"$EXIT_CODE".to_i'} == 0 ? "completado" : "fallido"
|
||||
File.write(f, JSON.pretty_generate(m))
|
||||
'
|
||||
|
||||
# Cerrar evento en bitácora si hay evento_id
|
||||
if [ -n "$EVENTO_ID" ] && [ "$EVENTO_ID" != "" ]; then
|
||||
HORA_FIN=$(date +%H:%M)
|
||||
DURACION=$(( ($(date +%s) - #{Time.now.to_i}) / 60 ))
|
||||
|
||||
if [ "$EXIT_CODE" -eq 0 ]; then
|
||||
RESULTADO="- 🤖 Novato: ✔ Completado exitosamente (${DURACION}min)"
|
||||
else
|
||||
RESULTADO="- 🤖 Novato: ✖ Falló con código $EXIT_CODE (${DURACION}min)"
|
||||
fi
|
||||
|
||||
NOTA_BASE="#{(nota || '').gsub('"', '\\"')}"
|
||||
if [ -n "$NOTA_BASE" ]; then
|
||||
DESC_FINAL="${NOTA_BASE}
|
||||
${RESULTADO}"
|
||||
else
|
||||
DESC_FINAL="#{comando_str}
|
||||
${RESULTADO}"
|
||||
fi
|
||||
|
||||
"$ADN_RUN" db evento:actualizar "$EVENTO_ID" --fin "$HORA_FIN" --descripcion "$DESC_FINAL" >> "$LOGFILE" 2>&1
|
||||
echo "[$(date)] Novato: Evento $EVENTO_ID cerrado a las $HORA_FIN ($RESULTADO)" >> "$LOGFILE"
|
||||
fi
|
||||
BASH
|
||||
|
||||
vigia_path = File.join(NOVATO_DIR, "#{tarea_id}.sh")
|
||||
File.write(vigia_path, vigia_script)
|
||||
FileUtils.chmod(0755, vigia_path)
|
||||
|
||||
pid = spawn("nohup bash #{vigia_path} &", [:out, :err] => '/dev/null')
|
||||
Process.detach(pid)
|
||||
|
||||
# Actualizar meta con PID
|
||||
meta[:pid] = pid
|
||||
meta[:estado] = 'vigilando'
|
||||
File.write(meta_file, JSON.pretty_generate(meta))
|
||||
|
||||
puts "#{Color::GREEN}✔ Novato lanzado#{Color::RESET}"
|
||||
puts " Tarea: #{tarea_id}"
|
||||
puts " PID: #{pid}"
|
||||
puts " Evento: #{evento_id || '(sin evento)'}"
|
||||
puts " Log: #{log_file}"
|
||||
puts " Comando: #{comando_str}"
|
||||
puts ""
|
||||
puts "#{Color::DIM}El novato cerrará el evento ##{evento_id} cuando termine.#{Color::RESET}" if evento_id
|
||||
puts "#{Color::DIM}Consultar: ./adn/tools/run novato estado#{Color::RESET}"
|
||||
end
|
||||
|
||||
# ─── vigilar: adjuntarse a un PID existente ──────────────────
|
||||
def cmd_vigilar(args)
|
||||
evento_id = nil
|
||||
pid = nil
|
||||
|
||||
OptionParser.new do |opts|
|
||||
opts.on("--evento ID", Integer, "ID del evento") { |e| evento_id = e }
|
||||
opts.on("--pid PID", Integer, "PID del proceso a vigilar") { |p| pid = p }
|
||||
end.parse!(args)
|
||||
|
||||
unless pid
|
||||
puts "#{Color::RED}✗ Falta --pid#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
tarea_id = "vigil_#{pid}_#{Time.now.strftime('%H%M%S')}"
|
||||
log_file = File.join(NOVATO_DIR, "#{tarea_id}.log")
|
||||
|
||||
# Lanzar vigía para PID existente
|
||||
vigia_cmd = <<~CMD
|
||||
nohup bash -c '
|
||||
tail --pid=#{pid} -f /dev/null 2>/dev/null
|
||||
HORA_FIN=$(date +%%H:%%M)
|
||||
echo "[$(date)] Novato: PID #{pid} terminó" >> #{log_file}
|
||||
#{evento_id ? "\"#{ADN_RUN}\" db evento:actualizar #{evento_id} --fin $HORA_FIN >> #{log_file} 2>&1" : ""}
|
||||
echo "[$(date)] Novato: Evento #{evento_id} cerrado a las $HORA_FIN" >> #{log_file}
|
||||
' > /dev/null 2>&1 &
|
||||
CMD
|
||||
|
||||
system(vigia_cmd)
|
||||
|
||||
meta = {
|
||||
tarea_id: tarea_id,
|
||||
evento_id: evento_id,
|
||||
pid_vigilado: pid,
|
||||
inicio: Time.now.iso8601,
|
||||
estado: 'vigilando'
|
||||
}
|
||||
File.write(File.join(NOVATO_DIR, "#{tarea_id}.json"), JSON.pretty_generate(meta))
|
||||
|
||||
puts "#{Color::GREEN}✔ Novato vigilando PID #{pid}#{Color::RESET}"
|
||||
puts " Evento: #{evento_id || '(sin evento)'}"
|
||||
puts " Log: #{log_file}"
|
||||
end
|
||||
|
||||
# ─── estado: mostrar tareas del novato ───────────────────────
|
||||
def cmd_estado
|
||||
metas = Dir.glob(File.join(NOVATO_DIR, '*.json')).sort
|
||||
if metas.empty?
|
||||
puts "#{Color::DIM}Sin tareas de novato activas.#{Color::RESET}"
|
||||
return
|
||||
end
|
||||
|
||||
puts "#{Color::BOLD}📋 Tareas del Novato#{Color::RESET}\n\n"
|
||||
metas.each do |f|
|
||||
m = JSON.parse(File.read(f))
|
||||
icono = case m['estado']
|
||||
when 'completado' then "#{Color::GREEN}✔#{Color::RESET}"
|
||||
when 'fallido' then "#{Color::RED}✖#{Color::RESET}"
|
||||
when 'vigilando' then "#{Color::YELLOW}⏳#{Color::RESET}"
|
||||
else "#{Color::DIM}?#{Color::RESET}"
|
||||
end
|
||||
|
||||
pid_info = m['pid'] || m['pid_vigilado']
|
||||
vivo = pid_info && system("kill -0 #{pid_info} 2>/dev/null")
|
||||
|
||||
puts " #{icono} #{m['tarea_id']}"
|
||||
puts " Evento: #{m['evento_id'] || '-'} PID: #{pid_info || '-'}#{vivo ? " #{Color::GREEN}(vivo)#{Color::RESET}" : ''}"
|
||||
puts " #{m['comando'] || "Vigilando PID #{m['pid_vigilado']}"}"
|
||||
puts " Inicio: #{m['inicio']}#{m['fin'] ? " Fin: #{m['fin']}" : ''}"
|
||||
puts ""
|
||||
end
|
||||
end
|
||||
|
||||
# ─── limpiar: borrar tareas completadas ──────────────────────
|
||||
def cmd_limpiar
|
||||
Dir.glob(File.join(NOVATO_DIR, '*.json')).each do |f|
|
||||
m = JSON.parse(File.read(f))
|
||||
if ['completado', 'fallido'].include?(m['estado'])
|
||||
base = File.basename(f, '.json')
|
||||
['.json', '.log', '.sh'].each do |ext|
|
||||
FileUtils.rm_f(File.join(NOVATO_DIR, "#{base}#{ext}"))
|
||||
end
|
||||
end
|
||||
end
|
||||
puts "#{Color::GREEN}✔ Tareas completadas limpiadas.#{Color::RESET}"
|
||||
end
|
||||
|
||||
def mostrar_ayuda
|
||||
puts <<~HELP
|
||||
#{Color::BOLD}#{Color::CYAN}Novato — Vigía de Tareas Largas#{Color::RESET}
|
||||
|
||||
El novato se encarga de las tareas de bajo nivel: ejecutar comandos
|
||||
largos en background y cerrar automáticamente eventos de bitácora
|
||||
cuando terminan. Delegar sin desperdiciar recursos.
|
||||
|
||||
#{Color::YELLOW}Uso:#{Color::RESET}
|
||||
novato lanzar --evento ID -- <comando> Lanzar y vigilar
|
||||
novato vigilar --evento ID --pid PID Vigilar PID existente
|
||||
novato estado Ver tareas activas
|
||||
novato limpiar Limpiar completadas
|
||||
|
||||
#{Color::YELLOW}Ejemplos:#{Color::RESET}
|
||||
./adn/tools/run novato lanzar --evento 1379 -- ./adn/tools/run bkps run C4 --batch
|
||||
./adn/tools/run novato vigilar --evento 1379 --pid 12345
|
||||
./adn/tools/run novato estado
|
||||
HELP
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,7 @@
|
||||
require 'date'
|
||||
require 'time'
|
||||
require 'json'
|
||||
require_relative '../cli/backup/configurador'
|
||||
# require_relative '../cli/backup/configurador' # Archivado → _hist/ (no usado aquí)
|
||||
|
||||
module ADN
|
||||
class Conciliador
|
||||
|
||||
@@ -1 +1 @@
|
||||
{ "command": "Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0; Set-Service -Name sshd -StartupType 'Automatic'; Start-Service sshd; New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server' -Enabled $True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 7022 -Profile Any -ErrorAction SilentlyContinue; $c = Get-Content 'C:\\ProgramData\\ssh\\sshd_config'; if ($c -notmatch 'Port 7022') { $c = $c -replace '#Port 22', 'Port 7022'; $c | Set-Content 'C:\\ProgramData\\ssh\\sshd_config'; Restart-Service sshd }; if (Test-Path D:\\guest-agent\\qemu-ga-x86_64.msi) { msiexec /i D:\\guest-agent\\qemu-ga-x86_64.msi /quiet /norestart }; if (Test-Path E:\\guest-agent\\qemu-ga-x86_64.msi) { msiexec /i E:\\guest-agent\\qemu-ga-x86_64.msi /quiet /norestart }; Start-Service QEMU-GA; Invoke-WebRequest 'https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe' -OutFile 'C:\\tailscale-setup.exe'; Start-Process 'C:\\tailscale-setup.exe' -ArgumentList '/quiet' -Wait; & \"C:\\Program Files\\Tailscale\\tailscale.exe\" up", "timestamp": "2026-03-30T17:59:00", "id": "sql2_payload" }
|
||||
{"id":2,"cmd":"$ErrorActionPreference='Stop'; Write-Host '=== Instalando QEMU-GA ==='; msiexec /i D:\\guest-agent\\qemu-ga-x86_64.msi /quiet /norestart; Start-Sleep 10; Start-Service QEMU-GA; Write-Host 'QEMU-GA: ' (Get-Service QEMU-GA).Status; Write-Host '=== Instalando Tailscale ==='; [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; iwr https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe -OutFile C:\\tailscale-setup.exe -UseBasicParsing; Start-Process C:\\tailscale-setup.exe /quiet -Wait; Start-Sleep 5; Write-Host '=== Conectando Tailscale ==='; $out = & 'C:\\Program Files\\Tailscale\\tailscale.exe' up 2>&1; Write-Host $out; Write-Host '=== IP Tailscale ==='; & 'C:\\Program Files\\Tailscale\\tailscale.exe' ip"}
|
||||
|
||||
Reference in New Issue
Block a user