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:
Ricardo Monla
2026-04-07 10:23:53 -03:00
co-authored by Claude Opus 4.6
parent 018e45e046
commit 1802c25194
61 changed files with 3148 additions and 1201 deletions
+71
View File
@@ -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
+86
View File
@@ -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
+150
View File
@@ -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
+88
View File
@@ -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
+54
View File
@@ -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