Files
Homelabs-Pi-Storage/tools/raid_manager.py
T

6127 lines
287 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
RAID Configuration Manager for Raspberry Pi
A modern Python replacement for the bash raid_script.sh
Features:
- ZFS and BTRFS management
- Interactive CLI with rich formatting
- Modular, object-oriented design
- Comprehensive error handling
- Easy configuration and maintenance
"""
import os
import sys
import json
import subprocess
import logging
import time
import math
import datetime
import re
from pathlib import Path
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass, field
from enum import Enum
import argparse
# Try to import rich for better CLI experience
try:
from rich.console import Console as RichConsole
from rich.table import Table
from rich.panel import Panel
from rich.prompt import Prompt, Confirm
from rich.progress import Progress, SpinnerColumn, TextColumn
from rich.text import Text
RICH_AVAILABLE = True
except ImportError:
RICH_AVAILABLE = False
print("⚠️ Para una mejor experiencia, instala rich: pip install rich")
class RAIDType(Enum):
"""Tipos de RAID soportados"""
STRIPE = "stripe"
MIRROR = "mirror"
RAIDZ1 = "raidz1"
RAIDZ2 = "raidz2"
RAIDZ3 = "raidz3"
BTRFS_RAID0 = "btrfs_raid0"
BTRFS_RAID1 = "btrfs_raid1"
BTRFS_RAID10 = "btrfs_raid10"
BTRFS_RAID5 = "btrfs_raid5"
BTRFS_RAID6 = "btrfs_raid6"
class FilesystemType(Enum):
"""Tipos de filesystem soportados"""
ZFS = "zfs"
BTRFS = "btrfs"
@dataclass
class Disk:
"""Representa un disco en el sistema"""
name: str
size: int
model: str
serial: str
sector_size: int
is_system: bool = False
has_partitions: bool = False
filesystem_type: Optional[str] = None
mount_points: List[str] = field(default_factory=list)
@property
def device_path(self) -> str:
return f"/dev/{self.name}"
@property
def size_human(self) -> str:
"""Tamaño en formato legible"""
size = self.size
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size < 1024:
return f"{size:.1f} {unit}"
size /= 1024
return f"{size:.1f} PB"
@dataclass
class Pool:
"""Representa un pool ZFS o filesystem BTRFS"""
name: str
type: FilesystemType
raid_type: RAIDType
disks: List[Disk]
health: str
size: int
used: int
available: int
ashift: Optional[int] = None
@property
def usage_percent(self) -> float:
return (self.used / self.size) * 100 if self.size > 0 else 0
class UIConsole:
"""Manejo de la interfaz de usuario"""
def __init__(self):
if RICH_AVAILABLE:
self.console = RichConsole()
else:
self.console = None
def print(self, message: str, style: str = ""):
"""Imprime un mensaje con estilo opcional"""
if RICH_AVAILABLE and self.console:
self.console.print(message, style=style)
else:
print(message)
def print_panel(self, message: str, title: str = "", style: str = ""):
"""Imprime un panel con mensaje"""
if RICH_AVAILABLE and self.console:
panel = Panel(message, title=title, style=style)
self.console.print(panel)
else:
print(f"\n=== {title} ===")
print(message)
print("=" * (len(title) + 8))
def prompt(self, message: str, default: str = "") -> str:
"""Solicita input del usuario"""
if RICH_AVAILABLE:
return Prompt.ask(message, default=default)
else:
response = input(f"{message} [{default}]: ").strip()
return response if response else default
def confirm(self, message: str, default: bool = False) -> bool:
"""Solicita confirmación del usuario"""
if RICH_AVAILABLE:
return Confirm.ask(message, default=default)
else:
while True:
response = input(f"{message} ({'S/n' if default else 's/N'}): ").strip().lower()
if not response:
return default
if response in ['s', 'sí', 'si', 'y', 'yes']:
return True
elif response in ['n', 'no']:
return False
print("Por favor responde 's' (sí) o 'n' (no)")
class RequirementsChecker:
"""Verifica y gestiona los requisitos del sistema"""
def __init__(self, console: UIConsole, system_manager):
self.console = console
self.system = system_manager
self.required_packages = {
'basic': ['util-linux', 'parted'],
'btrfs': ['btrfs-progs'],
'zfs': ['zfsutils-linux'],
'mdadm': ['mdadm']
}
self.required_commands = {
'basic': ['lsblk', 'parted', 'wipefs'],
'btrfs': ['mkfs.btrfs', 'btrfs'],
'zfs': ['zpool', 'zfs'],
'mdadm': ['mdadm']
}
def check_all_requirements(self) -> bool:
"""Verifica todos los requisitos del sistema"""
self.console.print_panel(
"Verificando requisitos del sistema...",
title="🔍 Verificación de Requisitos"
)
# Verificar y instalar paquetes de Python necesarios
python_ok = self._check_and_install_python_packages()
# Verificar herramientas básicas del sistema
basic_ok = self._check_basic_tools()
# Verificar sistemas RAID
raid_tools = self._check_raid_tools()
# Mostrar resumen de herramientas disponibles
self._show_tools_summary(raid_tools)
# Verificar si faltan herramientas RAID y ofrecer instalación
missing_tools = [tool for tool, available in raid_tools.items() if not available]
available_tools = [tool for tool, available in raid_tools.items() if available]
if not any(raid_tools.values()):
# No hay ninguna herramienta RAID
self.console.print_panel(
"❌ No se encontraron herramientas RAID en el sistema.\n"
"No es posible continuar sin al menos BTRFS o ZFS.",
title="🚫 Sin herramientas RAID",
style="red"
)
if self.console.confirm("¿Deseas instalar las herramientas RAID necesarias?", default=True):
return self._install_missing_packages()
else:
return False
elif missing_tools:
# Hay algunas herramientas pero faltan otras
missing_list = ", ".join(tool.upper() for tool in missing_tools)
available_list = ", ".join(tool.upper() for tool in available_tools)
self.console.print_panel(
f"✅ Herramientas disponibles: {available_list}\n"
f"❌ Herramientas faltantes: {missing_list}\n\n"
f"Puedes trabajar con las herramientas disponibles o instalar las faltantes para más opciones.",
title="⚙️ Estado de Herramientas RAID",
style="yellow"
)
if self.console.confirm(f"¿Deseas instalar las herramientas faltantes ({missing_list})?", default=False):
return self._install_selective_packages(missing_tools)
return True
def _check_and_install_python_packages(self) -> bool:
"""Verifica e instala paquetes de Python necesarios"""
self.console.print("🐍 Verificando paquetes de Python...")
# Lista de paquetes Python requeridos
required_python_packages = {
'rich': 'rich', # nombre_import: nombre_pip
}
missing_packages = []
# Verificar Rich especialmente
if not RICH_AVAILABLE:
self.console.print("❌ Rich no está disponible", style="red")
missing_packages.append('rich')
else:
self.console.print("✅ Rich disponible", style="green")
# Si hay paquetes faltantes, ofrecer instalarlos
if missing_packages:
self.console.print_panel(
f"📦 Paquetes Python faltantes: {', '.join(missing_packages)}\n"
"Rich mejora significativamente la experiencia del usuario con tablas y colores.",
title="📋 Paquetes Python Requeridos",
style="yellow"
)
if self.console.confirm("¿Deseas instalar los paquetes Python necesarios?", default=True):
return self._install_python_packages(missing_packages)
else:
self.console.print("⚠️ Continuando sin Rich. La experiencia será más limitada.", style="yellow")
return True
return True
def _install_python_packages(self, packages: list) -> bool:
"""Instala paquetes de Python usando apt (repositorios del sistema)"""
self.console.print("📦 Instalando paquetes de Python desde repositorios...")
# Instalar cada paquete
success_count = 0
for package in packages:
self.console.print(f" 🔄 Instalando {package}...")
# Usar apt para instalar desde repositorios del sistema
if package == 'rich':
try:
self.console.print(f" 🔄 Instalando python3-{package} desde repositorios...")
self.system.run_command(['apt', 'install', '-y', f'python3-{package}'],
capture_output=True)
self.console.print(f" ✅ python3-{package} instalado desde repositorios", style="green")
success_count += 1
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error instalando python3-{package}: {e}", style="red")
else:
# Para otros paquetes futuros, usar la misma estrategia
try:
self.console.print(f" 🔄 Instalando python3-{package} desde repositorios...")
self.system.run_command(['apt', 'install', '-y', f'python3-{package}'],
capture_output=True)
self.console.print(f" ✅ python3-{package} instalado desde repositorios", style="green")
success_count += 1
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error instalando python3-{package}: {e}", style="red")
# Evaluar resultado
if success_count == len(packages):
self.console.print_panel(
"✅ Todos los paquetes Python se instalaron exitosamente.\n"
"⚠️ NOTA: Reinicia el script para que los cambios surtan efecto completo.",
title="✅ Instalación Completada",
style="green"
)
# Sugerir reinicio si se instaló Rich
if 'rich' in packages:
self.console.print(
"💡 Para aprovechar Rich completamente, reinicia el script: python3 raid_manager.py",
style="blue"
)
return True
elif success_count > 0:
self.console.print_panel(
f"⚠️ Se instalaron {success_count} de {len(packages)} paquetes.\n"
"El script puede funcionar pero con funcionalidad limitada.",
title="⚠️ Instalación Parcial",
style="yellow"
)
return True
else:
self.console.print_panel(
"❌ No se pudo instalar ningún paquete Python.\n"
"El script funcionará con funcionalidad básica.",
title="❌ Instalación Fallida",
style="red"
)
return False
def _check_basic_tools(self) -> bool:
"""Verifica herramientas básicas del sistema"""
self.console.print("🔧 Verificando herramientas básicas...")
missing_commands = []
for command in self.required_commands['basic']:
if not self._command_exists(command):
missing_commands.append(command)
if missing_commands:
self.console.print(f"⚠️ Herramientas básicas faltantes: {', '.join(missing_commands)}", style="yellow")
return False
else:
self.console.print("✅ Herramientas básicas disponibles", style="green")
return True
def _check_raid_tools(self) -> dict:
"""Verifica disponibilidad de herramientas RAID"""
self.console.print("🔍 Verificando herramientas RAID...")
tools_status = {}
# Verificar BTRFS
btrfs_ok = all(self._command_exists(cmd) for cmd in self.required_commands['btrfs'])
tools_status['btrfs'] = btrfs_ok
if btrfs_ok:
try:
result = self.system.run_command(['btrfs', '--version'], capture_output=True)
version = result.stdout.strip().split()[-1] if result.stdout else "desconocida"
self.console.print(f"✅ BTRFS disponible (versión: {version})", style="green")
except subprocess.CalledProcessError:
self.console.print("⚠️ BTRFS detectado pero con problemas", style="yellow")
else:
self.console.print("❌ BTRFS no disponible", style="red")
# Verificar ZFS
zfs_ok = all(self._command_exists(cmd) for cmd in self.required_commands['zfs'])
tools_status['zfs'] = zfs_ok
if zfs_ok:
try:
result = self.system.run_command(['zpool', '--version'], capture_output=True)
version_line = result.stdout.strip().split('\n')[0] if result.stdout else ""
version = version_line.split()[-1] if version_line else "desconocida"
self.console.print(f"✅ ZFS disponible (versión: {version})", style="green")
except subprocess.CalledProcessError:
self.console.print("⚠️ ZFS detectado pero con problemas", style="yellow")
else:
self.console.print("❌ ZFS no disponible", style="red")
# Verificar mdadm
mdadm_ok = self._command_exists('mdadm')
tools_status['mdadm'] = mdadm_ok
if mdadm_ok:
self.console.print("✅ mdadm disponible", style="green")
else:
self.console.print("❌ mdadm no disponible", style="red")
return tools_status
def _command_exists(self, command: str) -> bool:
"""Verifica si un comando existe en el sistema"""
try:
self.system.run_command(['which', command], capture_output=True, use_sudo=False)
return True
except subprocess.CalledProcessError:
return False
def _show_tools_summary(self, tools_status: dict):
"""Muestra resumen de herramientas disponibles"""
available_tools = [tool for tool, status in tools_status.items() if status]
if available_tools:
self.console.print_panel(
f"Herramientas RAID disponibles: {', '.join(available_tools).upper()}",
title="✅ Herramientas Disponibles",
style="green"
)
else:
self.console.print_panel(
"No se encontraron herramientas RAID disponibles",
title="❌ Sin Herramientas RAID",
style="red"
)
def _install_missing_packages(self) -> bool:
"""Instala paquetes faltantes"""
self.console.print_panel(
"Instalando herramientas RAID necesarias...",
title="📦 Instalación"
)
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("❌ Se requieren permisos de administrador para instalar paquetes", style="red")
return False
# Actualizar lista de paquetes
self.console.print("🔄 Actualizando lista de paquetes...")
try:
self.system.run_command(['apt', 'update'], capture_output=False)
except subprocess.CalledProcessError:
self.console.print("⚠️ Error actualizando lista de paquetes", style="yellow")
# Instalar paquetes básicos
basic_packages = self.required_packages['basic'] + self.required_packages['mdadm']
self._install_package_group("herramientas básicas", basic_packages)
# Preguntar sobre BTRFS
if self.console.confirm("¿Instalar soporte para BTRFS?", default=True):
self._install_package_group("BTRFS", self.required_packages['btrfs'])
# Preguntar sobre ZFS (con advertencia)
if self.console.confirm("¿Instalar soporte para ZFS? (puede tomar varios minutos)", default=True):
self.console.print_panel(
"⚠️ ADVERTENCIA: La instalación de ZFS puede tomar mucho tiempo\n"
"y requiere compilación de módulos del kernel.\n"
"El progreso se mostrará en tiempo real.",
title="📋 Instalación ZFS",
style="yellow"
)
if self.console.confirm("¿Continuar con la instalación de ZFS?", default=True):
self._install_package_group("ZFS", self.required_packages['zfs'], show_progress=True)
# Verificar instalación
self.console.print("🔍 Verificando instalación...")
raid_tools = self._check_raid_tools()
if any(raid_tools.values()):
self.console.print_panel(
"✅ Instalación completada exitosamente",
title="✅ Éxito",
style="green"
)
return True
else:
self.console.print_panel(
"❌ La instalación no fue completamente exitosa",
title="❌ Error",
style="red"
)
return False
def _install_selective_packages(self, missing_tools: list) -> bool:
"""Instala selectivamente solo las herramientas RAID faltantes"""
self.console.print_panel(
f"Instalando herramientas faltantes: {', '.join(tool.upper() for tool in missing_tools)}",
title="📦 Instalación Selectiva"
)
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("❌ Se requieren permisos de administrador para instalar paquetes", style="red")
return False
# Actualizar lista de paquetes
self.console.print("🔄 Actualizando lista de paquetes...")
try:
self.system.run_command(['apt', 'update'], capture_output=True)
except subprocess.CalledProcessError:
self.console.print("⚠️ Error actualizando lista de paquetes", style="yellow")
# Instalar cada herramienta según lo que falte
installation_success = False
for tool in missing_tools:
if tool == 'btrfs':
if self.console.confirm("¿Instalar soporte para BTRFS?", default=True):
self._install_package_group("BTRFS", self.required_packages['btrfs'])
installation_success = True
elif tool == 'zfs':
if self.console.confirm("¿Instalar soporte para ZFS? (puede tomar varios minutos)", default=True):
self.console.print_panel(
"⚠️ ADVERTENCIA: La instalación de ZFS puede tomar mucho tiempo\n"
"y requiere compilación de módulos del kernel.\n"
"El progreso se mostrará en tiempo real.",
title="📋 Instalación ZFS",
style="yellow"
)
if self.console.confirm("¿Continuar con la instalación de ZFS?", default=True):
self._install_package_group("ZFS", self.required_packages['zfs'], show_progress=True)
installation_success = True
elif tool == 'mdadm':
if self.console.confirm("¿Instalar soporte para MDADM?", default=True):
self._install_package_group("MDADM", self.required_packages['mdadm'])
installation_success = True
# Solo verificar si se instaló algo
if installation_success:
# Verificar instalación
self.console.print("🔍 Verificando instalación...")
raid_tools = self._check_raid_tools()
newly_available = [tool for tool, available in raid_tools.items() if available and tool in missing_tools]
if newly_available:
self.console.print_panel(
f"✅ Nuevas herramientas disponibles: {', '.join(tool.upper() for tool in newly_available)}",
title="✅ Instalación Exitosa",
style="green"
)
return True
else:
self.console.print_panel(
"⚠️ La instalación se completó pero algunas herramientas pueden no estar disponibles",
title="⚠️ Instalación Parcial",
style="yellow"
)
return True
else:
self.console.print_panel(
"️ No se instalaron nuevas herramientas. Continuando con las disponibles.",
title="️ Sin Cambios",
style="blue"
)
return True
def _install_specific_raid_tool(self, tool: str) -> bool:
"""Instala una herramienta RAID específica"""
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("❌ Se requieren permisos de administrador para instalar paquetes", style="red")
return False
# Actualizar lista de paquetes
self.console.print("🔄 Actualizando lista de paquetes...")
try:
self.system.run_command(['apt', 'update'], capture_output=True)
except subprocess.CalledProcessError:
self.console.print("⚠️ Error actualizando lista de paquetes", style="yellow")
# Instalar herramienta específica
if tool == 'zfs':
self.console.print_panel(
"⚠️ ADVERTENCIA: La instalación de ZFS puede tomar mucho tiempo\n"
"y requiere compilación de módulos del kernel.\n"
"El progreso se mostrará en tiempo real.",
title="📋 Instalación ZFS",
style="yellow"
)
if self.console.confirm("¿Continuar con la instalación de ZFS?", default=True):
self._install_package_group("ZFS", self.required_packages['zfs'], show_progress=True)
else:
return False
elif tool == 'btrfs':
self._install_package_group("BTRFS", self.required_packages['btrfs'])
elif tool == 'mdadm':
self._install_package_group("MDADM", self.required_packages['mdadm'])
else:
self.console.print(f"❌ Herramienta desconocida: {tool}", style="red")
return False
# Verificar instalación
self.console.print("🔍 Verificando instalación...")
updated_tools = self._check_raid_tools()
if updated_tools.get(tool, False):
self.console.print_panel(
f"✅ {tool.upper()} instalado y disponible",
title="✅ Instalación Exitosa",
style="green"
)
return True
else:
self.console.print_panel(
f"❌ Error en la instalación de {tool.upper()}",
title="❌ Instalación Fallida",
style="red"
)
return False
def _install_package_group(self, group_name: str, packages: list, show_progress: bool = False):
"""Instala un grupo de paquetes"""
self.console.print(f"📦 Instalando {group_name}...")
for package in packages:
try:
self.console.print(f" 🔄 Instalando {package}...")
if show_progress:
# Mostrar salida en tiempo real para paquetes que toman mucho tiempo
self.system.run_command(['apt', 'install', '-y', package], capture_output=False)
else:
# Instalar silenciosamente para paquetes rápidos
self.system.run_command(['apt', 'install', '-y', package], capture_output=True)
self.console.print(f" ✅ {package} instalado", style="green")
except subprocess.CalledProcessError:
self.console.print(f" ❌ Error instalando {package}", style="red")
class SystemManager:
"""Gestión de operaciones del sistema"""
def __init__(self, console: UIConsole):
self.console = console
self.logger = self._setup_logging()
# Lista de comandos que típicamente requieren sudo
self.sudo_commands = {
'umount', 'mount', 'mkfs', 'wipefs', 'dd', 'zpool', 'zfs',
'btrfs', 'mdadm', 'pvremove', 'vgchange', 'vgreduce', 'lvremove',
'partprobe', 'sgdisk', 'mkdir', 'chown', 'chmod', 'apt', 'pip', 'pip3'
}
def _setup_logging(self) -> logging.Logger:
"""Configura el logging"""
log_handlers = []
# Intentar crear log en /var/log, si no funciona usar directorio local
try:
log_handlers.append(logging.FileHandler('/var/log/raid_manager.log'))
except PermissionError:
# Usar directorio local si no hay permisos
log_file = Path.home() / '.raid_manager.log'
log_handlers.append(logging.FileHandler(log_file))
# Solo logging a archivo, no a consola durante detección
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=log_handlers
)
return logging.getLogger(__name__)
def run_command(self, command: List[str], check: bool = True,
capture_output: bool = True, show_errors: bool = False,
use_sudo: bool = None) -> subprocess.CompletedProcess:
"""Ejecuta un comando del sistema con sudo automático cuando sea necesario"""
# Determinar si necesita sudo automáticamente
if use_sudo is None:
command_name = command[0].split('/')[-1] # Obtener nombre base del comando
needs_sudo = command_name in self.sudo_commands and not self.is_root()
else:
needs_sudo = use_sudo
# Agregar sudo si es necesario
if needs_sudo:
command = ['sudo'] + command
try:
self.logger.info(f"Ejecutando: {' '.join(command)}")
result = subprocess.run(
command,
check=check,
capture_output=capture_output,
text=True
)
return result
except subprocess.CalledProcessError as e:
# Asegurar que stderr esté disponible en la excepción
if hasattr(e, 'stderr') and e.stderr:
error_detail = e.stderr.strip()
elif hasattr(e, 'stdout') and e.stdout:
error_detail = e.stdout.strip()
else:
error_detail = str(e)
self.logger.error(f"Error ejecutando comando: {e}")
self.logger.error(f"Stderr: {error_detail}")
if show_errors:
self.console.print(f"❌ Error ejecutando comando: {e}", style="red")
if error_detail:
self.console.print(f"Detalles: {error_detail}", style="red")
# Añadir stderr a la excepción si no está presente
if not hasattr(e, 'stderr'):
e.stderr = error_detail
raise
def run_command_safe(self, command: List[str], show_errors: bool = False) -> bool:
"""Ejecuta un comando de forma segura, retorna True si fue exitoso"""
try:
self.run_command(command, check=True, show_errors=show_errors)
return True
except subprocess.CalledProcessError:
return False
def is_root(self) -> bool:
"""Verifica si el script se ejecuta como root"""
return os.geteuid() == 0
def check_sudo(self) -> bool:
"""Verifica disponibilidad de sudo"""
try:
self.run_command(['sudo', '-n', 'true'], use_sudo=False)
return True
except subprocess.CalledProcessError:
return False
class DiskManager:
"""Gestión de discos del sistema"""
def __init__(self, system: SystemManager, console: UIConsole):
self.system = system
self.console = console
def detect_disks(self) -> List[Disk]:
"""Detecta todos los discos disponibles"""
self.console.print("🔍 Detectando discos disponibles...", style="blue")
disks = []
try:
# Usar lsblk para obtener información de discos
result = self.system.run_command([
'lsblk', '-J', '-o',
'NAME,SIZE,MODEL,SERIAL,PHY-SEC,TYPE,MOUNTPOINT,FSTYPE'
])
data = json.loads(result.stdout)
system_disks = self._get_system_disks()
for device in data['blockdevices']:
if device['type'] == 'disk':
disk = self._parse_disk_info(device, system_disks)
if disk:
disks.append(disk)
except Exception as e:
self.console.print(f"❌ Error detectando discos: {e}", style="red")
return disks
def _get_system_disks(self) -> set:
"""Obtiene lista de discos del sistema que no deben tocarse"""
system_disks = set()
try:
# Disco raíz
result = self.system.run_command(['findmnt', '-n', '-o', 'SOURCE', '/'])
root_device = result.stdout.strip()
if root_device:
# Extraer nombre del disco (sin partición)
disk_name = root_device.split('/')[-1].rstrip('0123456789')
system_disks.add(disk_name)
# Otros puntos de montaje críticos del sistema
critical_mounts = ['/boot', '/usr', '/var', '/etc', '/lib', '/bin', '/sbin', '/home']
for mount_point in critical_mounts:
try:
result = self.system.run_command(['findmnt', '-n', '-o', 'SOURCE', mount_point])
device = result.stdout.strip()
if device:
disk_name = device.split('/')[-1].rstrip('0123456789')
system_disks.add(disk_name)
except subprocess.CalledProcessError:
continue
# Detectar todos los dispositivos montados con filesystems críticos
try:
result = self.system.run_command(['findmnt', '-rn', '-o', 'SOURCE,TARGET'])
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split()
if len(parts) >= 2:
device = parts[0]
mount_point = parts[1]
# Si está montado en puntos críticos del sistema
if any(mount_point.startswith(critical) for critical in ['/', '/boot', '/usr', '/var', '/etc']):
if device.startswith('/dev/'):
disk_name = device.split('/')[-1].rstrip('0123456789')
system_disks.add(disk_name)
except subprocess.CalledProcessError:
pass
# PROTECCIÓN CRÍTICA: Agregar TODA la familia mmcblk0 (Raspberry Pi)
# Esto incluye mmcblk0, mmcblk0boot0, mmcblk0boot1, mmcblk0rpmb, etc.
system_disks.add('mmcblk0')
# También proteger cualquier variante de mmcblk0
system_disks.add('mmcblk0boot0')
system_disks.add('mmcblk0boot1')
system_disks.add('mmcblk0rpmb')
# Proteger otros dispositivos típicos del sistema
system_disks.add('nvme0n1') # SSD NVMe del sistema
except Exception as e:
self.console.print(f"⚠️ Error detectando discos del sistema: {e}", style="yellow")
# Fallback de seguridad: agregar discos típicos del sistema
system_disks.update(['sda', 'mmcblk0', 'mmcblk0boot0', 'mmcblk0boot1', 'mmcblk0rpmb', 'nvme0n1'])
return system_disks
def _parse_disk_info(self, device: dict, system_disks: set) -> Optional[Disk]:
"""Parsea información de un disco desde lsblk"""
name = device['name']
# Convertir tamaño a bytes
size_str = device['size']
size_bytes = self._parse_size(size_str)
# Filtrar discos con tamaño 0 o inválido
if size_bytes <= 0:
return None
# Información del disco
model = device.get('model', 'Desconocido')
serial = device.get('serial', 'Desconocido')
sector_size = int(device.get('phy-sec', 512))
# Verificar si es disco del sistema
is_system_disk = name in system_disks
# Verificar particiones y filesystems
has_partitions = len(device.get('children', [])) > 0
filesystem_type = None
mount_points = []
if has_partitions:
for child in device['children']:
if child.get('fstype'):
filesystem_type = child['fstype']
if child.get('mountpoint'):
mount_points.append(child['mountpoint'])
# Si tiene montajes críticos del sistema, marcarlo como sistema
if child['mountpoint'] in ['/', '/boot', '/usr', '/var', '/etc', '/lib', '/bin', '/sbin']:
is_system_disk = True
return Disk(
name=name,
size=size_bytes,
model=model,
serial=serial,
sector_size=sector_size,
is_system=is_system_disk,
has_partitions=has_partitions,
filesystem_type=filesystem_type,
mount_points=mount_points
)
def _parse_size(self, size_str: str) -> int:
"""Convierte string de tamaño a bytes"""
if not size_str:
return 0
# Normalizar formato (cambiar comas por puntos)
size_str = size_str.replace(',', '.').upper().strip()
multipliers = {
'B': 1,
'K': 1024,
'M': 1024**2,
'G': 1024**3,
'T': 1024**4,
'P': 1024**5
}
# Buscar sufijo
for suffix, multiplier in multipliers.items():
if size_str.endswith(suffix):
try:
number_str = size_str[:-1].strip()
number = float(number_str)
return int(number * multiplier)
except ValueError:
break
# Sin sufijo, asumir bytes
try:
# Intentar parsear como número directo
return int(float(size_str))
except ValueError:
return 0
class RAIDManager:
"""Gestor principal de RAID"""
def __init__(self):
self.console = UIConsole()
self.system = SystemManager(self.console)
self.disk_manager = DiskManager(self.system, self.console)
self.requirements_checker = RequirementsChecker(self.console, self.system)
self.raid_tools_status = {} # Cache del estado de herramientas RAID
def run(self):
"""Punto de entrada principal del programa"""
# Mostrar banner inicial
self._show_banner()
# Verificar requisitos del sistema
if not self.requirements_checker.check_all_requirements():
self.console.print("❌ No se pueden cumplir los requisitos mínimos del sistema", style="red")
self.console.print("💡 El programa no puede continuar sin las herramientas necesarias", style="blue")
return False
# Cachear el estado actual de herramientas RAID para evitar verificaciones redundantes
self.raid_tools_status = self.requirements_checker._check_raid_tools()
# Continuar con el menú principal
self.main_menu()
return True
def _update_raid_tools_status(self):
"""Actualiza el cache del estado de herramientas RAID"""
self.raid_tools_status = self.requirements_checker._check_raid_tools()
def _show_banner(self):
"""Muestra el banner inicial del programa"""
banner = """
╔════════════════════════════════════════════════════════════════╗
║ RAID Configuration Manager ║
║ Gestión Avanzada de Almacenamiento ║
║ Versión Python 3 ║
╚════════════════════════════════════════════════════════════════╝
"""
self.console.print(banner, style="cyan")
self.console.print("🏠 Para Raspberry Pi y sistemas Linux", style="blue")
self.console.print("🔧 Soporte para ZFS, BTRFS y herramientas de disco\n", style="blue")
def main_menu(self):
"""Menú principal"""
self.console.print_panel(
"Selecciona una opción del menú para gestionar tu almacenamiento",
title="📋 Menú Principal",
style="blue"
)
while True:
self.console.print("\n📋 OPCIONES PRINCIPALES:")
options = [
"1. Detectar configuraciones RAID existentes",
"2. Crear nueva configuración RAID",
"3. Gestionar pools/filesystems existentes",
"4. Herramientas de disco",
"5. Configuración del sistema",
"6. Verificar requisitos del sistema",
"7. Actualizar paquetes del sistema",
"8. Corregir driver Realtek RTL8125",
"9. Recuperar RAID después de reinstalación",
"0. Salir"
]
for option in options:
self.console.print(f" {option}")
choice = self.console.prompt("👉 Selecciona una opción", "0")
if choice == "0":
self.console.print("👋 ¡Hasta luego!", style="green")
break
elif choice == "1":
self.detect_existing_raid()
elif choice == "2":
self.create_raid_wizard()
elif choice == "3":
self.manage_existing()
elif choice == "4":
self.disk_tools()
elif choice == "5":
self.system_configuration()
elif choice == "6":
self.requirements_checker.check_all_requirements()
elif choice == "7":
self.update_system_packages()
elif choice == "8":
self.fix_realtek_rtl8125_driver()
elif choice == "9":
self.recover_raid_after_reinstall()
else:
self.console.print("❌ Opción inválida", style="red")
def detect_existing_raid(self):
"""Detecta configuraciones RAID existentes"""
self.console.print_panel(
"Detectando configuraciones RAID existentes...",
title="🔍 Detección RAID"
)
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("⚠️ Algunas funciones requieren permisos de administrador", style="yellow")
found_anything = False
# Detectar pools ZFS
if self._detect_zfs_pools():
found_anything = True
# Detectar filesystems BTRFS
if self._detect_btrfs_filesystems():
found_anything = True
# Detectar arrays MDADM
if self._detect_mdadm_arrays():
found_anything = True
# Detectar Volume Groups LVM
if self._detect_lvm_volumes():
found_anything = True
# Si no se encontró nada
if not found_anything:
self.console.print_panel(
"No se detectaron configuraciones RAID activas en el sistema.",
title="📭 Sin configuraciones RAID",
style="yellow"
)
def update_system_packages(self):
"""Actualiza solo los paquetes necesarios para RAID Manager"""
self.console.print_panel(
"Verificando actualizaciones de paquetes necesarios para RAID Manager...",
title="🔄 Actualización de Paquetes RAID"
)
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("❌ Se requieren permisos de administrador para actualizar paquetes", style="red")
return
# Lista de paquetes que necesitamos para RAID Manager
required_packages = [
'zfsutils-linux', # ZFS
'btrfs-progs', # BTRFS
'mdadm', # MDADM
'util-linux', # Herramientas básicas (lsblk, etc.)
'parted', # Gestión de particiones
'python3-rich' # Interfaz mejorada
]
# Actualizar lista de paquetes
self.console.print("🔄 Actualizando lista de paquetes disponibles...")
try:
self.system.run_command(['apt', 'update'], capture_output=True)
self.console.print("✅ Lista de paquetes actualizada", style="green")
except subprocess.CalledProcessError:
self.console.print("❌ Error actualizando lista de paquetes", style="red")
return
# Verificar estado actual de nuestros paquetes
self.console.print("🔍 Verificando estado de paquetes necesarios...")
package_status = {}
updates_available = []
for package in required_packages:
try:
# Verificar si está instalado
result = self.system.run_command(['dpkg', '-l', package], capture_output=True, use_sudo=False)
if result.returncode == 0:
# Verificar si tiene actualizaciones
result = self.system.run_command(['apt', 'list', '--upgradable', package], capture_output=True, use_sudo=False)
if len(result.stdout.split('\n')) > 2: # Header + package line
updates_available.append(package)
package_status[package] = "actualizable"
else:
package_status[package] = "actualizado"
else:
package_status[package] = "no_instalado"
except subprocess.CalledProcessError:
package_status[package] = "error"
# Mostrar estado actual
self._show_package_status(package_status)
# Procesar actualizaciones si las hay
if updates_available:
self.console.print_panel(
f"Se encontraron {len(updates_available)} paquetes con actualizaciones disponibles:",
title="🔧 Actualizaciones Disponibles",
style="yellow"
)
for package in updates_available:
self.console.print(f" 📦 {package}")
if self.console.confirm("¿Actualizar estos paquetes?", default=True):
self._update_required_packages(updates_available)
else:
# Verificar paquetes no instalados
missing_packages = [pkg for pkg, status in package_status.items() if status == "no_instalado"]
if missing_packages:
self.console.print_panel(
f"Se encontraron {len(missing_packages)} paquetes no instalados:",
title="📦 Paquetes Faltantes",
style="blue"
)
for package in missing_packages:
self.console.print(f" 📦 {package}")
if self.console.confirm("¿Instalar paquetes faltantes?", default=True):
self._install_required_packages(missing_packages)
else:
self.console.print_panel(
"✅ Todos los paquetes necesarios están instalados y actualizados.",
title="✅ Paquetes Actualizados",
style="green"
)
def _show_package_status(self, package_status: dict):
"""Muestra el estado actual de los paquetes necesarios"""
if RICH_AVAILABLE:
table = Table(title="📦 Estado de Paquetes RAID Manager", show_header=True, header_style="bold blue")
table.add_column("Paquete", style="cyan", no_wrap=True)
table.add_column("Estado", style="green")
table.add_column("Descripción", style="white")
status_icons = {
"actualizado": "✅",
"actualizable": "🔄",
"no_instalado": "❌",
"error": "⚠️"
}
status_colors = {
"actualizado": "green",
"actualizable": "yellow",
"no_instalado": "red",
"error": "red"
}
descriptions = {
'zfsutils-linux': 'Sistema de archivos ZFS',
'btrfs-progs': 'Sistema de archivos BTRFS',
'mdadm': 'Gestión de RAID por software',
'util-linux': 'Herramientas básicas del sistema',
'parted': 'Gestión de particiones',
'python3-rich': 'Interfaz de usuario mejorada'
}
for package, status in package_status.items():
icon = status_icons.get(status, "?")
status_text = f"{icon} {status.replace('_', ' ').title()}"
description = descriptions.get(package, "Paquete del sistema")
table.add_row(
package,
Text(status_text, style=status_colors.get(status, "white")),
description
)
self.console.console.print(table)
else:
self.console.print("\n📦 Estado de paquetes:")
for package, status in package_status.items():
status_display = status.replace('_', ' ').title()
self.console.print(f" {package}: {status_display}")
def _update_required_packages(self, packages: list):
"""Actualiza paquetes específicos necesarios para RAID Manager"""
self.console.print("🔄 Actualizando paquetes necesarios...")
try:
success_count = 0
for package in packages:
self.console.print(f" 🔄 Actualizando {package}...")
self.system.run_command(['apt', 'install', '--only-upgrade', '-y', package], capture_output=True)
self.console.print(f" ✅ {package} actualizado", style="green")
success_count += 1
self.console.print_panel(
f"✅ {success_count} paquetes actualizados exitosamente.\n"
"💡 Se recomienda reiniciar el script para aprovechar las mejoras.",
title="✅ Actualización Completada",
style="green"
)
# Verificar si se actualizó ZFS
if any('zfs' in pkg for pkg in packages):
self.console.print_panel(
"⚠️ Se actualizaron componentes de ZFS.\n"
"Es recomendable reiniciar el sistema para cargar los nuevos módulos.",
title="🔄 Reinicio Recomendado",
style="yellow"
)
# Verificar si se actualizó Rich
if 'python3-rich' in packages:
self.console.print_panel(
"🎨 Se actualizó python3-rich.\n"
"Reinicia el script para aprovechar las nuevas funcionalidades de interfaz.",
title="🔄 Reinicio de Script Recomendado",
style="blue"
)
# Actualizar cache del estado de herramientas después de la actualización
self._update_raid_tools_status()
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error actualizando paquetes: {e}", style="red")
def _install_required_packages(self, packages: list):
"""Instala paquetes faltantes necesarios para RAID Manager"""
self.console.print("📦 Instalando paquetes necesarios...")
try:
success_count = 0
for package in packages:
self.console.print(f" 🔄 Instalando {package}...")
# Mostrar progreso para ZFS ya que puede tomar tiempo
if package == 'zfsutils-linux':
self.console.print_panel(
"⚠️ La instalación de ZFS puede tomar varios minutos.\n"
"Por favor espera mientras se descargan y compilan los módulos.",
title="📋 Instalando ZFS",
style="yellow"
)
self.system.run_command(['apt', 'install', '-y', package], capture_output=False)
else:
self.system.run_command(['apt', 'install', '-y', package], capture_output=True)
self.console.print(f" ✅ {package} instalado", style="green")
success_count += 1
self.console.print_panel(
f"✅ {success_count} paquetes instalados exitosamente.\n"
"💡 Reinicia el script para aprovechar todas las funcionalidades.",
title="✅ Instalación Completada",
style="green"
)
# Verificar si se instaló ZFS
if 'zfsutils-linux' in packages:
self.console.print_panel(
"🔧 ZFS instalado exitosamente.\n"
"Ya puedes crear pools ZFS con todas las funcionalidades avanzadas.",
title="🎉 ZFS Disponible",
style="green"
)
# Verificar si se instaló Rich
if 'python3-rich' in packages:
self.console.print_panel(
"🎨 python3-rich instalado exitosamente.\n"
"Reinicia el script para disfrutar de la interfaz mejorada con tablas y colores.",
title="🎉 Interfaz Mejorada Disponible",
style="green"
)
# Actualizar cache del estado de herramientas después de la instalación
self._update_raid_tools_status()
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error instalando paquetes: {e}", style="red")
def fix_realtek_rtl8125_driver(self):
"""Detecta y corrige problemas con el driver Realtek RTL8125"""
self.console.print_panel(
"Detección y Corrección del Driver Realtek RTL8125\n"
"Verifica si el dispositivo RTL8125 está usando el driver correcto.",
title="🌐 Driver de Red Realtek",
style="blue"
)
# Detectar dispositivos de red Realtek RTL8125
rtl8125_devices = self._detect_rtl8125_devices()
if not rtl8125_devices:
self.console.print_panel(
"️ No se detectaron dispositivos Realtek RTL8125 en el sistema.",
title="️ Sin Dispositivos RTL8125",
style="blue"
)
return
# Mostrar dispositivos detectados
self.console.print_panel(
f"🔍 Dispositivos RTL8125 detectados: {len(rtl8125_devices)}\n" +
"\n".join([f"• {device}" for device in rtl8125_devices]),
title="🌐 Dispositivos Detectados",
style="yellow"
)
# Verificar driver actual
driver_issues = self._check_rtl8125_driver_status(rtl8125_devices)
if not driver_issues:
self.console.print_panel(
"✅ Todos los dispositivos RTL8125 están usando el driver correcto.",
title="✅ Driver Correcto",
style="green"
)
return
# Mostrar problemas detectados
self.console.print_panel(
"⚠️ Problemas detectados con el driver RTL8125:\n" +
"\n".join([f"• {issue}" for issue in driver_issues]),
title="⚠️ Problemas del Driver",
style="red"
)
# Ofrecer corrección
if self.console.confirm(
"¿Deseas instalar el driver correcto para RTL8125? "
"(Esto reemplazará el driver r8169 por r8125)",
default=True
):
return self._install_rtl8125_driver()
else:
self.console.print("️ Corrección cancelada por el usuario.", style="blue")
return False
def _detect_rtl8125_devices(self) -> list:
"""Detecta dispositivos Realtek RTL8125 en el sistema"""
try:
# Usar lspci para detectar dispositivos RTL8125
result = self.system.run_command(['lspci', '-nn'], capture_output=True)
rtl8125_devices = []
for line in result.stdout.split('\n'):
# Buscar líneas que contengan RTL8125 o el ID del dispositivo
if 'RTL8125' in line or '10ec:8125' in line:
rtl8125_devices.append(line.strip())
return rtl8125_devices
except subprocess.CalledProcessError:
self.console.print("❌ Error ejecutando lspci", style="red")
return []
def _check_rtl8125_driver_status(self, devices: list) -> list:
"""Verifica el estado del driver para dispositivos RTL8125"""
issues = []
try:
# Usar lspci -vv para obtener información detallada del driver
result = self.system.run_command(['lspci', '-vv'], capture_output=True)
output = result.stdout
# Buscar secciones de RTL8125 y verificar el driver
in_rtl8125_section = False
current_device = ""
for line in output.split('\n'):
# Detectar inicio de sección RTL8125
if 'RTL8125' in line or '10ec:8125' in line:
in_rtl8125_section = True
current_device = line.strip()
continue
# Detectar fin de sección (nueva línea que empieza con dirección PCI)
if in_rtl8125_section and line and not line.startswith('\t') and ':' in line:
in_rtl8125_section = False
continue
# Verificar línea de driver dentro de la sección RTL8125
if in_rtl8125_section and 'Kernel driver in use:' in line:
driver = line.split(':')[-1].strip()
if driver == 'r8169':
issues.append(f"Dispositivo usando driver incorrecto 'r8169': {current_device}")
elif driver != 'r8125':
issues.append(f"Dispositivo usando driver desconocido '{driver}': {current_device}")
# Si es r8125, está correcto, no añadir a issues
except subprocess.CalledProcessError:
issues.append("Error verificando estado del driver con lspci")
return issues
def _install_rtl8125_driver(self) -> bool:
"""Instala el driver correcto RTL8125"""
self.console.print_panel(
"Instalando driver correcto para Realtek RTL8125\n"
"⚠️ ADVERTENCIA: Este proceso requerirá reinicio del sistema",
title="🔧 Instalación del Driver",
style="yellow"
)
try:
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("❌ Se requieren permisos de administrador", style="red")
return False
# Instalar dependencias
self.console.print("🔧 Instalando dependencias necesarias...")
dependencies = [
'dkms', 'build-essential',
f'linux-headers-{self._get_kernel_version()}', 'git'
]
# Actualizar repositorios
self.system.run_command(['apt', 'update'], capture_output=True)
# Instalar dependencias
self.system.run_command(['apt', 'install', '-y'] + dependencies, capture_output=False)
# Clonar repositorio del driver
self.console.print("⬇️ Clonando repositorio oficial del driver r8125...")
# Limpiar directorio temporal si existe
self.system.run_command(['rm', '-rf', '/tmp/realtek-r8125-dkms'], capture_output=True)
# Clonar repositorio
self.system.run_command([
'git', 'clone',
'https://github.com/awesometic/realtek-r8125-dkms.git',
'/tmp/realtek-r8125-dkms'
], capture_output=False)
# Instalar driver mediante DKMS
self.console.print("⚙️ Instalando el driver mediante DKMS...")
self.system.run_command([
'bash', '/tmp/realtek-r8125-dkms/dkms-install.sh'
], capture_output=False, cwd='/tmp/realtek-r8125-dkms')
# Bloquear driver r8169
self.console.print("⛔️ Bloqueando el driver r8169 para evitar conflictos...")
blacklist_content = "blacklist r8169\n"
with open('/tmp/blacklist-r8169.conf', 'w') as f:
f.write(blacklist_content)
self.system.run_command([
'cp', '/tmp/blacklist-r8169.conf', '/etc/modprobe.d/blacklist-r8169.conf'
], capture_output=True)
# Actualizar initramfs
self.console.print("🧱 Actualizando initramfs...")
self.system.run_command(['update-initramfs', '-u'], capture_output=False)
# Limpiar archivos temporales
self.system.run_command(['rm', '-rf', '/tmp/realtek-r8125-dkms', '/tmp/blacklist-r8169.conf'],
capture_output=True)
self.console.print_panel(
"✅ Instalación del driver RTL8125 completada con éxito.\n"
"🔁 SE REQUIERE REINICIO para aplicar los cambios.",
title="✅ Instalación Exitosa",
style="green"
)
# Ofrecer reinicio
if self.console.confirm("¿Deseas reiniciar ahora para aplicar los cambios?", default=False):
self.console.print("🔁 Reiniciando sistema...", style="blue")
self.system.run_command(['reboot'], capture_output=False)
else:
self.console.print_panel(
"️ Reinicia manualmente cuando estés listo: sudo reboot\n"
"Los cambios del driver no surtirán efecto hasta reiniciar.",
title="️ Reinicio Pendiente",
style="blue"
)
return True
except subprocess.CalledProcessError as e:
self.console.print_panel(
f"❌ Error durante la instalación del driver:\n{str(e)}",
title="❌ Error de Instalación",
style="red"
)
return False
except Exception as e:
self.console.print_panel(
f"❌ Error inesperado: {str(e)}",
title="❌ Error",
style="red"
)
return False
def _get_kernel_version(self) -> str:
"""Obtiene la versión del kernel actual"""
try:
result = self.system.run_command(['uname', '-r'], capture_output=True)
return result.stdout.strip()
except subprocess.CalledProcessError:
return "$(uname -r)" # Fallback
def recover_raid_after_reinstall(self):
"""Recupera configuraciones RAID después de una reinstalación del sistema"""
self.console.print_panel(
"Recuperación de RAID después de Reinstalación\n"
"Busca y recupera pools/arrays RAID existentes en los discos.",
title="🔄 Recuperación de RAID",
style="blue"
)
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("❌ Se requieren permisos de administrador para recuperar RAID", style="red")
return
# Mostrar información sobre el proceso
self.console.print_panel(
"️ Esta función busca configuraciones RAID existentes que pueden haberse\n"
"desconectado después de una reinstalación del sistema operativo.\n\n"
"🔷 ZFS: Busca pools exportados para reimportar\n"
"🌿 BTRFS: Detecta filesystems existentes\n"
"⚙️ MDADM: Busca arrays inactivos para reensamblar",
title="️ Información del Proceso",
style="blue"
)
# Escanear cada tipo de RAID
recovered_items = []
# 1. Recuperar ZFS
if self.raid_tools_status.get('zfs', False):
zfs_pools = self._recover_zfs_pools()
recovered_items.extend(zfs_pools)
# 2. Recuperar BTRFS
if self.raid_tools_status.get('btrfs', False):
btrfs_filesystems = self._recover_btrfs_filesystems()
recovered_items.extend(btrfs_filesystems)
# 3. Recuperar MDADM
if self.raid_tools_status.get('mdadm', False):
mdadm_arrays = self._recover_mdadm_arrays()
recovered_items.extend(mdadm_arrays)
# Mostrar resumen
if recovered_items:
self.console.print_panel(
f"✅ Recuperación completada: {len(recovered_items)} elemento(s) encontrado(s)\n\n" +
"\n".join([f"• {item}" for item in recovered_items]),
title="✅ Recuperación Exitosa",
style="green"
)
# Separar elementos por tipo para configuración específica
zfs_items = [item for item in recovered_items if item.startswith('ZFS Pool:')]
btrfs_items = [item for item in recovered_items if item.startswith('BTRFS:')]
mdadm_items = [item for item in recovered_items if item.startswith('MDADM Array:')]
# Configurar montaje automático solo para tipos que lo necesitan
needs_fstab_config = btrfs_items + mdadm_items
if zfs_items:
self.console.print_panel(
f"️ Pools ZFS ({len(zfs_items)}) ya tienen montaje automático nativo.\n"
"Los datasets se montan automáticamente al iniciar el sistema.",
title="🔷 ZFS - Montaje Automático Nativo",
style="blue"
)
if needs_fstab_config:
if self.console.confirm(f"¿Configurar montaje automático en /etc/fstab para {len(needs_fstab_config)} elemento(s)?", default=True):
self._setup_fstab_mounting(needs_fstab_config)
# Verificar configuración ZFS por separado
if zfs_items and self.console.confirm("¿Verificar configuración de montaje automático de ZFS?", default=True):
for zfs_item in zfs_items:
self._configure_zfs_automount(zfs_item)
else:
self.console.print_panel(
"️ No se encontraron configuraciones RAID recuperables.\n"
"Esto puede deberse a:\n"
"• Los discos están completamente limpios\n"
"• Las configuraciones ya están activas\n"
"• Los discos no están conectados",
title="️ Sin Configuraciones Recuperables",
style="yellow"
)
def _recover_zfs_pools(self) -> list:
"""Recupera pools ZFS exportados"""
self.console.print("🔷 Buscando pools ZFS...")
recovered = []
try:
# Buscar pools disponibles para importar
result = self.system.run_command(['zpool', 'import'], capture_output=True)
if "no pools available to import" in result.stdout.lower():
self.console.print(" ️ No se encontraron pools ZFS para importar", style="blue")
return recovered
# Parsear salida para encontrar pools
pools_found = []
current_pool = None
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('pool:'):
current_pool = line.split('pool:')[1].strip()
pools_found.append(current_pool)
if not pools_found:
# Intentar método alternativo: escanear directorios
try:
result = self.system.run_command(['zpool', 'import', '-d', '/dev'], capture_output=True)
# Parsear esta salida también
for line in result.stdout.split('\n'):
if line.strip().startswith('pool:'):
pool_name = line.split('pool:')[1].strip()
if pool_name not in pools_found:
pools_found.append(pool_name)
except:
pass
# Mostrar pools encontrados
if pools_found:
self.console.print_panel(
f"🔷 Pools ZFS encontrados: {len(pools_found)}\n" +
"\n".join([f"• {pool}" for pool in pools_found]),
title="🔷 Pools ZFS Disponibles",
style="blue"
)
# Preguntar cuáles importar
for pool in pools_found:
if self.console.confirm(f"¿Importar pool ZFS '{pool}'?", default=True):
try:
# Importar pool
self.console.print(f" 🔄 Importando pool '{pool}'...")
import_result = self.system.run_command(['zpool', 'import', '-f', pool], capture_output=True)
# Verificar importación
status_result = self.system.run_command(['zpool', 'status', pool], capture_output=True)
if status_result.returncode == 0:
self.console.print(f" ✅ Pool '{pool}' importado exitosamente", style="green")
recovered.append(f"ZFS Pool: {pool}")
# Mostrar información del pool
self._show_zfs_pool_info(pool)
else:
self.console.print(f" ❌ Error verificando pool '{pool}'", style="red")
except subprocess.CalledProcessError as e:
# Capturar stderr para mostrar el error específico
error_msg = e.stderr.strip() if hasattr(e, 'stderr') and e.stderr else str(e)
# Mostrar error detallado
self.console.print_panel(
f"❌ Error importando pool '{pool}':\n\n"
f"Código de salida: {e.returncode}\n"
f"Error: {error_msg}\n\n"
f"💡 Posibles causas:\n"
f"• El pool ya está importado\n"
f"• Faltan discos del pool\n"
f"• Pool con el mismo nombre ya existe\n"
f"• Permisos insuficientes\n"
f"• Pool corrupto o dañado",
title=f"❌ Error ZFS: {pool}",
style="red"
)
# Ofrecer diagnóstico adicional
if self.console.confirm(f"¿Ejecutar diagnóstico adicional para '{pool}'?", default=True):
self._diagnose_zfs_import_error(pool)
else:
self.console.print(" ️ No se encontraron pools ZFS para importar", style="blue")
except subprocess.CalledProcessError:
self.console.print(" ❌ Error ejecutando zpool import", style="red")
return recovered
def _diagnose_zfs_import_error(self, pool_name: str):
"""Diagnostica problemas de importación ZFS"""
self.console.print(f"🔍 Ejecutando diagnóstico para pool '{pool_name}'...")
try:
# 1. Verificar si el pool ya está importado
self.console.print(" 📊 Verificando si el pool ya está activo...")
try:
active_result = self.system.run_command(['zpool', 'list', pool_name], capture_output=True)
if active_result.returncode == 0:
self.console.print_panel(
f"✅ El pool '{pool_name}' ya está importado y activo.\n\n"
f"Estado actual:\n{active_result.stdout}",
title="️ Pool Ya Activo",
style="blue"
)
return
except subprocess.CalledProcessError:
pass # Pool no está activo, continuar diagnóstico
# 2. Verificar disponibilidad de dispositivos
self.console.print(" 🔍 Verificando dispositivos del pool...")
try:
import_check = self.system.run_command(['zpool', 'import', '-d', '/dev'], capture_output=True)
if pool_name in import_check.stdout:
# Encontrar la sección específica del pool
lines = import_check.stdout.split('\n')
in_pool_section = False
pool_info = []
for line in lines:
if f'pool: {pool_name}' in line:
in_pool_section = True
pool_info.append(line)
elif in_pool_section and line.strip().startswith('pool:'):
break # Nueva sección de pool
elif in_pool_section:
pool_info.append(line)
if pool_info:
self.console.print_panel(
f"📊 Información detallada del pool:\n\n" +
"\n".join(pool_info),
title=f"🔍 Diagnóstico: {pool_name}",
style="yellow"
)
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error obteniendo información detallada: {e.stderr if hasattr(e, 'stderr') else e}")
# 3. Verificar cachés ZFS
self.console.print(" 🗂️ Verificando caché de ZFS...")
try:
cache_result = self.system.run_command(['zpool', 'import', '-c', '/etc/zfs/zpool.cache'], capture_output=True)
if pool_name in cache_result.stdout:
self.console.print(" ✅ Pool encontrado en caché ZFS")
else:
self.console.print(" ⚠️ Pool no encontrado en caché ZFS")
except subprocess.CalledProcessError:
self.console.print(" ️ No se pudo verificar caché ZFS")
# 4. Sugerir acciones de recuperación
self.console.print_panel(
"🛠️ Acciones de recuperación sugeridas:\n\n"
"1. Si el pool ya está activo, no necesita importación\n"
"2. Si faltan discos, conecta todos los dispositivos\n"
"3. Si hay conflicto de nombres, usa: zpool import old_name new_name\n"
"4. Si el pool está corrupto, considera: zpool import -F\n"
"5. Para forzar importación: zpool import -f -m -N pool_name\n\n"
"⚠️ ADVERTENCIA: Las opciones de fuerza pueden causar pérdida de datos",
title="💡 Sugerencias de Recuperación",
style="blue"
)
# 5. Ofrecer comandos manuales
if self.console.confirm("¿Intentar importación con opciones avanzadas?", default=False):
self._try_advanced_zfs_import(pool_name)
except Exception as e:
self.console.print(f"❌ Error durante diagnóstico: {e}", style="red")
def _try_advanced_zfs_import(self, pool_name: str):
"""Intenta importación ZFS con opciones avanzadas"""
options = [
("Importar sin montar datasets (-N)", ['-N']),
("Importar en modo solo lectura (-o readonly=on)", ['-o', 'readonly=on']),
("Importar con recuperación forzada (-F)", ['-F']),
("Importar con nombre alternativo", ['new_name'])
]
self.console.print("🔧 Opciones de importación avanzada:")
for i, (desc, _) in enumerate(options, 1):
self.console.print(f" {i}. {desc}")
choice = self.console.prompt("Selecciona opción (1-4, 0 para cancelar)", "0")
if choice == "0":
return
try:
choice_idx = int(choice) - 1
if 0 <= choice_idx < len(options):
desc, opts = options[choice_idx]
if 'new_name' in opts:
new_name = self.console.prompt(f"Nuevo nombre para pool '{pool_name}'")
command = ['zpool', 'import'] + opts[:-1] + [pool_name, new_name]
else:
command = ['zpool', 'import'] + opts + [pool_name]
self.console.print(f"🔄 Ejecutando: {' '.join(command)}")
if self.console.confirm("¿Continuar con esta operación?", default=False):
result = self.system.run_command(command, capture_output=True)
self.console.print("✅ Comando ejecutado exitosamente", style="green")
if result.stdout:
self.console.print(f"Salida: {result.stdout}")
else:
self.console.print("Operación cancelada")
else:
self.console.print("Opción inválida")
except (ValueError, subprocess.CalledProcessError) as e:
self.console.print(f"❌ Error: {e}", style="red")
def _recover_btrfs_filesystems(self) -> list:
"""Recupera filesystems BTRFS"""
self.console.print("🌿 Buscando filesystems BTRFS...")
recovered = []
try:
# Buscar filesystems BTRFS
result = self.system.run_command(['btrfs', 'filesystem', 'show'], capture_output=True)
# Parsear salida para encontrar UUIDs y dispositivos
filesystems_found = []
current_uuid = None
current_devices = []
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('uuid:'):
if current_uuid and current_devices:
filesystems_found.append({
'uuid': current_uuid,
'devices': current_devices.copy()
})
current_uuid = line.split('uuid:')[1].strip()
current_devices = []
elif line.startswith('devid') and 'path' in line:
# Extraer ruta del dispositivo
if 'path ' in line:
device_path = line.split('path ')[1].strip()
current_devices.append(device_path)
# Agregar último filesystem si existe
if current_uuid and current_devices:
filesystems_found.append({
'uuid': current_uuid,
'devices': current_devices.copy()
})
if filesystems_found:
self.console.print_panel(
f"🌿 Filesystems BTRFS encontrados: {len(filesystems_found)}\n" +
"\n".join([f"• UUID: {fs['uuid'][:8]}... ({len(fs['devices'])} dispositivos)"
for fs in filesystems_found]),
title="🌿 Filesystems BTRFS Disponibles",
style="green"
)
# Verificar cuáles están montados
for fs in filesystems_found:
uuid_short = fs['uuid'][:8]
primary_device = fs['devices'][0] if fs['devices'] else 'unknown'
# Verificar si ya está montado
try:
mount_result = self.system.run_command(['findmnt', '-S', primary_device], capture_output=True)
if mount_result.returncode == 0:
self.console.print(f" ✅ Filesystem {uuid_short}... ya está montado", style="green")
recovered.append(f"BTRFS: {uuid_short}... (ya montado)")
else:
self.console.print(f" ️ Filesystem {uuid_short}... detectado pero no montado", style="blue")
# Ofrecer montaje
if self.console.confirm(f"¿Montar filesystem BTRFS {uuid_short}...?", default=True):
mountpoint = self.console.prompt(
f"Punto de montaje para {uuid_short}...",
f"/mnt/btrfs_{uuid_short}"
)
try:
# Crear directorio de montaje
self.system.run_command(['mkdir', '-p', mountpoint], capture_output=True)
# Montar filesystem
self.system.run_command(['mount', '-t', 'btrfs', primary_device, mountpoint], capture_output=True)
self.console.print(f" ✅ Filesystem montado en {mountpoint}", style="green")
recovered.append(f"BTRFS: {uuid_short}... (montado en {mountpoint})")
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error montando filesystem: {e}", style="red")
recovered.append(f"BTRFS: {uuid_short}... (detectado)")
else:
recovered.append(f"BTRFS: {uuid_short}... (detectado)")
except subprocess.CalledProcessError:
self.console.print(f" ️ Filesystem {uuid_short}... detectado", style="blue")
recovered.append(f"BTRFS: {uuid_short}... (detectado)")
else:
self.console.print(" ️ No se encontraron filesystems BTRFS", style="blue")
except subprocess.CalledProcessError:
self.console.print(" ❌ Error ejecutando btrfs filesystem show", style="red")
return recovered
def _recover_mdadm_arrays(self) -> list:
"""Recupera arrays MDADM"""
self.console.print("⚙️ Buscando arrays MDADM...")
recovered = []
try:
# Buscar arrays inactivos
result = self.system.run_command(['mdadm', '--examine', '--scan'], capture_output=True)
if result.stdout.strip():
arrays_found = []
for line in result.stdout.split('\n'):
if line.strip() and line.startswith('ARRAY'):
arrays_found.append(line.strip())
if arrays_found:
self.console.print_panel(
f"⚙️ Arrays MDADM encontrados: {len(arrays_found)}\n" +
"\n".join([f"• {array}" for array in arrays_found]),
title="⚙️ Arrays MDADM Disponibles",
style="yellow"
)
# Intentar reensamblar cada array
for array_line in arrays_found:
# Extraer nombre del array
if '/dev/md' in array_line:
array_name = array_line.split()[1]
if self.console.confirm(f"¿Reensamblar array '{array_name}'?", default=True):
try:
self.console.print(f" 🔄 Reensamblando {array_name}...")
self.system.run_command(['mdadm', '--assemble', array_name], capture_output=True)
# Verificar estado
status_result = self.system.run_command(['mdadm', '--detail', array_name], capture_output=True)
if status_result.returncode == 0:
self.console.print(f" ✅ Array '{array_name}' reensamblado exitosamente", style="green")
# Detectar filesystem y ofrecer montaje
fs_type = self._detect_filesystem_on_device(array_name)
if fs_type:
self.console.print(f" 🔍 Filesystem detectado: {fs_type}")
# Verificar si ya está montado
current_mountpoint = self._get_current_mountpoint(array_name)
if current_mountpoint:
self.console.print(f" ✅ Ya montado en: {current_mountpoint}", style="green")
recovered.append(f"MDADM Array: {array_name} (montado en {current_mountpoint})")
else:
# Ofrecer montaje
if self.console.confirm(f"¿Montar {fs_type} en {array_name}?", default=True):
mountpoint = self.console.prompt(
f"Punto de montaje para {array_name}",
f"/mnt/{array_name.replace('/dev/', '')}"
)
try:
# Crear directorio y montar
self.system.run_command(['mkdir', '-p', mountpoint], capture_output=True)
self.system.run_command(['mount', array_name, mountpoint], capture_output=True)
self.console.print(f" ✅ Array montado en {mountpoint}", style="green")
recovered.append(f"MDADM Array: {array_name} (montado en {mountpoint})")
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error montando array: {e}", style="red")
recovered.append(f"MDADM Array: {array_name}")
else:
recovered.append(f"MDADM Array: {array_name}")
else:
self.console.print(f" ⚠️ No se detectó filesystem en {array_name}", style="yellow")
recovered.append(f"MDADM Array: {array_name}")
else:
self.console.print(f" ❌ Error verificando array '{array_name}'", style="red")
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error reensamblando '{array_name}': {e}", style="red")
else:
self.console.print(" ️ No se encontraron arrays MDADM para reensamblar", style="blue")
except subprocess.CalledProcessError:
self.console.print(" ❌ Error ejecutando mdadm --examine --scan", style="red")
return recovered
def _show_zfs_pool_info(self, pool_name: str):
"""Muestra información detallada de un pool ZFS"""
try:
# Obtener información del pool
status_result = self.system.run_command(['zpool', 'status', pool_name], capture_output=True)
list_result = self.system.run_command(['zpool', 'list', pool_name], capture_output=True)
# Obtener datasets y sus puntos de montaje
datasets_result = self.system.run_command(['zfs', 'list', '-H', '-o', 'name,mountpoint', pool_name], capture_output=True)
datasets_info = "Datasets montados automáticamente:\n"
for line in datasets_result.stdout.strip().split('\n'):
if line.strip():
parts = line.split('\t')
if len(parts) >= 2:
name, mountpoint = parts[0], parts[1]
datasets_info += f" • {name}{mountpoint}\n"
pool_state = "Unknown"
if 'state:' in status_result.stdout:
pool_state = status_result.stdout.split('state:')[1].split()[0]
self.console.print_panel(
f"Pool: {pool_name}\n" +
f"Estado: {pool_state}\n\n" +
datasets_info,
title=f"️ Información del Pool {pool_name}",
style="blue"
)
except subprocess.CalledProcessError:
pass
def _setup_fstab_mounting(self, items_for_fstab: list):
"""Configura montaje automático en /etc/fstab solo para BTRFS y MDADM"""
self.console.print_panel(
"⚙️ Configurando montaje automático en /etc/fstab...\n"
"Solo para filesystems que requieren configuración manual.",
title="⚙️ Configuración /etc/fstab",
style="blue"
)
for item in items_for_fstab:
if item.startswith('BTRFS:'):
self._configure_btrfs_automount(item)
elif item.startswith('MDADM Array:'):
self._configure_mdadm_automount(item)
def _setup_automatic_mounting(self, recovered_items: list):
"""Configura montaje automático para elementos recuperados - DEPRECATED"""
# Esta función se mantiene por compatibilidad pero ya no se usa
self._setup_fstab_mounting([item for item in recovered_items if not item.startswith('ZFS Pool:')])
def _configure_zfs_automount(self, zfs_item):
"""Verifica y configura montaje automático nativo para ZFS"""
pool_name = zfs_item.split(':')[1].strip()
self.console.print_panel(
f"🔷 Verificando configuración ZFS para pool: {pool_name}\n"
"ZFS usa montaje automático nativo - no requiere /etc/fstab",
title="🔷 Configuración ZFS",
style="blue"
)
try:
# Obtener datasets del pool
result = self.system.run_command(['zfs', 'list', '-H', '-o', 'name,canmount,mountpoint', pool_name], capture_output=True)
datasets_info = []
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split('\t')
if len(parts) >= 3:
name, canmount, mountpoint = parts[0], parts[1], parts[2]
datasets_info.append({
'name': name,
'canmount': canmount,
'mountpoint': mountpoint
})
# Mostrar estado actual
self.console.print_panel(
"Estado actual de datasets:\n" +
"\n".join([f"• {ds['name']}: canmount={ds['canmount']}, mountpoint={ds['mountpoint']}"
for ds in datasets_info]),
title=f"📊 Datasets en {pool_name}",
style="green"
)
# Verificar y corregir configuración
needs_fix = False
for ds in datasets_info:
if ds['canmount'] != 'on':
needs_fix = True
break
if needs_fix:
if self.console.confirm("Algunos datasets no tienen montaje automático habilitado. ¿Corregir?", default=True):
for ds in datasets_info:
if ds['canmount'] != 'on':
self.console.print(f" 🔧 Habilitando montaje automático para {ds['name']}")
self.system.run_command(['zfs', 'set', 'canmount=on', ds['name']], capture_output=True)
self.console.print("✅ Montaje automático configurado para todos los datasets", style="green")
else:
self.console.print("✅ ZFS ya tiene montaje automático configurado correctamente", style="green")
# Verificar servicio ZFS
self._ensure_zfs_service_enabled()
self.console.print_panel(
"️ ZFS configurado correctamente.\n"
"Los datasets se montarán automáticamente al iniciar el sistema.\n"
"No se requiere configuración adicional en /etc/fstab.",
title="✅ ZFS Listo",
style="green"
)
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error configurando ZFS: {e}", style="red")
def _configure_btrfs_automount(self, btrfs_item):
"""Configura montaje automático para BTRFS"""
uuid_info = btrfs_item.split(':')[1].strip()
uuid_short = uuid_info.split('...')[0].strip() if '...' in uuid_info else uuid_info
self.console.print(f"🌿 Configurando montaje automático para BTRFS: {uuid_short}...")
try:
# Encontrar el UUID completo y dispositivos
result = self.system.run_command(['btrfs', 'filesystem', 'show'], capture_output=True)
full_uuid = None
devices = []
in_target_fs = False
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('uuid:') and uuid_short in line:
full_uuid = line.split('uuid:')[1].strip()
in_target_fs = True
elif in_target_fs and line.startswith('devid') and 'path' in line:
device_path = line.split('path ')[1].strip()
devices.append(device_path)
elif in_target_fs and line.startswith('uuid:'):
break
if not full_uuid or not devices:
self.console.print("❌ No se pudo obtener información completa del filesystem BTRFS", style="red")
return
primary_device = devices[0]
# Verificar si ya está montado
current_mountpoint = self._get_current_mountpoint(primary_device)
if current_mountpoint:
mountpoint = current_mountpoint
self.console.print(f"📍 Ya montado en: {mountpoint}")
else:
# Ofrecer montaje primero
if self.console.confirm("El filesystem BTRFS no está montado. ¿Montarlo ahora?", default=True):
mountpoint = self.console.prompt(
f"Punto de montaje para BTRFS {uuid_short}",
f"/mnt/btrfs_{uuid_short}"
)
# Crear directorio de montaje
self.system.run_command(['mkdir', '-p', mountpoint], capture_output=True)
# Montar filesystem
self.system.run_command(['mount', '-t', 'btrfs', primary_device, mountpoint], capture_output=True)
self.console.print(f"✅ BTRFS montado en {mountpoint}", style="green")
else:
self.console.print("️ Configuración de montaje automático omitida (filesystem no montado)", style="blue")
return
# Detectar subvolúmenes
subvolumes = self._get_btrfs_subvolumes(primary_device, mountpoint)
# Generar entradas fstab
fstab_entries = []
# Entrada principal
fstab_entries.append(f"UUID={full_uuid} {mountpoint} btrfs defaults 0 0")
# Entradas para subvolúmenes
for subvol in subvolumes:
if subvol != '@' and subvol != '.': # Evitar subvolumen raíz y directorio actual
subvol_mountpoint = f"{mountpoint}/{subvol}"
fstab_entries.append(f"UUID={full_uuid} {subvol_mountpoint} btrfs defaults,subvol={subvol} 0 0")
# Mostrar vista previa
self._show_fstab_preview(fstab_entries)
if self.console.confirm("¿Añadir estas entradas a /etc/fstab?", default=True):
self._add_to_fstab(fstab_entries)
self.console.print("✅ Montaje automático configurado para BTRFS", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error configurando BTRFS: {e}", style="red")
def _configure_mdadm_automount(self, mdadm_item):
"""Configura montaje automático para MDADM"""
array_name = mdadm_item.split(':')[1].strip()
self.console.print(f"⚙️ Configurando montaje automático para MDADM: {array_name}")
try:
# 1. Configurar array en mdadm.conf
array_uuid = self._get_mdadm_uuid(array_name)
if array_uuid:
array_config = f"ARRAY {array_name} metadata=1.2 UUID={array_uuid}"
self.console.print_panel(
f"Configuración MDADM que se añadirá:\n{array_config}",
title="📋 Configuración /etc/mdadm/mdadm.conf",
style="blue"
)
if self.console.confirm("¿Añadir configuración del array a /etc/mdadm/mdadm.conf?", default=True):
self._add_to_mdadm_conf(array_config)
# 2. Configurar filesystem en fstab
fs_type = self._detect_filesystem_on_device(array_name)
fs_uuid = self._get_filesystem_uuid(array_name)
if fs_type and fs_uuid:
# Verificar si ya está montado
current_mountpoint = self._get_current_mountpoint(array_name)
if current_mountpoint:
mountpoint = current_mountpoint
self.console.print(f"📍 Ya montado en: {mountpoint}")
else:
# Ofrecer montaje
if self.console.confirm(f"El array tiene filesystem {fs_type}. ¿Montarlo ahora?", default=True):
mountpoint = self.console.prompt(
f"Punto de montaje para {array_name}",
f"/mnt/{array_name.replace('/dev/', '')}"
)
# Crear directorio y montar
self.system.run_command(['mkdir', '-p', mountpoint], capture_output=True)
self.system.run_command(['mount', array_name, mountpoint], capture_output=True)
self.console.print(f"✅ Array montado en {mountpoint}", style="green")
else:
self.console.print("️ Configuración de montaje automático omitida (array no montado)", style="blue")
return
# Generar entrada fstab
fstab_entry = f"UUID={fs_uuid} {mountpoint} {fs_type} defaults 0 2"
self._show_fstab_preview([fstab_entry])
if self.console.confirm("¿Añadir entrada a /etc/fstab?", default=True):
self._add_to_fstab([fstab_entry])
self.console.print("✅ Montaje automático configurado para MDADM", style="green")
else:
self.console.print("⚠️ No se pudo detectar filesystem en el array", style="yellow")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error configurando MDADM: {e}", style="red")
def _get_current_mountpoint(self, device):
"""Obtiene el punto de montaje actual de un dispositivo"""
try:
result = self.system.run_command(['findmnt', '-n', '-o', 'TARGET', '-S', device], capture_output=True)
return result.stdout.strip() if result.stdout.strip() else None
except subprocess.CalledProcessError:
return None
def _get_btrfs_subvolumes(self, device, mountpoint=None):
"""Obtiene lista de subvolúmenes BTRFS"""
subvolumes = []
try:
if mountpoint:
# Si está montado, usar btrfs subvolume list
result = self.system.run_command(['btrfs', 'subvolume', 'list', mountpoint], capture_output=True)
for line in result.stdout.split('\n'):
if 'path ' in line:
subvol_path = line.split('path ')[1].strip()
subvolumes.append(subvol_path)
else:
# Si no está montado, montar temporalmente para inspeccionar
temp_mount = f"/tmp/btrfs_inspect_{int(time.time())}"
self.system.run_command(['mkdir', '-p', temp_mount], capture_output=True)
self.system.run_command(['mount', '-t', 'btrfs', device, temp_mount], capture_output=True)
result = self.system.run_command(['btrfs', 'subvolume', 'list', temp_mount], capture_output=True)
for line in result.stdout.split('\n'):
if 'path ' in line:
subvol_path = line.split('path ')[1].strip()
subvolumes.append(subvol_path)
# Desmontar temporal
self.system.run_command(['umount', temp_mount], capture_output=True)
self.system.run_command(['rmdir', temp_mount], capture_output=True)
except subprocess.CalledProcessError:
pass
return subvolumes
def _get_mdadm_uuid(self, array_name):
"""Obtiene el UUID de un array MDADM"""
try:
result = self.system.run_command(['mdadm', '--detail', array_name], capture_output=True)
for line in result.stdout.split('\n'):
if 'UUID :' in line:
return line.split('UUID :')[1].strip()
except subprocess.CalledProcessError:
pass
return None
def _detect_filesystem_on_device(self, device):
"""Detecta el tipo de filesystem en un dispositivo"""
try:
result = self.system.run_command(['blkid', '-o', 'value', '-s', 'TYPE', device], capture_output=True)
return result.stdout.strip() if result.stdout.strip() else None
except subprocess.CalledProcessError:
return None
def _get_filesystem_uuid(self, device):
"""Obtiene el UUID del filesystem en un dispositivo"""
try:
result = self.system.run_command(['blkid', '-o', 'value', '-s', 'UUID', device], capture_output=True)
return result.stdout.strip() if result.stdout.strip() else None
except subprocess.CalledProcessError:
return None
def _show_fstab_preview(self, entries):
"""Muestra vista previa de entradas fstab"""
self.console.print_panel(
"Entradas que se añadirán a /etc/fstab:\n\n" +
"\n".join(entries),
title=" Vista Previa /etc/fstab",
style="blue"
)
def _add_to_fstab(self, entries):
"""Añade entradas a /etc/fstab de forma segura"""
import time
try:
# Crear backup
backup_path = f"/etc/fstab.backup.{int(time.time())}"
self.system.run_command(['cp', '/etc/fstab', backup_path], capture_output=True)
# Verificar que las entradas no existan ya
with open('/etc/fstab', 'r') as f:
existing_content = f.read()
new_entries = []
for entry in entries:
# Extraer UUID de la entrada
uuid_part = entry.split()[0]
if uuid_part not in existing_content:
new_entries.append(entry)
if new_entries:
# Añadir nuevas entradas
with open('/etc/fstab', 'a') as f:
f.write(f"\n# Entradas añadidas por RAID Manager - {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
for entry in new_entries:
f.write(f"{entry}\n")
self.console.print(f"✅ Backup creado: {backup_path}", style="green")
self.console.print(f"✅ {len(new_entries)} entradas añadidas a /etc/fstab", style="green")
# Ofrecer prueba de configuración
if self.console.confirm("¿Probar configuración con 'mount -a'?", default=False):
self._test_mount_configuration()
else:
self.console.print("️ Todas las entradas ya existen en /etc/fstab", style="blue")
except Exception as e:
self.console.print(f"❌ Error modificando /etc/fstab: {e}", style="red")
def _add_to_mdadm_conf(self, config):
"""Añade configuración a /etc/mdadm/mdadm.conf"""
import time
try:
conf_path = '/etc/mdadm/mdadm.conf'
backup_path = f"{conf_path}.backup.{int(time.time())}"
# Crear backup
self.system.run_command(['cp', conf_path, backup_path], capture_output=True)
# Verificar si la configuración ya existe
with open(conf_path, 'r') as f:
existing_content = f.read()
if config not in existing_content:
# Añadir configuración
with open(conf_path, 'a') as f:
f.write(f"\n# Configuración añadida por RAID Manager - {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"{config}\n")
self.console.print(f"✅ Backup creado: {backup_path}", style="green")
self.console.print("✅ Configuración añadida a /etc/mdadm/mdadm.conf", style="green")
# Actualizar initramfs
if self.console.confirm("¿Actualizar initramfs para aplicar cambios?", default=True):
self.system.run_command(['update-initramfs', '-u'], capture_output=False)
self.console.print("✅ initramfs actualizado", style="green")
else:
self.console.print("️ La configuración ya existe en mdadm.conf", style="blue")
except Exception as e:
self.console.print(f"❌ Error modificando mdadm.conf: {e}", style="red")
def _test_mount_configuration(self):
"""Prueba la configuración de montaje"""
try:
self.console.print("🧪 Probando configuración de montaje...")
self.system.run_command(['mount', '-a'], capture_output=True)
self.console.print("✅ Configuración de montaje válida", style="green")
except subprocess.CalledProcessError as e:
self.console.print_panel(
f"❌ Error en configuración de montaje:\n{str(e)}\n\n"
"Revisa /etc/fstab manualmente antes del próximo reinicio.",
title="⚠️ Error de Configuración",
style="red"
)
def _ensure_zfs_service_enabled(self):
"""Asegura que los servicios ZFS estén habilitados"""
try:
# Verificar y habilitar servicios ZFS necesarios
services = ['zfs-import-cache', 'zfs-mount', 'zfs.target']
for service in services:
try:
# Verificar si está habilitado
result = self.system.run_command(['systemctl', 'is-enabled', service], capture_output=True)
if 'enabled' not in result.stdout:
self.system.run_command(['systemctl', 'enable', service], capture_output=True)
self.console.print(f"✅ Servicio {service} habilitado", style="green")
except subprocess.CalledProcessError:
# Algunos servicios pueden no estar disponibles en todos los sistemas
pass
except Exception as e:
self.console.print(f"⚠️ Advertencia configurando servicios ZFS: {e}", style="yellow")
def create_raid_wizard(self):
"""Asistente para crear nueva configuración RAID"""
self.console.print_panel(
"Asistente de creación de RAID\n"
"Te guiaremos paso a paso para crear tu configuración ideal",
title="🧙‍♂️ Asistente RAID"
)
# Verificar permisos
if not self.system.is_root() and not self.system.check_sudo():
self.console.print("⚠️ Se requieren permisos de administrador para crear RAID", style="yellow")
if not self.console.confirm("¿Continuar de todas formas?", default=False):
return
# Paso 1: Detectar discos disponibles
self.console.print_panel("Paso 1: Detectando discos disponibles", title="🔍 Detección")
disks = self.disk_manager.detect_disks()
available_disks = [d for d in disks if not d.is_system]
if not available_disks:
self.console.print("❌ No hay discos disponibles para RAID", style="red")
self.console.print("💡 Todos los discos detectados son del sistema o están en uso crítico", style="blue")
# Mostrar discos del sistema detectados para información
system_disks = [d for d in disks if d.is_system]
if system_disks:
self.console.print("\n🔒 Discos del sistema detectados (protegidos):")
for disk in system_disks:
mount_info = f" (montajes: {', '.join(disk.mount_points)})" if disk.mount_points else ""
self.console.print(f" • {disk.name} - {disk.size_human}{mount_info}")
return
# Mostrar discos detectados
self._show_available_disks(available_disks)
# Paso 2: Seleccionar tipo de filesystem
self.console.print_panel("Paso 2: Seleccionando tipo de filesystem", title="🗂️ Filesystem")
fs_type = self._select_filesystem_type()
# Paso 3 y 4: Bucle para selección de discos y tipo de RAID
while True:
# Paso 3: Seleccionar discos
self.console.print_panel("Paso 3: Seleccionando discos para el RAID", title="💾 Selección")
selected_disks = self._select_disks(available_disks)
if not selected_disks:
self.console.print("❌ Operación cancelada", style="yellow")
return
# Paso 4: Seleccionar tipo de RAID
self.console.print_panel("Paso 4: Seleccionando tipo de RAID", title="⚙️ Configuración")
raid_type = self._select_raid_type(fs_type, len(selected_disks))
# Si raid_type es None, significa que quiere volver a selección de discos
if raid_type is None:
self.console.print("↩️ Volviendo a selección de discos...", style="blue")
continue
else:
break # Salir del bucle si se seleccionó un tipo válido
# Paso 5: Cálculo de capacidad y confirmación
self.console.print_panel("Paso 5: Resumen y confirmación", title="📋 Confirmación")
capacity_info = self._calculate_raid_capacity(raid_type, selected_disks)
# Mostrar resumen
self._show_raid_summary(fs_type, raid_type, selected_disks, capacity_info)
# Confirmación final
if not self.console.confirm("¿Proceder con la creación del RAID?", default=False):
self.console.print("❌ Operación cancelada", style="yellow")
return
# Paso 6: Ejecución
self.console.print_panel("Paso 6: Creando RAID", title="🔨 Ejecución")
self._configure_raid(fs_type, raid_type, selected_disks)
def _detect_zfs_pools(self):
"""Detecta pools ZFS existentes"""
try:
# Verificar si ZFS está disponible
self.system.run_command(['which', 'zpool'])
result = self.system.run_command(['zpool', 'list', '-H'])
if result.stdout.strip():
self._show_zfs_pools_detailed()
return True
else:
return False
except subprocess.CalledProcessError:
return False
def _show_zfs_pools_detailed(self):
"""Muestra información detallada de pools ZFS"""
try:
# Obtener lista de pools con información detallada
result = self.system.run_command(['zpool', 'list', '-H', '-o', 'name,size,allocated,free,health,altroot'])
if RICH_AVAILABLE:
table = Table(title="🔷 Pools ZFS", show_header=True, header_style="bold blue")
table.add_column("Pool", style="cyan", no_wrap=True)
table.add_column("Tamaño", style="green")
table.add_column("Usado", style="yellow")
table.add_column("Libre", style="blue")
table.add_column("Estado", style="magenta")
table.add_column("Datasets", style="white")
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split('\t')
if len(parts) >= 5:
pool_name = parts[0]
size = parts[1]
allocated = parts[2]
free = parts[3]
health = parts[4]
# Obtener número de datasets
datasets_count = self._get_zfs_datasets_count(pool_name)
# Formatear estado con emojis
health_emoji = "💚" if health == "ONLINE" else "⚠️" if health == "DEGRADED" else "❌"
health_display = f"{health_emoji} {health}"
table.add_row(pool_name, size, allocated, free, health_display, str(datasets_count))
self.console.console.print(table)
# Mostrar información de datasets para cada pool
self._show_zfs_datasets_info()
else:
print("\n🔷 Pools ZFS:")
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split('\t')
if len(parts) >= 5:
print(f" 📦 {parts[0]} - {parts[1]} (Usado: {parts[2]}, Libre: {parts[3]}, Estado: {parts[4]})")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error obteniendo información de pools ZFS: {e}", style="red")
def _show_zfs_datasets_info(self):
"""Muestra información de datasets para cada pool ZFS"""
try:
pools_result = self.system.run_command(['zpool', 'list', '-H', '-o', 'name'])
for pool_name in pools_result.stdout.strip().split('\n'):
if pool_name.strip():
# Obtener datasets del pool
try:
datasets_result = self.system.run_command(['zfs', 'list', '-H', '-r', pool_name, '-o', 'name,used,avail,mountpoint,compression'])
if datasets_result.stdout.strip():
# Crear tabla para datasets de este pool
if RICH_AVAILABLE:
datasets_table = Table(title=f"📁 Datasets del pool '{pool_name}'", show_header=True, header_style="bold cyan")
datasets_table.add_column("Dataset", style="cyan")
datasets_table.add_column("Usado", style="yellow")
datasets_table.add_column("Disponible", style="green")
datasets_table.add_column("Montaje", style="blue")
datasets_table.add_column("Compresión", style="magenta")
for line in datasets_result.stdout.strip().split('\n'):
parts = line.split('\t')
if len(parts) >= 4 and parts[0] != pool_name: # Skip pool itself
dataset_name = parts[0].split('/')[-1] if '/' in parts[0] else parts[0]
used = parts[1]
avail = parts[2]
mountpoint = parts[3]
compression = parts[4] if len(parts) > 4 else "N/A"
datasets_table.add_row(dataset_name, used, avail, mountpoint, compression)
self.console.console.print(datasets_table)
else:
print(f"\n📁 Datasets del pool '{pool_name}':")
for line in datasets_result.stdout.strip().split('\n'):
parts = line.split('\t')
if len(parts) >= 4 and parts[0] != pool_name:
dataset_name = parts[0].split('/')[-1]
used = parts[1]
mountpoint = parts[3]
print(f" • {dataset_name} - Usado: {used}, Montaje: {mountpoint}")
except subprocess.CalledProcessError:
pass
except subprocess.CalledProcessError:
pass
def _get_zfs_datasets_count(self, pool_name: str) -> int:
"""Obtiene el número de datasets en un pool ZFS"""
try:
result = self.system.run_command(['zfs', 'list', '-H', '-r', pool_name])
# Contar líneas menos la del pool principal
lines = result.stdout.strip().split('\n')
return len([line for line in lines if line.strip()]) - 1
except subprocess.CalledProcessError:
return 0
def _show_zfs_pool_details(self):
"""Muestra detalles adicionales de cada pool ZFS"""
try:
pools_result = self.system.run_command(['zpool', 'list', '-H', '-o', 'name'])
for pool_name in pools_result.stdout.strip().split('\n'):
if pool_name.strip():
self.console.print(f"\n📋 Detalles del pool '{pool_name}':", style="bold blue")
# Información de datasets
try:
datasets_result = self.system.run_command(['zfs', 'list', '-H', '-r', pool_name, '-o', 'name,used,avail,mountpoint,compression'])
if datasets_result.stdout.strip():
self.console.print(" 📁 Datasets:")
for line in datasets_result.stdout.strip().split('\n'):
parts = line.split('\t')
if len(parts) >= 4 and parts[0] != pool_name: # Skip pool itself
dataset_name = parts[0]
used = parts[1]
avail = parts[2]
mountpoint = parts[3]
compression = parts[4] if len(parts) > 4 else "N/A"
self.console.print(f" • {dataset_name.split('/')[-1]} - Usado: {used}, Montaje: {mountpoint}, Compresión: {compression}")
except subprocess.CalledProcessError:
pass
# Información de dispositivos
try:
status_result = self.system.run_command(['zpool', 'status', pool_name])
self.console.print(" 💿 Dispositivos:")
# Parsear salida de zpool status para mostrar dispositivos
in_config = False
config_lines = []
for line in status_result.stdout.split('\n'):
stripped_line = line.strip()
if 'config:' in line.lower():
in_config = True
continue
elif in_config and stripped_line and not stripped_line.startswith('NAME') and not stripped_line.startswith('errors'):
if not stripped_line.startswith('pool:') and not stripped_line.startswith('state:'):
# Buscar líneas que contengan dispositivos
parts = stripped_line.split()
if parts and (parts[0].startswith('/dev/') or
any(x in parts[0] for x in ['sd', 'nvme', 'loop']) or
parts[0] in ['mirror-0', 'raidz1-0', 'raidz2-0', 'raidz3-0']):
device_name = parts[0]
device_state = parts[1] if len(parts) > 1 else "UNKNOWN"
read_errors = parts[2] if len(parts) > 2 else "0"
write_errors = parts[3] if len(parts) > 3 else "0"
checksum_errors = parts[4] if len(parts) > 4 else "0"
# Formatear estado con emoji
if device_state == "ONLINE":
state_emoji = "✅"
elif device_state in ["DEGRADED", "FAULTED"]:
state_emoji = "⚠️"
elif device_state == "OFFLINE":
state_emoji = "❌"
else:
state_emoji = "❓"
self.console.print(f" • {device_name} - {state_emoji} {device_state}")
# Mostrar errores si los hay
if any(err != "0" for err in [read_errors, write_errors, checksum_errors]):
self.console.print(f" ⚠️ Errores: R:{read_errors} W:{write_errors} C:{checksum_errors}")
elif in_config and (stripped_line.startswith('errors:') or stripped_line == ''):
break
# Si no se encontraron dispositivos específicos, mostrar información básica
if not any('✅' in line or '⚠️' in line or '❌' in line for line in config_lines):
# Obtener información básica del pool
try:
list_result = self.system.run_command(['zpool', 'list', '-v', pool_name])
self.console.print(" 📊 Configuración del pool detectada")
except subprocess.CalledProcessError:
pass
except subprocess.CalledProcessError:
pass
except subprocess.CalledProcessError:
pass
def _detect_btrfs_filesystems(self):
"""Detecta filesystems BTRFS existentes"""
try:
# Verificar si BTRFS está disponible
self.system.run_command(['which', 'btrfs'])
result = self.system.run_command(['btrfs', 'filesystem', 'show'])
if result.stdout.strip() and 'no btrfs found' not in result.stdout.lower():
self._show_btrfs_detailed()
return True
else:
return False
except subprocess.CalledProcessError:
return False
def _show_btrfs_detailed(self):
"""Muestra información detallada de filesystems BTRFS"""
try:
# Obtener información de filesystems BTRFS
result = self.system.run_command(['btrfs', 'filesystem', 'show'])
if RICH_AVAILABLE:
table = Table(title="🌿 Filesystems BTRFS", show_header=True, header_style="bold green")
table.add_column("UUID", style="cyan")
table.add_column("Label", style="green")
table.add_column("Dispositivos", style="yellow")
table.add_column("Uso", style="blue")
table.add_column("Estado", style="magenta")
# Parsear salida de btrfs filesystem show
current_fs = {}
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('uuid:'):
if current_fs:
# Agregar filesystem anterior a la tabla
self._add_btrfs_to_table(table, current_fs)
current_fs = {'uuid': line.split('uuid:')[1].strip()}
elif 'Label:' in line:
current_fs['label'] = line.split('Label:')[1].strip().replace("'", "")
elif line.startswith('devid'):
if 'devices' not in current_fs:
current_fs['devices'] = []
# Extraer información del dispositivo
parts = line.split()
for part in parts:
if part.startswith('/dev/'):
current_fs['devices'].append(part)
# Agregar último filesystem
if current_fs:
self._add_btrfs_to_table(table, current_fs)
self.console.console.print(table)
else:
print("\n🌿 Filesystems BTRFS:")
# Versión texto simple
current_fs = None
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('uuid:'):
uuid = line.split('uuid:')[1].strip()
print(f" 📦 UUID: {uuid}")
elif 'Label:' in line:
label = line.split('Label:')[1].strip().replace("'", "")
print(f" Label: {label}")
elif line.startswith('devid'):
parts = line.split()
for part in parts:
if part.startswith('/dev/'):
print(f" Dispositivo: {part}")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error obteniendo información de BTRFS: {e}", style="red")
def _add_btrfs_to_table(self, table, fs_info):
"""Añade información de filesystem BTRFS a la tabla"""
uuid_short = fs_info.get('uuid', 'N/A')[:8] + '...'
label = fs_info.get('label', 'Sin label')
devices = ', '.join(fs_info.get('devices', []))
# Obtener información de uso
usage_info = self._get_btrfs_usage(fs_info.get('devices', []))
table.add_row(
uuid_short,
label,
devices,
usage_info.get('usage', 'N/A'),
usage_info.get('status', '✅ OK')
)
def _get_btrfs_usage(self, devices):
"""Obtiene información de uso de un filesystem BTRFS"""
if not devices:
return {'usage': 'N/A', 'status': 'N/A'}
try:
# Intentar obtener información de uso del primer dispositivo
device = devices[0]
result = self.system.run_command(['btrfs', 'filesystem', 'usage', device])
# Parsear información básica
usage_lines = result.stdout.split('\n')
size = "N/A"
used = "N/A"
for line in usage_lines:
if 'Device size:' in line:
size = line.split('Device size:')[1].strip()
elif 'Used:' in line and 'Device' not in line:
used = line.split('Used:')[1].strip()
return {
'usage': f"Usado: {used} / {size}",
'status': '✅ OK'
}
except subprocess.CalledProcessError:
return {'usage': 'Error', 'status': '❌ Error'}
def _show_btrfs_usage_details(self):
"""Muestra detalles de uso de filesystems BTRFS"""
try:
# Obtener lista de filesystems montados
result = self.system.run_command(['findmnt', '-t', 'btrfs', '-n', '-o', 'TARGET,SOURCE'])
if result.stdout.strip():
self.console.print("\n📊 Información detallada de BTRFS:", style="bold blue")
for line in result.stdout.strip().split('\n'):
parts = line.split()
if len(parts) >= 2:
mountpoint = parts[0]
device = parts[1]
self.console.print(f" 📁 Montado en: {mountpoint}")
self.console.print(f" Dispositivo: {device}")
# Obtener información de subvolúmenes
try:
subvol_result = self.system.run_command(['btrfs', 'subvolume', 'list', mountpoint])
if subvol_result.stdout.strip():
subvol_count = len(subvol_result.stdout.strip().split('\n'))
self.console.print(f" Subvolúmenes: {subvol_count}")
except subprocess.CalledProcessError:
pass
self.console.print("")
except subprocess.CalledProcessError:
pass
def _detect_mdadm_arrays(self):
"""Detecta arrays MDADM existentes"""
try:
# Verificar si MDADM está disponible
self.system.run_command(['which', 'mdadm'])
# Leer /proc/mdstat
result = self.system.run_command(['cat', '/proc/mdstat'])
if 'md' in result.stdout and 'active' in result.stdout:
self._show_mdadm_detailed()
return True
else:
return False
except subprocess.CalledProcessError:
return False
def _show_mdadm_detailed(self):
"""Muestra información detallada de arrays MDADM"""
try:
result = self.system.run_command(['cat', '/proc/mdstat'])
if RICH_AVAILABLE:
table = Table(title="⚡ Arrays MDADM", show_header=True, header_style="bold yellow")
table.add_column("Array", style="cyan")
table.add_column("Tipo RAID", style="green")
table.add_column("Estado", style="yellow")
table.add_column("Dispositivos", style="blue")
table.add_column("Progreso", style="magenta")
# Parsear /proc/mdstat
arrays_info = self._parse_mdstat(result.stdout)
for array_info in arrays_info:
status_emoji = "✅" if array_info['active'] else "❌"
status = f"{status_emoji} {'Activo' if array_info['active'] else 'Inactivo'}"
progress = array_info.get('progress', 'Completo')
table.add_row(
array_info['name'],
array_info['raid_type'],
status,
', '.join(array_info['devices']),
progress
)
self.console.console.print(table)
else:
print("\n⚡ Arrays MDADM:")
arrays_info = self._parse_mdstat(result.stdout)
for array_info in arrays_info:
status = "Activo" if array_info['active'] else "Inactivo"
print(f" 📦 {array_info['name']} - {array_info['raid_type']} - {status}")
print(f" Dispositivos: {', '.join(array_info['devices'])}")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error obteniendo información de MDADM: {e}", style="red")
def _parse_mdstat(self, mdstat_content):
"""Parsea el contenido de /proc/mdstat"""
arrays = []
lines = mdstat_content.split('\n')
i = 0
while i < len(lines):
line = lines[i].strip()
if line.startswith('md') and ':' in line:
# Línea de definición del array
parts = line.split()
array_name = parts[0]
active = 'active' in line
# Extraer tipo RAID
raid_type = "Unknown"
if 'raid0' in line:
raid_type = "RAID 0"
elif 'raid1' in line:
raid_type = "RAID 1"
elif 'raid5' in line:
raid_type = "RAID 5"
elif 'raid6' in line:
raid_type = "RAID 6"
elif 'raid10' in line:
raid_type = "RAID 10"
# Extraer dispositivos
devices = []
for part in parts:
if part.startswith('sd') or part.startswith('nvme'):
# Limpiar sufijos como [0], [1], etc.
device = part.split('[')[0]
devices.append(device)
array_info = {
'name': array_name,
'active': active,
'raid_type': raid_type,
'devices': devices
}
# Verificar línea siguiente para progreso
if i + 1 < len(lines):
next_line = lines[i + 1].strip()
if '%' in next_line and ('recovery' in next_line or 'resync' in next_line or 'rebuild' in next_line):
# Extraer información de progreso
if 'recovery' in next_line:
array_info['progress'] = "🔄 Recuperando..."
elif 'resync' in next_line:
array_info['progress'] = "🔄 Resincronizando..."
elif 'rebuild' in next_line:
array_info['progress'] = "🔄 Reconstruyendo..."
arrays.append(array_info)
i += 1
return arrays
def _show_mdadm_details(self):
"""Muestra detalles adicionales de arrays MDADM"""
try:
# Obtener lista de arrays activos
result = self.system.run_command(['cat', '/proc/mdstat'])
arrays_info = self._parse_mdstat(result.stdout)
for array_info in arrays_info:
array_name = array_info['name']
self.console.print(f"\n📋 Detalles del array '{array_name}':", style="bold blue")
try:
# Obtener información detallada con mdadm --detail
detail_result = self.system.run_command(['mdadm', '--detail', f'/dev/{array_name}'])
# Parsear información importante
for line in detail_result.stdout.split('\n'):
line = line.strip()
if 'Array Size' in line:
size = line.split(':')[1].strip()
self.console.print(f" 📏 Tamaño: {size}")
elif 'Used Dev Size' in line:
used_size = line.split(':')[1].strip()
self.console.print(f" 💾 Tamaño por dispositivo: {used_size}")
elif 'State :' in line:
state = line.split(':')[1].strip()
self.console.print(f" 🔍 Estado: {state}")
elif 'Active Devices' in line:
active_devs = line.split(':')[1].strip()
self.console.print(f" ✅ Dispositivos activos: {active_devs}")
elif 'Failed Devices' in line:
failed_devs = line.split(':')[1].strip()
if failed_devs != '0':
self.console.print(f" ❌ Dispositivos fallidos: {failed_devs}")
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo obtener información detallada de {array_name}")
except subprocess.CalledProcessError:
pass
def _detect_lvm_volumes(self):
"""Detecta Volume Groups LVM existentes"""
try:
# Verificar si LVM está disponible
self.system.run_command(['which', 'vgs'])
result = self.system.run_command(['vgs', '--noheadings'])
if result.stdout.strip():
self._show_lvm_detailed()
return True
else:
return False
except subprocess.CalledProcessError:
return False
def _show_lvm_detailed(self):
"""Muestra información detallada de Volume Groups LVM"""
try:
result = self.system.run_command(['vgs', '--noheadings', '--units', 'g'])
if RICH_AVAILABLE:
table = Table(title="💼 Volume Groups LVM", show_header=True, header_style="bold magenta")
table.add_column("VG Name", style="cyan")
table.add_column("PVs", style="green")
table.add_column("LVs", style="yellow")
table.add_column("Tamaño", style="blue")
table.add_column("Libre", style="magenta")
table.add_column("Logical Volumes", style="white")
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split()
if len(parts) >= 6:
vg_name = parts[0]
pv_count = parts[1]
lv_count = parts[2]
vg_size = parts[5]
vg_free = parts[6]
# Obtener nombres de logical volumes
lv_names = self._get_lvm_logical_volumes(vg_name)
lv_display = ', '.join(lv_names[:3]) # Mostrar hasta 3
if len(lv_names) > 3:
lv_display += f" (+{len(lv_names)-3} más)"
table.add_row(vg_name, pv_count, lv_count, vg_size, vg_free, lv_display)
self.console.console.print(table)
else:
print("\n💼 Volume Groups LVM:")
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split()
if len(parts) >= 6:
print(f" 📦 {parts[0]} - PVs: {parts[1]}, LVs: {parts[2]}, Tamaño: {parts[5]}")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error obteniendo información de LVM: {e}", style="red")
def _get_lvm_logical_volumes(self, vg_name):
"""Obtiene nombres de logical volumes de un VG"""
try:
result = self.system.run_command(['lvs', '--noheadings', '-o', 'name', vg_name])
return [line.strip() for line in result.stdout.strip().split('\n') if line.strip()]
except subprocess.CalledProcessError:
return []
def _show_lvm_details(self):
"""Muestra detalles adicionales de Volume Groups LVM"""
try:
vgs_result = self.system.run_command(['vgs', '--noheadings', '-o', 'name'])
for line in vgs_result.stdout.strip().split('\n'):
vg_name = line.strip()
if vg_name:
self.console.print(f"\n📋 Detalles del Volume Group '{vg_name}':", style="bold blue")
# Información de Physical Volumes
try:
pvs_result = self.system.run_command(['pvs', '--noheadings', '-o', 'name,size', '-S', f'vg_name={vg_name}'])
if pvs_result.stdout.strip():
self.console.print(" 💿 Physical Volumes:")
for pv_line in pvs_result.stdout.strip().split('\n'):
pv_parts = pv_line.strip().split()
if len(pv_parts) >= 2:
self.console.print(f" • {pv_parts[0]} - {pv_parts[1]}")
except subprocess.CalledProcessError:
pass
# Información de Logical Volumes
try:
lvs_result = self.system.run_command(['lvs', '--noheadings', '-o', 'name,size,attr', vg_name])
if lvs_result.stdout.strip():
self.console.print(" 📁 Logical Volumes:")
for lv_line in lvs_result.stdout.strip().split('\n'):
lv_parts = lv_line.strip().split()
if len(lv_parts) >= 3:
lv_name = lv_parts[0]
lv_size = lv_parts[1]
lv_attr = lv_parts[2]
active_status = "✅ Activo" if lv_attr[4] == 'a' else "❌ Inactivo"
self.console.print(f" • {lv_name} - {lv_size} - {active_status}")
except subprocess.CalledProcessError:
pass
except subprocess.CalledProcessError:
pass
def _show_available_disks(self, disks: List[Disk]):
"""Muestra discos disponibles en formato tabla"""
if RICH_AVAILABLE:
table = Table(title="💾 Discos Disponibles")
table.add_column("Disco", style="cyan")
table.add_column("Tamaño", style="green")
table.add_column("Modelo", style="yellow")
table.add_column("Sectores", style="blue")
for disk in disks:
table.add_row(
disk.name,
disk.size_human,
disk.model,
str(disk.sector_size)
)
self.console.console.print(table)
else:
print("\n💾 Discos Disponibles:")
for i, disk in enumerate(disks, 1):
print(f" {i}. {disk.name} - {disk.size_human} - {disk.model}")
def _select_filesystem_type(self) -> FilesystemType:
"""Selecciona tipo de filesystem usando información ya verificada"""
self.console.print("\n📁 Tipo de filesystem:")
# Usar el estado ya verificado al inicio del script
raid_tools = self.raid_tools_status
# Mostrar ZFS con estado
if raid_tools.get('zfs', False):
self.console.print(" 1. ZFS (recomendado para máxima funcionalidad) ✅")
else:
self.console.print(" 1. ZFS (recomendado para máxima funcionalidad) ❌ No instalado")
# Mostrar BTRFS con estado
if raid_tools.get('btrfs', False):
self.console.print(" 2. BTRFS (alternativa moderna) ✅")
else:
self.console.print(" 2. BTRFS (alternativa moderna) ❌ No instalado")
while True:
choice = self.console.prompt("👉 Selecciona tipo", "1")
if choice == "1":
# Usuario seleccionó ZFS
if not raid_tools.get('zfs', False):
self.console.print_panel(
"⚠️ ZFS no está instalado en el sistema.\n"
"ZFS ofrece características avanzadas como snapshots, compresión y detección de errores.",
title="🔷 ZFS No Disponible",
style="yellow"
)
if self.console.confirm("¿Deseas instalar ZFS ahora?", default=True):
if self.requirements_checker._install_specific_raid_tool('zfs'):
self.console.print("✅ ZFS instalado correctamente", style="green")
# Actualizar cache después de la instalación
self.raid_tools_status['zfs'] = True
return FilesystemType.ZFS
else:
self.console.print("❌ Error instalando ZFS. Selecciona otra opción.", style="red")
continue
else:
self.console.print("💡 Selecciona BTRFS o instala ZFS para continuar.", style="blue")
continue
else:
return FilesystemType.ZFS
elif choice == "2":
# Usuario seleccionó BTRFS
if not raid_tools.get('btrfs', False):
self.console.print_panel(
"⚠️ BTRFS no está instalado en el sistema.\n"
"BTRFS ofrece características modernas como snapshots, compresión y balanceado.",
title="🌿 BTRFS No Disponible",
style="yellow"
)
if self.console.confirm("¿Deseas instalar BTRFS ahora?", default=True):
if self.requirements_checker._install_specific_raid_tool('btrfs'):
self.console.print("✅ BTRFS instalado correctamente", style="green")
# Actualizar cache después de la instalación
self.raid_tools_status['btrfs'] = True
return FilesystemType.BTRFS
else:
self.console.print("❌ Error instalando BTRFS. Selecciona otra opción.", style="red")
continue
else:
self.console.print("💡 Selecciona ZFS o instala BTRFS para continuar.", style="blue")
continue
else:
return FilesystemType.BTRFS
else:
self.console.print("❌ Opción inválida", style="red")
def _select_disks(self, available_disks: List[Disk]) -> List[Disk]:
"""Selecciona discos para el RAID"""
self.console.print("\n💾 Selección de discos para RAID:")
selected_disks = []
while True:
# Mostrar tabla actualizada con selecciones
self._show_disk_selection_table(available_disks, selected_disks)
self.console.print(f"\n📋 Discos seleccionados: {len(selected_disks)}")
if selected_disks:
selected_names = [f"{disk.name} ({disk.size_human})" for disk in selected_disks]
self.console.print(f" ✅ {', '.join(selected_names)}")
self.console.print("\nOpciones:")
self.console.print(" • Números separados por espacios (ej: 1 3 4) para agregar/quitar")
self.console.print(" • 'c' para continuar con la selección actual")
self.console.print(" • 'q' para cancelar")
choice = self.console.prompt("👉 Selección", "c").strip().lower()
if choice == 'c':
if len(selected_disks) < 2:
self.console.print("❌ Necesitas al menos 2 discos para RAID", style="red")
continue
# Verificar advertencias sobre datos existentes solo al final
disks_with_data = [d for d in selected_disks if d.has_partitions or d.filesystem_type]
if disks_with_data:
self.console.print("\n⚠️ ADVERTENCIA: Los siguientes discos seleccionados contienen datos:", style="yellow")
for disk in disks_with_data:
warnings = []
if disk.has_partitions:
warnings.append("particiones")
if disk.filesystem_type:
warnings.append(f"filesystem {disk.filesystem_type}")
self.console.print(f" • {disk.name} - {', '.join(warnings)}")
self.console.print("🚨 La creación de RAID DESTRUIRÁ todos los datos existentes", style="red")
if not self.console.confirm("¿Continuar de todas formas?", default=False):
continue
break
elif choice == 'q':
return []
else:
# Parsear números múltiples
try:
disk_numbers = [int(x.strip()) for x in choice.split() if x.strip().isdigit()]
for disk_num in disk_numbers:
disk_index = disk_num - 1
if 0 <= disk_index < len(available_disks):
disk = available_disks[disk_index]
if disk in selected_disks:
selected_disks.remove(disk)
self.console.print(f" Disco {disk.name} eliminado de la selección", style="yellow")
else:
selected_disks.append(disk)
self.console.print(f" Disco {disk.name} agregado a la selección", style="green")
else:
self.console.print(f"❌ Número de disco inválido: {disk_num}", style="red")
if not disk_numbers:
self.console.print("❌ Entrada inválida. Usa números separados por espacios", style="red")
except ValueError:
self.console.print("❌ Entrada inválida. Usa números separados por espacios", style="red")
return selected_disks
def _show_disk_selection_table(self, available_disks: List[Disk], selected_disks: List[Disk]):
"""Muestra tabla de selección de discos con estado de selección"""
if RICH_AVAILABLE:
table = Table(title="🎯 Selección de Discos para RAID")
table.add_column("Sel", style="bold green", width=4, justify="center")
table.add_column("#", style="bold cyan", width=3)
table.add_column("Disco", style="cyan")
table.add_column("Tamaño", style="green")
table.add_column("Modelo", style="yellow")
table.add_column("Estado", style="blue")
for i, disk in enumerate(available_disks, 1):
# Verificar si está seleccionado
is_selected = disk in selected_disks
selection_mark = "✅" if is_selected else "⬜"
# Verificar estado del disco
status_parts = []
if disk.has_partitions:
status_parts.append("🟡 Particiones")
if disk.filesystem_type:
status_parts.append(f"🔵 {disk.filesystem_type}")
status = " + ".join(status_parts) if status_parts else "🟢 Libre"
table.add_row(
selection_mark,
str(i),
disk.name,
disk.size_human,
disk.model,
status
)
self.console.console.print(table)
else:
print("\n🎯 Selección de Discos para RAID:")
for i, disk in enumerate(available_disks, 1):
is_selected = disk in selected_disks
mark = "[✓]" if is_selected else "[ ]"
status_parts = []
if disk.has_partitions:
status_parts.append("Particiones")
if disk.filesystem_type:
status_parts.append(disk.filesystem_type)
status = " + ".join(status_parts) if status_parts else "Libre"
print(f" {mark} {i}. {disk.name} - {disk.size_human} - {disk.model} ({status})")
def _select_raid_type(self, fs_type: FilesystemType, disk_count: int) -> RAIDType:
"""Selecciona tipo de RAID según filesystem y número de discos"""
self.console.print(f"\n⚙️ Selección de tipo RAID para {fs_type.value.upper()}")
self.console.print(f"📊 Discos disponibles: {disk_count}")
if fs_type == FilesystemType.ZFS:
return self._select_zfs_raid_type(disk_count)
else:
return self._select_btrfs_raid_type(disk_count)
def _select_zfs_raid_type(self, disk_count: int) -> RAIDType:
"""Selecciona tipo de RAID para ZFS"""
self.console.print("\n🔷 Tipos de RAID disponibles en ZFS:")
options = []
# Stripe (sin redundancia)
options.append((1, RAIDType.STRIPE, "Stripe - Sin redundancia, máximo rendimiento"))
# Mirror (requiere 2+ discos)
if disk_count >= 2:
options.append((2, RAIDType.MIRROR, "Mirror - Datos duplicados (50% capacidad)"))
# RAIDZ1 (requiere 3+ discos)
if disk_count >= 3:
options.append((3, RAIDType.RAIDZ1, "RAIDZ1 - Tolerancia a 1 fallo (equivalente RAID 5)"))
# RAIDZ2 (requiere 4+ discos)
if disk_count >= 4:
options.append((4, RAIDType.RAIDZ2, "RAIDZ2 - Tolerancia a 2 fallos (equivalente RAID 6)"))
# RAIDZ3 (requiere 5+ discos)
if disk_count >= 5:
options.append((5, RAIDType.RAIDZ3, "RAIDZ3 - Tolerancia a 3 fallos"))
# Mostrar opciones
for num, raid_type, description in options:
self.console.print(f" {num}. {description}")
# Opción para volver a selección de discos
self.console.print(f" 0. ← Volver a selección de discos")
while True:
choice = self.console.prompt("👉 Selecciona tipo de RAID", "2" if disk_count >= 2 else "1")
if choice == "0":
return None # Señal para volver a selección de discos
for num, raid_type, description in options:
if choice == str(num):
return raid_type
self.console.print("❌ Opción inválida", style="red")
def _select_btrfs_raid_type(self, disk_count: int) -> RAIDType:
"""Selecciona tipo de RAID para BTRFS"""
self.console.print("\n🌿 Tipos de RAID disponibles en BTRFS:")
options = []
# RAID 0
options.append((1, RAIDType.BTRFS_RAID0, "RAID 0 - Sin redundancia, máximo rendimiento"))
# RAID 1 (requiere 2+ discos)
if disk_count >= 2:
options.append((2, RAIDType.BTRFS_RAID1, "RAID 1 - Datos duplicados (50% capacidad)"))
# RAID 10 (requiere 4+ discos)
if disk_count >= 4:
options.append((3, RAIDType.BTRFS_RAID10, "RAID 10 - Combinación RAID 0+1 (requiere 4+ discos)"))
# RAID 5 (requiere 3+ discos) - EXPERIMENTAL
if disk_count >= 3:
options.append((4, RAIDType.BTRFS_RAID5, "RAID 5 - Tolerancia a 1 fallo ⚠️ EXPERIMENTAL"))
# RAID 6 (requiere 4+ discos) - EXPERIMENTAL
if disk_count >= 4:
options.append((5, RAIDType.BTRFS_RAID6, "RAID 6 - Tolerancia a 2 fallos ⚠️ EXPERIMENTAL"))
# Mostrar opciones
for num, raid_type, description in options:
self.console.print(f" {num}. {description}")
# Advertencia sobre RAID 5/6 experimental
if disk_count >= 3:
self.console.print("\n⚠️ ADVERTENCIA: RAID 5/6 en BTRFS es experimental", style="yellow")
self.console.print(" • Puede tener problemas de estabilidad y rendimiento", style="yellow")
self.console.print(" • No recomendado para sistemas de producción críticos", style="yellow")
# Opción para volver a selección de discos
self.console.print(f"\n 0. ← Volver a selección de discos")
while True:
choice = self.console.prompt("👉 Selecciona tipo de RAID", "2" if disk_count >= 2 else "1")
if choice == "0":
return None # Señal para volver a selección de discos
for num, raid_type, description in options:
if choice == str(num):
return raid_type
self.console.print("❌ Opción inválida", style="red")
def _configure_raid(self, fs_type: FilesystemType, raid_type: RAIDType, disks: List[Disk]):
"""Configura el RAID con los parámetros seleccionados"""
self.console.print("🔨 Iniciando configuración del RAID...", style="bold blue")
# Verificar permisos de root - sin pedir confirmación, usar sudo directamente
if not self.system.is_root():
self.console.print("🔐 Se ejecutarán comandos con sudo según sea necesario", style="blue")
try:
# Paso 1: Limpieza de discos (ejecutar sin confirmaciones adicionales)
self._clean_disks(disks)
# Paso 2: Crear RAID según el tipo de filesystem
if fs_type == FilesystemType.ZFS:
self._create_zfs_raid(raid_type, disks)
else:
self._create_btrfs_raid(raid_type, disks)
# Paso 3: Configurar montaje automático
self._configure_auto_mount(fs_type, raid_type, disks)
# Paso 4: Mostrar resumen final
self._show_final_summary(fs_type, raid_type, disks)
except Exception as e:
self.console.print(f"❌ Error durante la configuración: {e}", style="red")
self.console.print("🔄 Revirtiendo cambios...", style="yellow")
# Aquí podríamos implementar rollback si es necesario
raise
def _calculate_raid_capacity(self, raid_type: RAIDType, disks: List[Disk]) -> Dict[str, str]:
"""Calcula la capacidad del RAID según tipo y discos"""
if not disks:
return {"total": "0 GB", "usable": "0 GB", "redundancy": "Ninguna"}
# Encontrar disco más pequeño
min_size = min(disk.size for disk in disks)
total_raw = sum(disk.size for disk in disks)
disk_count = len(disks)
# Calcular según tipo de RAID
if raid_type in [RAIDType.STRIPE, RAIDType.BTRFS_RAID0]:
usable_size = total_raw
redundancy = "Ninguna - Sin tolerancia a fallos"
efficiency = "100%"
elif raid_type in [RAIDType.MIRROR, RAIDType.BTRFS_RAID1]:
usable_size = min_size * (disk_count // 2)
redundancy = f"Tolerancia a {disk_count // 2} fallos"
efficiency = f"{((disk_count // 2) / disk_count) * 100:.1f}%"
elif raid_type == RAIDType.RAIDZ1:
usable_size = min_size * (disk_count - 1)
redundancy = "Tolerancia a 1 fallo"
efficiency = f"{((disk_count - 1) / disk_count) * 100:.1f}%"
elif raid_type == RAIDType.RAIDZ2:
usable_size = min_size * (disk_count - 2)
redundancy = "Tolerancia a 2 fallos"
efficiency = f"{((disk_count - 2) / disk_count) * 100:.1f}%"
elif raid_type == RAIDType.RAIDZ3:
usable_size = min_size * (disk_count - 3)
redundancy = "Tolerancia a 3 fallos"
efficiency = f"{((disk_count - 3) / disk_count) * 100:.1f}%"
elif raid_type == RAIDType.BTRFS_RAID10:
usable_size = min_size * (disk_count // 2)
redundancy = "Tolerancia múltiple (RAID 0+1)"
efficiency = "50%"
elif raid_type == RAIDType.BTRFS_RAID5:
usable_size = min_size * (disk_count - 1)
redundancy = "Tolerancia a 1 fallo (EXPERIMENTAL)"
efficiency = f"{((disk_count - 1) / disk_count) * 100:.1f}%"
elif raid_type == RAIDType.BTRFS_RAID6:
usable_size = min_size * (disk_count - 2)
redundancy = "Tolerancia a 2 fallos (EXPERIMENTAL)"
efficiency = f"{((disk_count - 2) / disk_count) * 100:.1f}%"
else:
usable_size = total_raw
redundancy = "Desconocida"
efficiency = "N/A"
# Convertir a formato legible
def size_to_human(size_bytes: int) -> str:
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if size_bytes < 1024:
return f"{size_bytes:.1f} {unit}"
size_bytes /= 1024
return f"{size_bytes:.1f} PB"
return {
"total": size_to_human(total_raw),
"usable": size_to_human(usable_size),
"redundancy": redundancy,
"efficiency": efficiency,
"min_disk": size_to_human(min_size)
}
def _show_raid_summary(self, fs_type: FilesystemType, raid_type: RAIDType,
disks: List[Disk], capacity_info: Dict[str, str]):
"""Muestra resumen de la configuración RAID"""
if RICH_AVAILABLE:
# Crear tabla de resumen
summary_table = Table(title="📋 Resumen de Configuración RAID", show_header=False)
summary_table.add_column("Concepto", style="bold cyan", width=20)
summary_table.add_column("Valor", style="white")
summary_table.add_row("Filesystem", fs_type.value.upper())
summary_table.add_row("Tipo RAID", raid_type.value)
summary_table.add_row("Número de discos", str(len(disks)))
summary_table.add_row("Capacidad total", capacity_info["total"])
summary_table.add_row("Capacidad utilizable", capacity_info["usable"])
summary_table.add_row("Eficiencia", capacity_info["efficiency"])
summary_table.add_row("Redundancia", capacity_info["redundancy"])
self.console.console.print(summary_table)
# Crear tabla de discos
disks_table = Table(title="💾 Discos Seleccionados", show_header=True)
disks_table.add_column("Disco", style="cyan")
disks_table.add_column("Tamaño", style="green")
disks_table.add_column("Modelo", style="yellow")
disks_table.add_column("Sectores", style="blue")
for disk in disks:
disks_table.add_row(
disk.name,
disk.size_human,
disk.model,
str(disk.sector_size)
)
self.console.console.print(disks_table)
else:
print("\n📋 Resumen de Configuración RAID:")
print(f" Filesystem: {fs_type.value.upper()}")
print(f" Tipo RAID: {raid_type.value}")
print(f" Discos: {len(disks)}")
print(f" Capacidad total: {capacity_info['total']}")
print(f" Capacidad utilizable: {capacity_info['usable']}")
print(f" Redundancia: {capacity_info['redundancy']}")
print("\n💾 Discos seleccionados:")
for disk in disks:
print(f" • {disk.name} - {disk.size_human} - {disk.model}")
# Advertencias específicas
warnings = []
if raid_type in [RAIDType.STRIPE, RAIDType.BTRFS_RAID0]:
warnings.append("⚠️ Sin redundancia: la pérdida de cualquier disco significa pérdida total de datos")
if fs_type == FilesystemType.BTRFS and "raid" in raid_type.value:
warnings.append("⚠️ BTRFS RAID puede requerir configuración adicional después de la creación")
if raid_type in [RAIDType.BTRFS_RAID5, RAIDType.BTRFS_RAID6]:
warnings.append("🚨 RAID 5/6 en BTRFS es EXPERIMENTAL - no recomendado para producción")
warnings.append("⚠️ Riesgo de corrupción de datos durante reconstrucción en RAID 5/6")
# Verificar si los discos tienen tamaños muy diferentes
sizes = [disk.size for disk in disks]
if max(sizes) > min(sizes) * 1.5: # Si hay más de 50% de diferencia
warnings.append("⚠️ Los discos tienen tamaños muy diferentes - se usará el tamaño del más pequeño")
if warnings:
self.console.print("\n🚨 Advertencias importantes:")
for warning in warnings:
self.console.print(f" {warning}", style="yellow")
def _clean_disks(self, disks: List[Disk]):
"""Limpia los discos antes de crear el RAID"""
self.console.print_panel("Analizando y limpiando discos seleccionados", title="🧹 Preparación")
for disk in disks:
self.console.print(f"🔍 Analizando disco {disk.name}...")
try:
# 1. Detectar qué tipo de configuración tiene el disco
disk_info = self._analyze_disk_configuration(disk.name)
# 2. Mostrar información encontrada automáticamente
if disk_info['has_data']:
self.console.print(f" 📋 Configuración detectada en {disk.name}:")
for info in disk_info['details']:
self.console.print(f" • {info}")
# 3. Limpiar automáticamente sin preguntar (como el script bash)
self.console.print(f" 🧹 Procediendo con limpieza automática...")
self._perform_disk_cleanup(disk.name, disk_info)
else:
self.console.print(f" ✅ Disco {disk.name} está limpio")
self.console.print(f"✅ Disco {disk.name} preparado correctamente", style="green")
except Exception as e:
self.console.print(f"⚠️ Advertencia preparando disco {disk.name}: {e}", style="yellow")
# Continuar con el siguiente disco en lugar de fallar completamente
self.console.print(f" 🔄 Continuando con limpieza básica...", style="blue")
try:
self._wipe_disk_completely(disk.name)
self.console.print(f"✅ Limpieza básica completada para {disk.name}", style="green")
except Exception as e2:
self.console.print(f"❌ Error crítico con disco {disk.name}: {e2}", style="red")
raise
def _analyze_disk_configuration(self, disk_name: str) -> Dict:
"""Analiza la configuración actual de un disco"""
info = {
'has_data': False,
'details': [],
'zfs_pools': [],
'btrfs_filesystems': [],
'mdadm_arrays': [],
'lvm_volumes': [],
'mounted_partitions': [],
'partitions': []
}
device_path = f"/dev/{disk_name}"
# 1. Verificar particiones
try:
result = self.system.run_command(['lsblk', '-n', '-o', 'NAME,MOUNTPOINT,FSTYPE', device_path])
for line in result.stdout.strip().split('\n'):
if line.strip():
parts = line.split()
if len(parts) >= 1:
part_name = parts[0].strip()
mountpoint = parts[1].strip() if len(parts) > 1 and parts[1] != '' else None
fstype = parts[2].strip() if len(parts) > 2 and parts[2] != '' else None
if part_name != disk_name: # Es una partición
info['partitions'].append(part_name)
info['has_data'] = True
if mountpoint and mountpoint != '':
info['mounted_partitions'].append(f"/dev/{part_name} en {mountpoint}")
info['details'].append(f"Partición {part_name} montada en {mountpoint}")
elif fstype:
info['details'].append(f"Partición {part_name} con filesystem {fstype}")
else:
info['details'].append(f"Partición {part_name}")
except subprocess.CalledProcessError:
pass
# 2. Verificar si forma parte de pools ZFS
try:
self.system.run_command(['which', 'zpool'])
result = self.system.run_command(['zpool', 'status'])
current_pool = None
for line in result.stdout.split('\n'):
line = line.strip()
if line.startswith('pool:'):
current_pool = line.split('pool:')[1].strip()
elif current_pool and (disk_name in line or any(f"{disk_name}p{i}" in line for i in range(1, 10))):
if current_pool not in info['zfs_pools']:
info['zfs_pools'].append(current_pool)
info['has_data'] = True
info['details'].append(f"Miembro del pool ZFS '{current_pool}'")
except subprocess.CalledProcessError:
pass
# 3. Verificar si forma parte de filesystems BTRFS
try:
self.system.run_command(['which', 'btrfs'])
result = self.system.run_command(['btrfs', 'filesystem', 'show'])
current_uuid = None
current_label = None
for line in result.stdout.split('\n'):
if 'uuid:' in line:
current_uuid = line.split('uuid:')[1].strip()
current_label = None
elif 'Label:' in line:
current_label = line.split('Label:')[1].strip().replace("'", "")
elif 'devid' in line and device_path in line:
fs_name = current_label if current_label else f"UUID {current_uuid[:8]}..."
info['btrfs_filesystems'].append(fs_name)
info['has_data'] = True
info['details'].append(f"Miembro del filesystem BTRFS '{fs_name}'")
except subprocess.CalledProcessError:
pass
# 4. Verificar arrays MDADM
try:
result = self.system.run_command(['cat', '/proc/mdstat'])
for line in result.stdout.split('\n'):
if 'active' in line and disk_name in line:
# Extraer nombre del array
array_name = line.split(':')[0].strip()
info['mdadm_arrays'].append(array_name)
info['has_data'] = True
info['details'].append(f"Miembro del array MDADM '{array_name}'")
except subprocess.CalledProcessError:
pass
# 5. Verificar Volume Groups LVM
try:
self.system.run_command(['which', 'pvs'])
result = self.system.run_command(['pvs', '--noheadings', '-o', 'pv_name,vg_name'])
for line in result.stdout.strip().split('\n'):
if line.strip() and device_path in line:
parts = line.split()
if len(parts) >= 2:
vg_name = parts[1]
info['lvm_volumes'].append(vg_name)
info['has_data'] = True
info['details'].append(f"Physical Volume en VG '{vg_name}'")
except subprocess.CalledProcessError:
pass
return info
def _perform_disk_cleanup(self, disk_name: str, disk_info: Dict):
"""Realiza la limpieza del disco según la configuración detectada"""
if not disk_info['has_data']:
self.console.print(f" ✅ Disco {disk_name} ya está limpio")
return
# 1. Desmontar particiones montadas
if disk_info['mounted_partitions']:
self.console.print(f" 📤 Desmontando particiones...")
for partition_info in disk_info['mounted_partitions']:
partition = partition_info.split(' en ')[0] # Extraer solo el dispositivo
self.console.print(f" • Desmontando {partition}")
# Intentar desmontaje normal
if not self.system.run_command_safe(['umount', partition]):
# Intentar desmontaje forzado
if self.system.run_command_safe(['umount', '-f', partition]):
self.console.print(f" ✅ Desmontado forzadamente {partition}")
else:
self.console.print(f" ⚠️ No se pudo desmontar {partition}, continuando...")
else:
self.console.print(f" ✅ Desmontado {partition}")
# 2. Destruir pools ZFS
if disk_info['zfs_pools']:
self.console.print(f" 🔷 Destruyendo pools ZFS...")
for pool in disk_info['zfs_pools']:
self.console.print(f" • Destruyendo pool {pool}")
# Exportar pool primero (sin falla si ya está exportado)
self.system.run_command_safe(['zpool', 'export', pool])
# Destruir pool con fuerza
if self.system.run_command_safe(['zpool', 'destroy', '-f', pool]):
self.console.print(f" ✅ Pool {pool} destruido")
else:
self.console.print(f" ⚠️ Error destruyendo pool {pool}, continuando...")
# 3. Limpiar filesystems BTRFS (desmontaje automático)
if disk_info['btrfs_filesystems']:
self.console.print(f" 🌿 Limpiando filesystems BTRFS...")
device_path = f"/dev/{disk_name}"
try:
# Buscar y desmontar puntos de montaje BTRFS
result = self.system.run_command(['findmnt', '-t', 'btrfs', '-n', '-o', 'TARGET,SOURCE'], check=False)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if line.strip() and device_path in line:
mountpoint = line.split()[0]
if self.system.run_command_safe(['umount', mountpoint]):
self.console.print(f" ✅ Desmontado BTRFS en {mountpoint}")
elif self.system.run_command_safe(['umount', '-f', mountpoint]):
self.console.print(f" ✅ Desmontado BTRFS forzadamente en {mountpoint}")
else:
self.console.print(f" ⚠️ No se pudo desmontar {mountpoint}")
except subprocess.CalledProcessError:
pass # No hay filesystems BTRFS montados
# 4. Parar arrays MDADM
if disk_info['mdadm_arrays']:
self.console.print(f" ⚡ Parando arrays MDADM...")
for array in disk_info['mdadm_arrays']:
self.console.print(f" • Parando array {array}")
if self.system.run_command_safe(['mdadm', '--stop', f"/dev/{array}"]):
self.console.print(f" ✅ Array {array} parado")
else:
self.console.print(f" ⚠️ Error parando array {array}, continuando...")
# 5. Remover de Volume Groups LVM
if disk_info['lvm_volumes']:
self.console.print(f" 💼 Removiendo de Volume Groups LVM...")
device_path = f"/dev/{disk_name}"
for vg in disk_info['lvm_volumes']:
# Desactivar VG primero
self.system.run_command_safe(['vgchange', '-an', vg])
# Remover PV del VG
self.system.run_command_safe(['vgreduce', vg, device_path])
# Remover PV completamente con fuerza
if self.system.run_command_safe(['pvremove', '-ff', device_path]):
self.console.print(f" ✅ PV removido del VG {vg}")
else:
self.console.print(f" ⚠️ Error removiendo PV de {vg}, continuando...")
# 6. Limpiar completamente el disco
self.console.print(f" 🧽 Limpiando todos los metadatos...")
self._wipe_disk_completely(disk_name)
def _wipe_disk_completely(self, disk_name: str):
"""Limpia completamente un disco de todos los metadatos"""
device_path = f"/dev/{disk_name}"
# 1. Limpiar etiquetas ZFS si es posible
try:
self.system.run_command(['which', 'zpool'], check=False)
if self.system.run_command_safe(['zpool', 'labelclear', '-f', device_path]):
self.console.print(f" ✅ Etiquetas ZFS limpiadas")
except subprocess.CalledProcessError:
pass # ZFS no disponible
# 2. Limpiar metadatos MDADM
if self.system.run_command_safe(['mdadm', '--zero-superblock', device_path]):
self.console.print(f" ✅ Metadatos MDADM limpiados")
# 3. Usar wipefs para limpiar todas las firmas de filesystem
self.console.print(f" • Limpiando firmas de filesystem...")
if self.system.run_command_safe(['wipefs', '-af', device_path]):
self.console.print(f" ✅ Firmas de filesystem limpiadas")
else:
self.console.print(f" ⚠️ Error con wipefs, usando método alternativo...")
# 4. Limpiar primeros sectores con dd (como en script bash)
self.console.print(f" • Limpiando primeros 100MB...")
if self.system.run_command_safe(['dd', 'if=/dev/zero', f'of={device_path}', 'bs=1M', 'count=100', 'conv=fsync']):
self.console.print(f" ✅ Primeros sectores limpiados")
else:
self.console.print(f" ⚠️ Error limpiando primeros sectores")
# 5. Limpiar últimos sectores (metadatos al final del disco)
try:
self.console.print(f" • Limpiando últimos sectores...")
# Obtener tamaño del disco en bytes
result = self.system.run_command(['lsblk', '-dpno', 'SIZE', device_path, '--bytes'], check=False)
if result.returncode == 0:
disk_size = int(result.stdout.strip())
if disk_size > 104857600: # Mayor a 100MB
seek_mb = (disk_size // 1048576) - 100 # 100MB antes del final
if self.system.run_command_safe(['dd', 'if=/dev/zero', f'of={device_path}',
'bs=1M', f'seek={seek_mb}', 'count=100', 'conv=fsync']):
self.console.print(f" ✅ Últimos sectores limpiados")
else:
self.console.print(f" ⚠️ Error limpiando últimos sectores")
except (subprocess.CalledProcessError, ValueError):
self.console.print(f" ⚠️ Error obteniendo tamaño del disco")
# 6. Limpiar tabla de particiones con sgdisk si está disponible
if self.system.run_command_safe(['sgdisk', '--zap-all', device_path]):
self.console.print(f" ✅ Tabla de particiones GPT limpiada")
else:
# sgdisk no disponible, usar dd básico para MBR
if self.system.run_command_safe(['dd', 'if=/dev/zero', f'of={device_path}', 'bs=512', 'count=1', 'conv=fsync']):
self.console.print(f" ✅ Tabla de particiones MBR limpiada")
# 7. Informar al kernel sobre los cambios y esperar
self.system.run_command_safe(['partprobe', device_path])
self.system.run_command_safe(['udevadm', 'settle'])
# 8. Esperar como en el script bash
import time
time.sleep(3)
def _unmount_disk(self, disk_name: str):
"""Desmonta todas las particiones de un disco"""
try:
# Obtener particiones montadas
result = self.system.run_command(['mount'])
mounted_partitions = []
for line in result.stdout.split('\n'):
if f'/dev/{disk_name}' in line:
parts = line.split()
if len(parts) > 0:
mounted_partitions.append(parts[0])
# Desmontar cada partición
for partition in mounted_partitions:
self.console.print(f" 📤 Desmontando {partition}...")
try:
self.system.run_command(['umount', partition])
except subprocess.CalledProcessError:
# Forzar desmontaje si es necesario
try:
self.system.run_command(['umount', '-f', partition])
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo desmontar {partition}", style="yellow")
except subprocess.CalledProcessError:
pass # No hay problema si no hay particiones montadas
def _destroy_zfs_pools_using_disk(self, disk_name: str):
"""Destruye pools ZFS que usen el disco especificado"""
try:
# Verificar si ZFS está disponible
self.system.run_command(['which', 'zpool'])
# Obtener lista de pools
result = self.system.run_command(['zpool', 'list', '-H', '-o', 'name'])
pools = [line.strip() for line in result.stdout.strip().split('\n') if line.strip()]
pools_to_destroy = []
for pool in pools:
try:
# Verificar si el disco está en el pool
status_result = self.system.run_command(['zpool', 'status', pool])
# Buscar el disco en la salida del status
for line in status_result.stdout.split('\n'):
if disk_name in line and ('/dev/' in line or line.strip().startswith(disk_name)):
pools_to_destroy.append(pool)
break
except subprocess.CalledProcessError:
continue
# Destruir pools que usen este disco
for pool in pools_to_destroy:
self.console.print(f" 🗑️ Destruyendo pool ZFS: {pool}")
if self.console.confirm(f"¿Confirmar destrucción del pool '{pool}'?", default=False):
try:
# Primero intentar exportar el pool
try:
self.system.run_command(['zpool', 'export', pool])
self.console.print(f" 📤 Pool {pool} exportado", style="blue")
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo exportar {pool}, forzando destrucción", style="yellow")
# Luego destruir
self.system.run_command(['zpool', 'destroy', '-f', pool])
self.console.print(f" ✅ Pool {pool} destruido", style="green")
except subprocess.CalledProcessError as e:
# Intentar forzar la destrucción más agresivamente
self.console.print(f" ⚠️ Error destruyendo {pool}, intentando limpieza forzada", style="yellow")
try:
# Forzar unmount y destruir
self.system.run_command(['zfs', 'unmount', '-f', pool])
self.system.run_command(['zpool', 'destroy', '-f', pool])
self.console.print(f" ✅ Pool {pool} destruido (forzado)", style="green")
except subprocess.CalledProcessError:
self.console.print(f" ❌ No se pudo destruir el pool {pool}. Continúa con limpieza manual.", style="red")
self.console.print(f" 💡 Comando manual: sudo zpool destroy -f {pool}", style="blue")
else:
self.console.print("❌ Operación cancelada por el usuario", style="red")
raise Exception("Operación cancelada")
except subprocess.CalledProcessError:
# ZFS no disponible, continuar
pass
def _wipe_disk_metadata(self, disk_name: str):
"""Limpia todos los metadatos del disco"""
self.console.print(f" 🧽 Limpiando metadatos de /dev/{disk_name}...")
try:
# Primero intentar con dd para limpiar los primeros sectores
try:
self.system.run_command(['dd', 'if=/dev/zero', f'of=/dev/{disk_name}', 'bs=1M', 'count=100'])
self.console.print(f" ✨ Primeros sectores limpiados con dd", style="blue")
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudieron limpiar sectores con dd", style="yellow")
# Usar wipefs para limpiar metadatos
try:
self.system.run_command(['wipefs', '-af', f'/dev/{disk_name}'])
self.console.print(f" ✨ Metadatos limpiados con wipefs", style="green")
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ wipefs falló, intentando limpieza manual", style="yellow")
# Intentar limpiar manualmente con dd
try:
# Limpiar el principio del disco
self.system.run_command(['dd', 'if=/dev/zero', f'of=/dev/{disk_name}', 'bs=512', 'count=2048'])
# Obtener tamaño del disco y limpiar el final
result = self.system.run_command(['blockdev', '--getsz', f'/dev/{disk_name}'])
sectors = int(result.stdout.strip())
end_sector = sectors - 2048
self.system.run_command(['dd', 'if=/dev/zero', f'of=/dev/{disk_name}',
f'seek={end_sector}', 'bs=512', 'count=2048'])
self.console.print(f" ✨ Limpieza manual completada", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error en limpieza manual: {e}", style="red")
# Continuar de todas formas
# Limpiar también particiones existentes si existen
try:
result = self.system.run_command(['lsblk', '-ln', '-o', 'NAME', f'/dev/{disk_name}'])
partitions = result.stdout.strip().split('\n')[1:] # Skip el disco principal
for partition in partitions:
if partition.strip():
partition_name = partition.strip()
try:
self.system.run_command(['wipefs', '-af', f'/dev/{partition_name}'])
self.console.print(f" ✨ Partición {partition_name} limpiada", style="blue")
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo limpiar partición {partition_name}", style="yellow")
except subprocess.CalledProcessError:
pass # No hay particiones para limpiar
# Notificar al kernel sobre los cambios
try:
self.system.run_command(['partprobe', f'/dev/{disk_name}'])
self.console.print(f" 🔄 Kernel notificado de cambios", style="blue")
except subprocess.CalledProcessError:
pass
self.console.print(f" ✅ Disco /dev/{disk_name} preparado", style="green")
except Exception as e:
self.console.print(f" ⚠️ Algunos errores durante la limpieza, pero continuando: {e}", style="yellow")
# No lanzar excepción, continuar con el proceso
def _create_zfs_raid(self, raid_type: RAIDType, disks: List[Disk]):
"""Crea un RAID ZFS"""
self.console.print_panel("Configurando ZFS RAID", title="🔷 ZFS")
# Verificar que ZFS esté disponible
try:
self.system.run_command(['which', 'zpool'])
except subprocess.CalledProcessError:
self.console.print("❌ ZFS no está disponible en el sistema", style="red")
raise Exception("ZFS no disponible")
# Cargar módulo ZFS si no está cargado
self._ensure_zfs_module_loaded()
# Obtener configuración del usuario
pool_name = self._get_zfs_pool_name()
# Configurar ARC (cache)
arc_size = self._configure_zfs_arc()
# Detectar ashift óptimo
ashift = self._detect_optimal_ashift(disks)
# Crear pool ZFS (SIN punto de montaje específico - ZFS lo maneja automáticamente)
self._create_zfs_pool(pool_name, raid_type, disks, ashift)
# Configurar propiedades del pool
self._configure_zfs_properties(pool_name, arc_size)
self.console.print(f"✅ Pool ZFS '{pool_name}' creado exitosamente", style="green")
# Configurar cache devices (SLOG/L2ARC) si hay dispositivos NVMe/SSD disponibles
self._configure_cache_devices(pool_name, disks)
def _ensure_zfs_module_loaded(self):
"""Asegura que el módulo ZFS esté cargado"""
try:
result = self.system.run_command(['lsmod'])
if 'zfs' not in result.stdout:
self.console.print("📦 Cargando módulo ZFS...")
self.system.run_command(['modprobe', 'zfs'])
# Esperar un poco y verificar
import time
time.sleep(2)
result = self.system.run_command(['lsmod'])
if 'zfs' not in result.stdout:
raise Exception("No se pudo cargar el módulo ZFS")
self.console.print("✅ Módulo ZFS cargado", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error cargando módulo ZFS: {e}", style="red")
raise
def _get_zfs_pool_name(self) -> str:
"""Obtiene el nombre del pool ZFS del usuario"""
while True:
pool_name = self.console.prompt("📝 Nombre del pool ZFS", "storage").strip()
if not pool_name:
self.console.print("❌ El nombre no puede estar vacío", style="red")
continue
# Verificar que el nombre sea válido
if not pool_name.replace('_', '').replace('-', '').isalnum():
self.console.print("❌ El nombre solo puede contener letras, números, _ y -", style="red")
continue
# Verificar que no exista ya
try:
result = self.system.run_command(['zpool', 'list', pool_name])
self.console.print(f"❌ El pool '{pool_name}' ya existe", style="red")
continue
except subprocess.CalledProcessError:
# Pool no existe, perfecto
return pool_name
def _get_mount_point(self, default_path: str) -> str:
"""Obtiene el punto de montaje del usuario"""
mount_point = self.console.prompt("📁 Punto de montaje", default_path).strip()
if not mount_point.startswith('/'):
mount_point = f"/{mount_point}"
# Crear directorio si no existe
if not os.path.exists(mount_point):
if self.console.confirm(f"El directorio {mount_point} no existe. ¿Crearlo?", default=True):
try:
# Intentar crear sin sudo primero
os.makedirs(mount_point, exist_ok=True)
self.console.print(f"✅ Directorio {mount_point} creado", style="green")
except OSError:
# Si falla, usar sudo automáticamente
try:
self.console.print(f"🔐 Creando directorio con permisos elevados...")
# SystemManager ahora maneja sudo automáticamente
self.system.run_command(['mkdir', '-p', mount_point])
self.console.print(f"✅ Directorio {mount_point} creado", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error creando directorio: {e}", style="red")
raise Exception(f"No se pudo crear el directorio {mount_point}")
else:
raise Exception("Se necesita un punto de montaje válido")
return mount_point
def _configure_zfs_arc(self) -> int:
"""Configura el tamaño del ARC de ZFS"""
try:
# Obtener RAM del sistema
with open('/proc/meminfo', 'r') as f:
for line in f:
if line.startswith('MemTotal:'):
ram_kb = int(line.split()[1])
ram_gb = ram_kb // (1024 * 1024)
break
# Calcular ARC recomendado (25% de RAM)
recommended_arc = max(1, ram_gb // 4)
self.console.print(f"💾 RAM del sistema: {ram_gb}GB")
self.console.print(f"📊 ARC recomendado: {recommended_arc}GB")
while True:
arc_input = self.console.prompt(f"🎯 Tamaño del ARC en GB", str(recommended_arc)).strip()
try:
arc_size = int(arc_input)
if arc_size < 1:
self.console.print("❌ El ARC debe ser al menos 1GB", style="red")
continue
if arc_size > ram_gb:
self.console.print(f"❌ El ARC no puede ser mayor que la RAM ({ram_gb}GB)", style="red")
continue
return arc_size
except ValueError:
self.console.print("❌ Ingresa un número válido", style="red")
except Exception as e:
self.console.print(f"⚠️ Error detectando RAM, usando 1GB para ARC: {e}", style="yellow")
return 1
def _detect_optimal_ashift(self, disks: List[Disk]) -> int:
"""Detecta el ashift óptimo para ZFS"""
max_sector_size = 512
has_4k_sectors = False
for disk in disks:
if disk.sector_size > max_sector_size:
max_sector_size = disk.sector_size
if disk.sector_size == 4096:
has_4k_sectors = True
# Estrategia de ashift optimizada para compatibilidad
if max_sector_size <= 512 and not has_4k_sectors:
ashift = 12 # Compatibilidad con cache devices SSD
self.console.print("🔧 Usando ashift=12 para compatibilidad con cache devices", style="blue")
else:
# Calcular ashift basado en el tamaño de sector
import math
ashift = int(math.log2(max_sector_size)) if max_sector_size >= 512 else 12
if ashift < 9:
ashift = 12 # Mínimo seguro
self.console.print(f"🔧 Ashift detectado: {ashift} (sector size: {max_sector_size})", style="blue")
return ashift
def _create_zfs_pool(self, pool_name: str, raid_type: RAIDType, disks: List[Disk], ashift: int):
"""Crea el pool ZFS con configuración automática de montaje"""
self.console.print(f"🔨 Creando pool ZFS '{pool_name}'...")
# Preguntar sobre mountpoint (opcional)
use_custom_mountpoint = self.console.confirm(
"¿Configurar punto de montaje personalizado? (No recomendado - ZFS lo maneja automáticamente)",
default=False
)
# Construir comando base
cmd = ['zpool', 'create', '-f', '-o', f'ashift={ashift}']
# Configurar mountpoint
if use_custom_mountpoint:
custom_mount = self.console.prompt("📁 Punto de montaje personalizado", f"/{pool_name}")
cmd.extend(['-m', custom_mount])
self.console.print(f"📁 Usando punto de montaje personalizado: {custom_mount}")
else:
# Dejar que ZFS maneje automáticamente (mountpoint por defecto será /{pool_name})
self.console.print(f"📁 ZFS configurará automáticamente el montaje en: /{pool_name}")
# Añadir nombre del pool
cmd.append(pool_name)
# Añadir configuración RAID
if raid_type == RAIDType.STRIPE:
# Stripe: solo añadir los discos directamente
pass
elif raid_type == RAIDType.MIRROR:
cmd.append('mirror')
elif raid_type == RAIDType.RAIDZ1:
cmd.append('raidz1')
elif raid_type == RAIDType.RAIDZ2:
cmd.append('raidz2')
elif raid_type == RAIDType.RAIDZ3:
cmd.append('raidz3')
# Añadir discos
for disk in disks:
cmd.append(f'/dev/{disk.name}')
try:
self.console.print(f"📝 Ejecutando: {' '.join(cmd)}")
self.system.run_command(cmd)
self.console.print(f"✅ Pool '{pool_name}' creado exitosamente", style="green")
# Mostrar información del pool creado
self._show_created_pool_info(pool_name)
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error creando pool: {e}", style="red")
raise
def _show_created_pool_info(self, pool_name: str):
"""Muestra información del pool recién creado"""
try:
# Obtener información básica del pool
result = self.system.run_command(['zfs', 'get', 'mounted,mountpoint', pool_name])
self.console.print(f"\n📋 Información del pool '{pool_name}':")
for line in result.stdout.strip().split('\n')[1:]: # Skip header
if line.strip():
parts = line.split()
if len(parts) >= 4:
property_name = parts[1]
value = parts[2]
if property_name == 'mountpoint':
self.console.print(f" 📁 Punto de montaje: {value}")
elif property_name == 'mounted':
mounted_status = "✅ Montado" if value == 'yes' else "❌ No montado"
self.console.print(f" 🔗 Estado: {mounted_status}")
# Mostrar comando útil
self.console.print(f"\n💡 Comandos útiles:")
self.console.print(f" • Ver estado: zpool status {pool_name}")
self.console.print(f" • Ver propiedades: zfs get all {pool_name}")
self.console.print(f" • Crear dataset: zfs create {pool_name}/mi_dataset")
except subprocess.CalledProcessError:
self.console.print(" ⚠️ No se pudo obtener información adicional del pool", style="yellow")
def _configure_zfs_properties(self, pool_name: str, arc_size: int):
"""Configura propiedades optimizadas del pool ZFS"""
self.console.print("⚙️ Configurando propiedades optimizadas del pool...", style="blue")
# Propiedades básicas de rendimiento
basic_properties = [
('compression', 'lz4', 'Compresión LZ4 (rápida y eficiente)'),
('atime', 'off', 'Desactivar atime para mejor rendimiento'),
('relatime', 'on', 'Activar relatime (compromiso rendimiento/compatibilidad)'),
('xattr', 'sa', 'Atributos extendidos en system attributes'),
('recordsize', '128K', 'Tamaño de registro optimizado para uso general'),
('logbias', 'latency', 'Optimizar para latencia en lugar de throughput'),
('sync', 'standard', 'Comportamiento de sync estándar'),
('dedup', 'off', 'Desactivar deduplicación (consume mucha RAM)'),
('dnodesize', 'auto', 'Tamaño de dnode automático')
]
# Aplicar propiedades básicas
for prop, value, description in basic_properties:
if self._set_zfs_property(pool_name, prop, value):
self.console.print(f" ✅ {description}")
else:
self.console.print(f" ⚠️ {description} - no aplicada", style="yellow")
# Configuraciones específicas según el tipo de uso
self.console.print("\n🎯 Configuraciones específicas por tipo de uso:")
# Preguntar tipo de uso previsto
use_case = self._get_zfs_use_case()
if use_case == "storage":
self._configure_zfs_for_storage(pool_name)
elif use_case == "database":
self._configure_zfs_for_database(pool_name)
elif use_case == "media":
self._configure_zfs_for_media(pool_name)
elif use_case == "mixed":
self._configure_zfs_for_mixed(pool_name)
# Configurar ARC del sistema
self._configure_zfs_arc_system(arc_size)
# Configuraciones adicionales de pool
self._configure_zfs_advanced_properties(pool_name)
# Crear datasets si el usuario lo desea
self._create_zfs_datasets(pool_name)
self.console.print("✅ Propiedades ZFS configuradas", style="green")
def _set_zfs_property(self, pool_name: str, prop: str, value: str) -> bool:
"""Establece una propiedad ZFS y maneja errores"""
try:
self.system.run_command(['zfs', 'set', f'{prop}={value}', pool_name])
return True
except subprocess.CalledProcessError:
return False
def _get_zfs_use_case(self) -> str:
"""Obtiene el caso de uso previsto para el pool ZFS"""
self.console.print("\n📊 ¿Cuál será el uso principal de este pool?")
self.console.print(" 1. Almacenamiento general (documentos, backups)")
self.console.print(" 2. Base de datos / aplicaciones")
self.console.print(" 3. Media server (vídeos, música, fotos)")
self.console.print(" 4. Uso mixto")
while True:
choice = self.console.prompt("👉 Selecciona el tipo de uso", "1")
if choice == "1":
return "storage"
elif choice == "2":
return "database"
elif choice == "3":
return "media"
elif choice == "4":
return "mixed"
else:
self.console.print("❌ Opción inválida", style="red")
def _configure_zfs_for_storage(self, pool_name: str):
"""Configuración optimizada para almacenamiento general"""
self.console.print(" 📦 Optimizando para almacenamiento general...")
storage_properties = [
('recordsize', '1M', 'Registros grandes para archivos grandes'),
('compression', 'zstd', 'Compresión alta para mejor ratio'),
('checksum', 'sha256', 'Checksums robustos'),
('redundant_metadata', 'most', 'Metadatos redundantes')
]
for prop, value, description in storage_properties:
if self._set_zfs_property(pool_name, prop, value):
self.console.print(f" ✅ {description}")
def _configure_zfs_for_database(self, pool_name: str):
"""Configuración optimizada para bases de datos"""
self.console.print(" 🗄️ Optimizando para bases de datos...")
db_properties = [
('recordsize', '8K', 'Registros pequeños para I/O de BD'),
('logbias', 'throughput', 'Optimizar para throughput'),
('sync', 'always', 'Sync inmediato para consistencia'),
('primarycache', 'metadata', 'Cache solo metadatos'),
('redundant_metadata', 'all', 'Todos los metadatos redundantes')
]
for prop, value, description in db_properties:
if self._set_zfs_property(pool_name, prop, value):
self.console.print(f" ✅ {description}")
def _configure_zfs_for_media(self, pool_name: str):
"""Configuración optimizada para media server"""
self.console.print(" 🎬 Optimizando para media server...")
media_properties = [
('recordsize', '1M', 'Registros grandes para streaming'),
('compression', 'lz4', 'Compresión rápida'),
('atime', 'off', 'Sin atime para mejor rendimiento'),
('logbias', 'latency', 'Baja latencia para streaming'),
('primarycache', 'all', 'Cache completo para acceso frecuente')
]
for prop, value, description in media_properties:
if self._set_zfs_property(pool_name, prop, value):
self.console.print(f" ✅ {description}")
def _configure_zfs_for_mixed(self, pool_name: str):
"""Configuración balanceada para uso mixto"""
self.console.print(" ⚖️ Configuración balanceada para uso mixto...")
mixed_properties = [
('recordsize', '128K', 'Registro balanceado'),
('compression', 'lz4', 'Compresión eficiente'),
('logbias', 'latency', 'Balance latencia/throughput'),
('primarycache', 'all', 'Cache completo'),
('redundant_metadata', 'most', 'Metadatos importantes redundantes')
]
for prop, value, description in mixed_properties:
if self._set_zfs_property(pool_name, prop, value):
self.console.print(f" ✅ {description}")
def _configure_zfs_arc_system(self, arc_size: int):
"""Configura el ARC del sistema ZFS"""
self.console.print("\n💾 Configurando ZFS ARC del sistema...")
try:
# Crear directorio de configuración si no existe
config_dir = "/etc/modprobe.d"
if not os.path.exists(config_dir):
if self.system.run_command_safe(['mkdir', '-p', config_dir]):
self.console.print(f" ✅ Directorio {config_dir} creado")
# Configurar ARC
arc_bytes = arc_size * 1024 * 1024 * 1024
arc_min = arc_bytes // 4 # Mínimo 25% del máximo
zfs_conf_content = f"""# ZFS ARC Configuration - Configurado por raid_manager.py
# Tamaño máximo del ARC: {arc_size}GB
options zfs zfs_arc_max={arc_bytes}
# Tamaño mínimo del ARC: {arc_size//4}GB
options zfs zfs_arc_min={arc_min}
# Configuración de L2ARC
options zfs l2arc_write_max=134217728
options zfs l2arc_headroom=4
"""
# Escribir configuración usando sudo
config_file = '/etc/modprobe.d/zfs.conf'
temp_file = '/tmp/zfs_config.tmp'
# Escribir a archivo temporal primero
try:
with open(temp_file, 'w') as f:
f.write(zfs_conf_content)
# Mover archivo con sudo
if self.system.run_command_safe(['sudo', 'cp', temp_file, config_file]):
self.console.print(f" ✅ ARC máximo: {arc_size}GB")
self.console.print(f" ✅ ARC mínimo: {arc_size//4}GB")
self.console.print(" ✅ Configuración L2ARC optimizada")
# Limpiar archivo temporal
try:
os.remove(temp_file)
except:
pass
# Aplicar configuración actual (si es posible)
try:
current_max = f"/sys/module/zfs/parameters/zfs_arc_max"
if os.path.exists(current_max):
# Usar echo con sudo para escribir al sistema
if self.system.run_command_safe(['sudo', 'bash', '-c', f'echo {arc_bytes} > {current_max}']):
self.console.print(" ✅ ARC aplicado inmediatamente")
else:
self.console.print(" 💡 ARC se aplicará en el próximo reinicio")
else:
self.console.print(" 💡 ARC se aplicará cuando se cargue el módulo ZFS")
except Exception:
self.console.print(" 💡 ARC se aplicará en el próximo reinicio")
else:
self.console.print(" ⚠️ No se pudo escribir configuración ARC", style="yellow")
self.console.print(" 💡 Puedes configurar manualmente editando /etc/modprobe.d/zfs.conf", style="blue")
except Exception as e:
self.console.print(f" ⚠️ Error escribiendo configuración temporal: {e}", style="yellow")
except Exception as e:
self.console.print(f" ⚠️ Error configurando ARC: {e}", style="yellow")
self.console.print(" 💡 Puedes configurar manualmente editando /etc/modprobe.d/zfs.conf", style="blue")
def _configure_zfs_advanced_properties(self, pool_name: str):
"""Configuraciones avanzadas adicionales de ZFS"""
self.console.print("\n🔧 Configuraciones avanzadas del pool...")
# Solo configuraciones que aplican al pool entero
self.console.print(" 💡 Las cuotas y snapshots se configurarán por dataset individual")
# Configuración de cache devices (futuro)
self.console.print(" 💡 Después puedes agregar cache devices con: zpool add <pool> cache <device>")
def _create_zfs_datasets(self, pool_name: str):
"""Crea datasets ZFS según las necesidades del usuario"""
self.console.print("\n📁 Configuración de Datasets ZFS")
if not self.console.confirm("¿Crear datasets organizados?", default=True):
return
while True:
self.console.print("\n🎯 Opciones de creación de datasets:")
self.console.print(" 1. Configuración rápida recomendada")
self.console.print(" 2. Crear datasets personalizados")
self.console.print(" 3. No crear datasets ahora")
choice = self.console.prompt("👉 Selecciona opción", "1")
if choice == "1":
# Mostrar explicación y pedir confirmación
if self._explain_recommended_setup(pool_name):
self._create_recommended_datasets(pool_name)
break # Salir del bucle después de crear
# Si no confirma, volver al menú de opciones
elif choice == "2":
self._create_custom_datasets(pool_name)
break # Salir del bucle después de crear
elif choice == "3":
self.console.print(" ⏭️ Omitiendo creación de datasets")
break # Salir sin crear nada
else:
self.console.print("❌ Opción inválida", style="red")
def _explain_recommended_setup(self, pool_name: str):
"""Explica la configuración rápida recomendada al usuario"""
self.console.print("\n📋 Configuración Rápida Recomendada:")
self.console.print(f" Se crearán 5 datasets organizados en el pool '{pool_name}':")
self.console.print("\n 📁 Datasets que se crearán:")
self.console.print(" ┌─ 📦 data/ → Datos generales del usuario")
self.console.print(" ├─ 🎬 media/ → Videos, música, fotos (optimizado para streaming)")
self.console.print(" ├─ 💾 backups/ → Respaldos (máxima compresión)")
self.console.print(" ├─ 🌐 shares/ → Carpetas compartidas en red")
self.console.print(" └─ ⚙️ apps/ → Datos de aplicaciones y servicios")
self.console.print("\n 🔧 Configuraciones específicas por dataset:")
self.console.print(" • data/ → Compresión LZ4, recordsize 128K (uso general)")
self.console.print(" • media/ → Compresión LZ4, recordsize 1M (archivos grandes)")
self.console.print(" • backups/ → Compresión ZSTD, recordsize 1M (máximo ratio)")
self.console.print(" • shares/ → Compresión LZ4, recordsize 128K (red)")
self.console.print(" • apps/ → Compresión LZ4, recordsize 64K (aplicaciones)")
self.console.print("\n 📍 Puntos de montaje:")
self.console.print(f" • /{pool_name}/data → Datos del usuario")
self.console.print(f" • /{pool_name}/media → Biblioteca multimedia")
self.console.print(f" • /{pool_name}/backups → Respaldos importantes")
self.console.print(f" • /{pool_name}/shares → Recursos compartidos")
self.console.print(f" • /{pool_name}/apps → Datos de aplicaciones")
self.console.print("\n ✅ Beneficios de esta estructura:")
self.console.print(" • Organización clara y escalable")
self.console.print(" • Configuraciones optimizadas por tipo de contenido")
self.console.print(" • Gestión independiente por dataset")
self.console.print(" • Fácil gestión de permisos y políticas")
if not self.console.confirm("\n¿Proceder con esta configuración básica?", default=True):
self.console.print(" ❌ Configuración cancelada")
return False
return True
def _create_recommended_datasets(self, pool_name: str):
"""Crea una estructura de datasets recomendada"""
self.console.print("\n🏗️ Creando estructura de datasets recomendada...")
# Preguntar sobre configuraciones adicionales una sola vez
self.console.print("\n⚙️ Configuraciones adicionales para todos los datasets:")
# Explicar qué son los snapshots antes de preguntar
self.console.print("📸 Snapshots automáticos:")
self.console.print(" • Crean copias de seguridad automáticas de tus datos")
self.console.print(" • Permiten recuperar archivos borrados o versiones anteriores")
self.console.print(" • Podrás elegir la frecuencia (diario, semanal, mensual, etc.)")
enable_snapshots = self.console.confirm("¿Habilitar snapshots automáticos para los datasets?", default=True)
enable_quotas = self.console.confirm("¿Configurar cuotas de espacio para los datasets?", default=False)
# Datasets recomendados con configuraciones específicas
recommended_datasets = [
{
'name': 'data',
'description': 'Datos generales del usuario',
'properties': {
'compression': 'lz4',
'atime': 'off',
'recordsize': '128K'
},
'suggested_quota': '500G'
},
{
'name': 'media',
'description': 'Archivos multimedia (videos, música, fotos)',
'properties': {
'compression': 'lz4',
'atime': 'off',
'recordsize': '1M'
},
'suggested_quota': '2T'
},
{
'name': 'backups',
'description': 'Respaldos y archivos importantes',
'properties': {
'compression': 'zstd',
'atime': 'off',
'recordsize': '1M'
},
'suggested_quota': '1T'
},
{
'name': 'shares',
'description': 'Carpetas compartidas en red',
'properties': {
'compression': 'lz4',
'atime': 'off',
'recordsize': '128K'
},
'suggested_quota': '200G'
},
{
'name': 'apps',
'description': 'Datos de aplicaciones y servicios',
'properties': {
'compression': 'lz4',
'atime': 'off',
'recordsize': '64K'
},
'suggested_quota': '100G'
}
]
created_datasets = []
for dataset_config in recommended_datasets:
dataset_full_name = f"{pool_name}/{dataset_config['name']}"
try:
# Crear dataset
self.console.print(f" 📁 Creando dataset: {dataset_full_name}")
self.system.run_command(['zfs', 'create', dataset_full_name])
# Aplicar propiedades específicas
for prop, value in dataset_config['properties'].items():
try:
self.system.run_command(['zfs', 'set', f'{prop}={value}', dataset_full_name])
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo configurar {prop}={value}", style="yellow")
# Configurar automontaje para este dataset (ZFS maneja automáticamente)
try:
self.system.run_command(['zfs', 'set', 'canmount=on', dataset_full_name])
# ZFS automáticamente usa /{pool_name}/{dataset_name} como mountpoint por defecto
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo configurar automontaje", style="yellow")
# Configurar snapshots solo si el usuario lo pidió
if enable_snapshots:
self._configure_dataset_snapshots(dataset_full_name)
# Configurar cuota solo si el usuario lo pidió
if enable_quotas:
self._configure_dataset_quota(dataset_full_name, dataset_config['suggested_quota'])
created_datasets.append({
'name': dataset_full_name,
'description': dataset_config['description'],
'mountpoint': f"/{dataset_full_name}"
})
self.console.print(f" ✅ Dataset creado: {dataset_config['description']}", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error creando dataset {dataset_config['name']}: {e}", style="red")
# Mostrar resumen de datasets creados
if created_datasets:
self._show_datasets_summary(created_datasets)
def _create_custom_datasets(self, pool_name: str):
"""Permite al usuario crear datasets personalizados"""
self.console.print("\n🛠️ Creación de datasets personalizados")
datasets_created = []
while True:
self.console.print(f"\n📁 Crear nuevo dataset en pool '{pool_name}'")
# Nombre del dataset
dataset_name = self.console.prompt("📝 Nombre del dataset", "").strip()
if not dataset_name:
break
# Validar nombre
if not self._validate_dataset_name(dataset_name):
self.console.print("❌ Nombre inválido. Use solo letras, números, - y _", style="red")
continue
dataset_full_name = f"{pool_name}/{dataset_name}"
# Verificar si ya existe
try:
result = self.system.run_command(['zfs', 'list', dataset_full_name], check=False)
if result.returncode == 0:
self.console.print(f"❌ El dataset '{dataset_full_name}' ya existe", style="red")
continue
except:
pass
# Descripción opcional
description = self.console.prompt("📋 Descripción (opcional)", "").strip()
# Configuraciones específicas
self.console.print("\n⚙️ Configuraciones del dataset:")
# Compresión
compression_options = {
'1': 'off',
'2': 'lz4',
'3': 'zstd',
'4': 'gzip'
}
self.console.print(" Compresión:")
self.console.print(" 1. Sin compresión")
self.console.print(" 2. LZ4 (rápida)")
self.console.print(" 3. ZSTD (alta ratio)")
self.console.print(" 4. GZIP (máxima ratio)")
comp_choice = self.console.prompt(" 👉 Compresión", "2")
compression = compression_options.get(comp_choice, 'lz4')
# Recordsize
recordsize_options = {
'1': '16K',
'2': '64K',
'3': '128K',
'4': '1M'
}
self.console.print("\n Tamaño de registro:")
self.console.print(" 1. 16K (bases de datos)")
self.console.print(" 2. 64K (aplicaciones)")
self.console.print(" 3. 128K (uso general)")
self.console.print(" 4. 1M (archivos grandes)")
rec_choice = self.console.prompt(" 👉 Recordsize", "3")
recordsize = recordsize_options.get(rec_choice, '128K')
# Atime
disable_atime = self.console.confirm(" ¿Desactivar atime? (recomendado para rendimiento)", default=True)
atime = 'off' if disable_atime else 'on'
# Automontaje
enable_automount = self.console.confirm(" ¿Habilitar automontaje?", default=True)
# Snapshots
self.console.print("\n 📸 Snapshots automáticos (copias de seguridad automáticas):")
enable_snapshots = self.console.confirm(" ¿Habilitar snapshots automáticos?", default=True)
# Cuota
configure_quota = self.console.confirm(" ¿Configurar cuota de espacio?", default=False)
quota_size = None
if configure_quota:
quota_size = self.console.prompt(" 💾 Cuota (ej: 100G, 1T)", "").strip()
# Crear dataset
try:
self.console.print(f"\n🔨 Creando dataset '{dataset_full_name}'...")
# Crear con propiedades
cmd = ['zfs', 'create']
cmd.extend(['-o', f'compression={compression}'])
cmd.extend(['-o', f'recordsize={recordsize}'])
cmd.extend(['-o', f'atime={atime}'])
if enable_automount:
cmd.extend(['-o', 'canmount=on'])
# ZFS automáticamente usa /{pool_name}/{dataset_name} como mountpoint por defecto
else:
cmd.extend(['-o', 'canmount=off'])
cmd.append(dataset_full_name)
self.system.run_command(cmd)
# Configurar snapshots si está habilitado
if enable_snapshots:
self._configure_dataset_snapshots(dataset_full_name)
# Configurar cuota si se especificó
if quota_size:
self._configure_dataset_quota(dataset_full_name, quota_size)
datasets_created.append({
'name': dataset_full_name,
'description': description or "Dataset personalizado",
'mountpoint': f"/{dataset_full_name}",
'compression': compression,
'recordsize': recordsize,
'atime': atime
})
self.console.print(f"✅ Dataset '{dataset_name}' creado exitosamente", style="green")
# Preguntar si crear otro
if not self.console.confirm("¿Crear otro dataset?", default=False):
break
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error creando dataset: {e}", style="red")
# Mostrar resumen
if datasets_created:
self._show_datasets_summary(datasets_created)
def _validate_dataset_name(self, name: str) -> bool:
"""Valida que el nombre del dataset sea válido"""
if not name:
return False
# Solo letras, números, guiones y guiones bajos
import re
return bool(re.match(r'^[a-zA-Z0-9_-]+$', name))
def _show_datasets_summary(self, datasets: list):
"""Muestra un resumen de los datasets creados"""
self.console.print("\n📊 Resumen de Datasets Creados:")
if RICH_AVAILABLE:
from rich.table import Table
table = Table(title="📁 Datasets ZFS Creados")
table.add_column("Dataset", style="cyan")
table.add_column("Punto de Montaje", style="green")
table.add_column("Descripción", style="yellow")
for dataset in datasets:
table.add_row(
dataset['name'],
dataset['mountpoint'],
dataset['description']
)
self.console.console.print(table)
else:
for dataset in datasets:
print(f" 📁 {dataset['name']}")
print(f" 📍 Montaje: {dataset['mountpoint']}")
print(f" 📝 Descripción: {dataset['description']}")
print()
self.console.print("\n💡 Comandos útiles para datasets:")
self.console.print(" • Listar datasets: zfs list")
self.console.print(" • Ver propiedades: zfs get all <dataset>")
self.console.print(" • Crear snapshot: zfs snapshot <dataset>@<nombre>")
self.console.print(" • Configurar cuota: zfs set quota=<tamaño> <dataset>")
def _configure_dataset_snapshots(self, dataset_name: str):
"""Configura snapshots automáticos para un dataset específico con selección de frecuencia"""
self.console.print(f" 📸 Configurando snapshots para {dataset_name}")
# MEJORA 1: Verificar/instalar servicio zfs-auto-snapshot
if not self._verify_zfs_auto_snapshot_service():
return
# Preguntar qué tipo de snapshots quiere el usuario
self.console.print(" 🕐 Frecuencia de snapshots automáticos:")
self.console.print(" 1. Solo diarios (recomendado para uso general)")
self.console.print(" 2. Solo semanales (para datos poco cambiantes)")
self.console.print(" 3. Solo mensuales (para archivos estáticos)")
self.console.print(" 4. Diarios + semanales (balance espacio/protección)")
self.console.print(" 5. Semanales + mensuales (mínimo espacio)")
self.console.print(" 6. Todos (cada hora, día, semana, mes) ⚠️ Consume más espacio")
choice = self.console.prompt(" 👉 Selecciona frecuencia", "1")
# Configurar snapshots base (siempre necesario)
base_properties = [('com.sun:auto-snapshot', 'true', 'Snapshots automáticos base')]
# Configurar según la elección del usuario
if choice == "1": # Solo diarios
snapshot_properties = base_properties + [
('com.sun:auto-snapshot:daily', 'true', 'Snapshots diarios')
]
elif choice == "2": # Solo semanales
snapshot_properties = base_properties + [
('com.sun:auto-snapshot:weekly', 'true', 'Snapshots semanales')
]
elif choice == "3": # Solo mensuales
snapshot_properties = base_properties + [
('com.sun:auto-snapshot:monthly', 'true', 'Snapshots mensuales')
]
elif choice == "4": # Diarios + semanales
snapshot_properties = base_properties + [
('com.sun:auto-snapshot:daily', 'true', 'Snapshots diarios'),
('com.sun:auto-snapshot:weekly', 'true', 'Snapshots semanales')
]
elif choice == "5": # Semanales + mensuales
snapshot_properties = base_properties + [
('com.sun:auto-snapshot:weekly', 'true', 'Snapshots semanales'),
('com.sun:auto-snapshot:monthly', 'true', 'Snapshots mensuales')
]
elif choice == "6": # Todos
snapshot_properties = base_properties + [
('com.sun:auto-snapshot:hourly', 'true', 'Snapshots cada hora'),
('com.sun:auto-snapshot:daily', 'true', 'Snapshots diarios'),
('com.sun:auto-snapshot:weekly', 'true', 'Snapshots semanales'),
('com.sun:auto-snapshot:monthly', 'true', 'Snapshots mensuales')
]
else: # Default a diarios
self.console.print(" ⚠️ Opción inválida, usando snapshots diarios por defecto", style="yellow")
snapshot_properties = base_properties + [
('com.sun:auto-snapshot:daily', 'true', 'Snapshots diarios')
]
# Aplicar configuración seleccionada
for prop, value, description in snapshot_properties:
try:
self.system.run_command(['zfs', 'set', f'{prop}={value}', dataset_name])
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo configurar {description}", style="yellow")
# MEJORA 2: Crear snapshot de demostración
self._create_demo_snapshot(dataset_name)
# Configurar retención de snapshots (nueva funcionalidad)
retention_config = self._configure_snapshot_retention(choice)
# MEJORA 4: Mostrar métodos de acceso a snapshots (mejorado)
self._show_snapshot_access_methods(dataset_name)
# Mostrar comandos útiles para gestión de snapshots
self._show_snapshot_management_commands(dataset_name)
self.console.print(f" ✅ Snapshots automáticos configurados")
def _show_snapshot_retention_info(self, choice: str):
"""Muestra información sobre retención de snapshots según la configuración"""
retention_info = {
"1": "Se mantendrán ~30 snapshots diarios",
"2": "Se mantendrán ~12 snapshots semanales",
"3": "Se mantendrán ~12 snapshots mensuales",
"4": "Se mantendrán ~30 diarios + ~12 semanales",
"5": "Se mantendrán ~12 semanales + ~12 mensuales",
"6": "Se mantendrán ~24 por hora + ~30 diarios + ~12 semanales + ~12 mensuales"
}
info = retention_info.get(choice, "Retención según configuración estándar")
self.console.print(f" 💡 {info}", style="blue")
if choice == "6":
self.console.print(" ⚠️ Los snapshots por hora pueden consumir espacio significativo", style="yellow")
def _configure_snapshot_retention(self, choice: str):
"""Configura cuántos snapshots mantener vivos según la frecuencia seleccionada"""
self.console.print(" ⚙️ Configurando retención de snapshots...")
# Configuraciones recomendadas para cada frecuencia
retention_config = {}
if choice == "1": # Solo diarios
retention_config['daily'] = self._ask_retention_count("diarios", 30)
elif choice == "2": # Solo semanales
retention_config['weekly'] = self._ask_retention_count("semanales", 12)
elif choice == "3": # Solo mensuales
retention_config['monthly'] = self._ask_retention_count("mensuales", 12)
elif choice == "4": # Diarios + semanales
retention_config['daily'] = self._ask_retention_count("diarios", 30)
retention_config['weekly'] = self._ask_retention_count("semanales", 12)
elif choice == "5": # Semanales + mensuales
retention_config['weekly'] = self._ask_retention_count("semanales", 12)
retention_config['monthly'] = self._ask_retention_count("mensuales", 12)
elif choice == "6": # Todos
retention_config['hourly'] = self._ask_retention_count("por hora", 24)
retention_config['daily'] = self._ask_retention_count("diarios", 30)
retention_config['weekly'] = self._ask_retention_count("semanales", 12)
retention_config['monthly'] = self._ask_retention_count("mensuales", 12)
# Aplicar configuración de retención
self._apply_retention_configuration(retention_config)
return retention_config
def _ask_retention_count(self, frequency_name: str, recommended: int) -> int:
"""Pregunta al usuario cuántos snapshots mantener para una frecuencia específica"""
self.console.print(f" 📅 Snapshots {frequency_name}:")
self.console.print(f" Recomendado: {recommended} snapshots")
while True:
try:
count = self.console.prompt(f" ¿Cuántos snapshots {frequency_name} mantener?", str(recommended))
count = int(count.strip())
if count < 1:
self.console.print(" ❌ Debe ser al menos 1", style="red")
continue
elif count > 100:
if not self.console.confirm(f" ⚠️ {count} snapshots es mucho. ¿Continuar?", default=False):
continue
# Mostrar estimación de espacio si es relevante
if frequency_name == "por hora" and count > 48:
self.console.print(" ⚠️ Muchos snapshots por hora pueden consumir espacio significativo", style="yellow")
elif frequency_name == "diarios" and count > 60:
self.console.print(" 💡 Considere usar snapshots semanales para períodos largos", style="blue")
return count
except ValueError:
self.console.print(" ❌ Ingrese un número válido", style="red")
def _apply_retention_configuration(self, retention_config: dict):
"""Aplica la configuración de retención al archivo de configuración del sistema"""
self.console.print(" 🔧 Aplicando configuración de retención...")
config_file = '/etc/default/zfs-auto-snapshot'
temp_file = '/tmp/zfs_auto_snapshot_config.tmp'
try:
# Leer configuración actual si existe
current_config = {}
if os.path.exists(config_file):
try:
result = self.system.run_command(['sudo', 'cat', config_file], check=False)
if result.returncode == 0:
for line in result.stdout.strip().split('\n'):
if '=' in line and not line.strip().startswith('#'):
key, value = line.split('=', 1)
current_config[key.strip()] = value.strip()
except:
pass
# Preparar nueva configuración
new_config = {
'HOURLY': str(retention_config.get('hourly', current_config.get('HOURLY', '0'))),
'DAILY': str(retention_config.get('daily', current_config.get('DAILY', '0'))),
'WEEKLY': str(retention_config.get('weekly', current_config.get('WEEKLY', '0'))),
'MONTHLY': str(retention_config.get('monthly', current_config.get('MONTHLY', '0')))
}
# Crear contenido del archivo de configuración
config_content = f"""# ZFS Auto-Snapshot Configuration
# Configurado por raid_manager.py - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
# Número de snapshots a mantener para cada frecuencia
# 0 = deshabilitado, >0 = número de snapshots a conservar
# Snapshots por hora (0-24 recomendado)
HOURLY={new_config['HOURLY']}
# Snapshots diarios (7-60 recomendado)
DAILY={new_config['DAILY']}
# Snapshots semanales (4-12 recomendado)
WEEKLY={new_config['WEEKLY']}
# Snapshots mensuales (6-24 recomendado)
MONTHLY={new_config['MONTHLY']}
# Configuraciones adicionales
VERBOSE=false
DRYRUN=false
"""
# Escribir a archivo temporal
with open(temp_file, 'w') as f:
f.write(config_content)
# Copiar con sudo al destino final
result = self.system.run_command(['sudo', 'cp', temp_file, config_file], check=False)
if result.returncode == 0:
self.console.print(" ✅ Configuración de retención aplicada", style="green")
# Mostrar resumen de configuración
self._show_retention_summary(retention_config)
# Limpiar archivo temporal
os.remove(temp_file)
else:
self.console.print(" ⚠️ Error aplicando configuración, usando valores por defecto", style="yellow")
except Exception as e:
self.console.print(f" ⚠️ Error configurando retención: {e}", style="yellow")
self.console.print(" 💡 Puedes configurar manualmente editando /etc/default/zfs-auto-snapshot", style="blue")
def _show_retention_summary(self, retention_config: dict):
"""Muestra un resumen de la configuración de retención aplicada"""
self.console.print(" 📊 Configuración de retención aplicada:", style="blue")
frequency_names = {
'hourly': 'Por hora',
'daily': 'Diarios',
'weekly': 'Semanales',
'monthly': 'Mensuales'
}
for freq, count in retention_config.items():
name = frequency_names.get(freq, freq.capitalize())
self.console.print(f" 📅 {name}: {count} snapshots")
# Calcular estimación de snapshots totales
total_snapshots = sum(retention_config.values())
self.console.print(f" 💾 Total máximo de snapshots por dataset: ~{total_snapshots}")
if total_snapshots > 50:
self.console.print(" 💡 Considera monitorear el uso de espacio regularmente", style="blue")
def _show_snapshot_management_commands(self, dataset_name: str):
"""Muestra comandos útiles para gestionar snapshots"""
self.console.print(f" 📚 Comandos útiles para gestionar snapshots:", style="blue")
self.console.print(f" • Ver snapshots: zfs list -t snapshot {dataset_name}")
self.console.print(f" • Crear manual: zfs snapshot {dataset_name}@manual-$(date +%Y%m%d)")
self.console.print(f" • Restaurar archivo: zfs send/recv o acceso directo en .zfs/snapshot/")
self.console.print(f" • Eliminar snapshot: zfs destroy {dataset_name}@nombre_snapshot")
def _verify_zfs_auto_snapshot_service(self) -> bool:
"""MEJORA 1: Verifica si el servicio zfs-auto-snapshot está instalado y lo instala si es necesario"""
self.console.print(" 🔍 Verificando servicio zfs-auto-snapshot...")
# Verificar si zfs-auto-snapshot está instalado
try:
self.system.run_command(['which', 'zfs-auto-snapshot'])
self.console.print(" ✅ Servicio zfs-auto-snapshot encontrado", style="green")
# Verificar que los cron jobs estén configurados
self._verify_snapshot_cron_jobs()
return True
except subprocess.CalledProcessError:
self.console.print(" ❌ zfs-auto-snapshot no está instalado", style="red")
if self.console.confirm(" ¿Instalar zfs-auto-snapshot automáticamente?", default=True):
return self._install_zfs_auto_snapshot()
else:
self.console.print(" ⚠️ Sin zfs-auto-snapshot, los snapshots automáticos no funcionarán", style="yellow")
self.console.print(" 💡 Instala manualmente: apt install zfs-auto-snapshot", style="blue")
return False
def _verify_snapshot_cron_jobs(self):
"""Verifica que los cron jobs de snapshots estén activos"""
cron_files = [
'/etc/cron.hourly/zfs-auto-snapshot',
'/etc/cron.daily/zfs-auto-snapshot',
'/etc/cron.weekly/zfs-auto-snapshot',
'/etc/cron.monthly/zfs-auto-snapshot'
]
active_jobs = []
for cron_file in cron_files:
if os.path.exists(cron_file) and os.access(cron_file, os.X_OK):
active_jobs.append(cron_file.split('/')[-2]) # hourly, daily, etc.
if active_jobs:
self.console.print(f" ✅ Cron jobs activos: {', '.join(active_jobs)}", style="green")
else:
self.console.print(" ⚠️ No se encontraron cron jobs activos", style="yellow")
def _install_zfs_auto_snapshot(self) -> bool:
"""Instala el servicio zfs-auto-snapshot"""
self.console.print(" 🔄 Instalando zfs-auto-snapshot...")
try:
# Actualizar lista de paquetes
self.console.print(" 📦 Actualizando lista de paquetes...")
self.system.run_command(['sudo', 'apt', 'update', '-qq'])
# Instalar zfs-auto-snapshot
self.console.print(" 📥 Instalando zfs-auto-snapshot...")
result = self.system.run_command(['sudo', 'apt', 'install', '-y', 'zfs-auto-snapshot'])
if result.returncode == 0:
self.console.print(" ✅ zfs-auto-snapshot instalado exitosamente", style="green")
# Verificar instalación
try:
self.system.run_command(['which', 'zfs-auto-snapshot'])
self.console.print(" ✅ Instalación verificada", style="green")
self._verify_snapshot_cron_jobs()
return True
except subprocess.CalledProcessError:
self.console.print(" ❌ Error verificando instalación", style="red")
return False
else:
self.console.print(" ❌ Error durante la instalación", style="red")
return False
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error instalando zfs-auto-snapshot: {e}", style="red")
self.console.print(" 💡 Instala manualmente: sudo apt install zfs-auto-snapshot", style="blue")
return False
def _create_demo_snapshot(self, dataset_name: str):
"""MEJORA 2: Crea un snapshot de demostración para probar el sistema"""
self.console.print(" 🧪 Creando snapshot de demostración...")
try:
# Crear timestamp para el snapshot
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
snapshot_name = f"{dataset_name}@demo-{timestamp}"
# Crear el snapshot
self.system.run_command(['zfs', 'snapshot', snapshot_name])
self.console.print(f" ✅ Snapshot creado: {snapshot_name}", style="green")
# Verificar que se creó correctamente
try:
result = self.system.run_command(['zfs', 'list', '-t', 'snapshot', snapshot_name])
if result.returncode == 0:
self.console.print(" ✅ Snapshot verificado correctamente", style="green")
# Mostrar información del snapshot
self._show_demo_snapshot_info(dataset_name, snapshot_name)
except subprocess.CalledProcessError:
self.console.print(" ⚠️ No se pudo verificar el snapshot", style="yellow")
except subprocess.CalledProcessError as e:
self.console.print(f" ❌ Error creando snapshot de demostración: {e}", style="red")
def _show_demo_snapshot_info(self, dataset_name: str, snapshot_name: str):
"""Muestra información del snapshot de demostración creado"""
self.console.print(" 📊 Información del snapshot de demostración:", style="blue")
try:
# Obtener información del snapshot
result = self.system.run_command(['zfs', 'list', '-t', 'snapshot', '-o', 'name,used,refer', snapshot_name])
lines = result.stdout.strip().split('\n')
if len(lines) > 1:
self.console.print(f" 📸 {lines[1]}")
# Mostrar cómo acceder al snapshot
pool_name = dataset_name.split('/')[0]
dataset_path = dataset_name.replace(pool_name, f"/{pool_name}")
snapshot_timestamp = snapshot_name.split('@')[1]
self.console.print(f" 🔗 Acceso al snapshot:")
self.console.print(f" • Ruta directa: {dataset_path}/.zfs/snapshot/{snapshot_timestamp}/")
self.console.print(f" • Navegación: cd {dataset_path}/.zfs/snapshot/")
except subprocess.CalledProcessError:
self.console.print(" ⚠️ No se pudo obtener información detallada", style="yellow")
def _show_snapshot_access_methods(self, dataset_name: str):
"""MEJORA 4: Muestra métodos detallados para acceder a snapshots"""
pool_name = dataset_name.split('/')[0]
dataset_path = dataset_name.replace(pool_name, f"/{pool_name}")
self.console.print(" 🔗 Métodos de acceso a snapshots:", style="blue")
self.console.print(" ")
self.console.print(" 📁 1. Acceso directo por navegación:")
self.console.print(f" cd {dataset_path}/.zfs/snapshot/")
self.console.print(" ls -la # Ver todos los snapshots")
self.console.print(" cd auto-2024MMDD-HHMM/ # Entrar en snapshot específico")
self.console.print(" ")
self.console.print(" 📁 2. Restaurar archivos específicos:")
self.console.print(f" cp {dataset_path}/.zfs/snapshot/SNAPSHOT_NAME/archivo.txt {dataset_path}/")
self.console.print(" # Copia archivo desde snapshot a ubicación actual")
self.console.print(" ")
self.console.print(" 📁 3. Explorar contenido de snapshots:")
self.console.print(f" find {dataset_path}/.zfs/snapshot/ -name '*archivo*' -type f")
self.console.print(" # Busca archivos en todos los snapshots")
self.console.print(" ")
self.console.print(" 📁 4. Comparar versiones:")
self.console.print(f" diff {dataset_path}/archivo.txt {dataset_path}/.zfs/snapshot/SNAPSHOT/archivo.txt")
self.console.print(" # Compara archivo actual con versión en snapshot")
self.console.print(" ")
self.console.print(" 💡 Los snapshots son de solo lectura y no ocupan espacio inicialmente")
self.console.print(" 💡 Solo los cambios posteriores al snapshot consumen espacio adicional")
def _configure_dataset_quota(self, dataset_name: str, suggested_quota: str):
"""Configura cuota para un dataset específico"""
try:
self.system.run_command(['zfs', 'set', f'quota={suggested_quota}', dataset_name])
self.console.print(f" 💾 Cuota de {suggested_quota} configurada")
except subprocess.CalledProcessError:
self.console.print(f" ⚠️ No se pudo configurar cuota de {suggested_quota}", style="yellow")
def _create_btrfs_raid(self, raid_type: RAIDType, disks: List[Disk]):
"""Crea un RAID BTRFS"""
self.console.print_panel("Configurando BTRFS RAID", title="🌿 BTRFS")
# Verificar que BTRFS esté disponible
try:
self.system.run_command(['which', 'mkfs.btrfs'])
except subprocess.CalledProcessError:
self.console.print("❌ BTRFS no está disponible en el sistema", style="red")
raise Exception("BTRFS no disponible")
# Obtener punto de montaje
mount_point = self._get_mount_point("/mnt/btrfs_raid")
# Mapear tipos de RAID
raid_mapping = {
RAIDType.BTRFS_RAID0: 'raid0',
RAIDType.BTRFS_RAID1: 'raid1',
RAIDType.BTRFS_RAID10: 'raid10',
RAIDType.BTRFS_RAID5: 'raid5',
RAIDType.BTRFS_RAID6: 'raid6'
}
btrfs_raid_type = raid_mapping.get(raid_type)
if not btrfs_raid_type:
raise Exception(f"Tipo de RAID no soportado: {raid_type}")
# Mostrar advertencia para RAID experimentales
if raid_type in [RAIDType.BTRFS_RAID5, RAIDType.BTRFS_RAID6]:
self.console.print("⚠️ ADVERTENCIA: RAID 5/6 en BTRFS es experimental", style="yellow")
if not self.console.confirm("¿Continuar con RAID experimental?", default=False):
raise Exception("Operación cancelada por el usuario")
# Crear filesystem BTRFS
self._create_btrfs_filesystem(btrfs_raid_type, disks, mount_point)
# Configurar propiedades BTRFS
self._configure_btrfs_properties(mount_point)
self.console.print("✅ BTRFS RAID creado exitosamente", style="green")
def _create_btrfs_filesystem(self, raid_type: str, disks: List[Disk], mount_point: str):
"""Crea el filesystem BTRFS"""
self.console.print(f"🔨 Creando filesystem BTRFS {raid_type.upper()}...")
# Construir comando
cmd = ['mkfs.btrfs', '-f', '-d', raid_type, '-m', raid_type]
# Añadir discos
for disk in disks:
cmd.append(f'/dev/{disk.name}')
try:
self.console.print(f"📝 Ejecutando: {' '.join(cmd)}")
self.system.run_command(cmd)
self.console.print("✅ Filesystem BTRFS creado", style="green")
# Montar el filesystem
self.console.print(f"📁 Montando en {mount_point}...")
self.system.run_command(['mount', f'/dev/{disks[0].name}', mount_point])
self.console.print(f"✅ Montado en {mount_point}", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error creando filesystem BTRFS: {e}", style="red")
raise
def _configure_btrfs_properties(self, mount_point: str):
"""Configura propiedades del filesystem BTRFS"""
self.console.print("⚙️ Configurando propiedades BTRFS...")
# Habilitar compresión
try:
self.system.run_command(['btrfs', 'property', 'set', mount_point, 'compression', 'lzo'])
self.console.print(" ✅ Compresión LZO habilitada", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f" ⚠️ No se pudo habilitar compresión: {e}", style="yellow")
# Mostrar información del filesystem
try:
result = self.system.run_command(['btrfs', 'filesystem', 'show', mount_point])
self.console.print("📊 Información del filesystem:")
for line in result.stdout.split('\n'):
if line.strip():
self.console.print(f" {line}")
except subprocess.CalledProcessError:
pass
def _configure_auto_mount(self, fs_type: FilesystemType, raid_type: RAIDType, disks: List[Disk]):
"""Configura el montaje automático según el tipo de filesystem"""
self.console.print_panel("Configurando montaje automático", title="🔧 Configuración")
if not self.console.confirm("¿Configurar montaje automático en el arranque?", default=True):
self.console.print("⏭️ Saltando configuración de montaje automático", style="blue")
return
try:
if fs_type == FilesystemType.ZFS:
self._configure_zfs_auto_mount()
else:
# BTRFS requiere entrada en fstab
self._configure_btrfs_fstab(disks)
except Exception as e:
self.console.print(f"❌ Error configurando montaje automático: {e}", style="red")
def _configure_zfs_auto_mount(self):
"""Configura montaje automático para ZFS (sin fstab)"""
self.console.print("🔷 Configurando ZFS para montaje automático...", style="blue")
# ZFS no necesita /etc/fstab - maneja su propio sistema de montaje
self.console.print("💡 ZFS gestiona automáticamente el montaje de pools y datasets")
self.console.print(" • Los pools se importan automáticamente al arranque")
self.console.print(" • Los datasets se montan según su propiedad 'mountpoint'")
# Habilitar servicios ZFS del sistema
zfs_services = [
('zfs-import-cache.service', 'Importación automática de pools'),
('zfs-mount.service', 'Montaje automático de datasets'),
('zfs.target', 'Target principal de ZFS')
]
for service, description in zfs_services:
if self.system.run_command_safe(['systemctl', 'enable', service]):
self.console.print(f" ✅ {service} habilitado - {description}")
else:
self.console.print(f" ⚠️ Error con {service} - {description}", style="yellow")
# Verificar que los servicios estén activos
self.console.print("\n🔍 Verificando estado de servicios ZFS...")
for service, description in zfs_services:
try:
result = self.system.run_command(['systemctl', 'is-enabled', service], check=False)
if result.returncode == 0:
status = result.stdout.strip()
if status == 'enabled':
self.console.print(f" ✅ {service}: {status}")
else:
self.console.print(f" ⚠️ {service}: {status}", style="yellow")
else:
self.console.print(f" ❌ {service}: no disponible", style="red")
except subprocess.CalledProcessError:
self.console.print(f" ❌ {service}: error verificando", style="red")
# Información adicional sobre montaje ZFS
self.console.print("\n📚 Información sobre montaje ZFS:")
self.console.print(" • Para cambiar punto de montaje: zfs set mountpoint=/ruta pool/dataset")
self.console.print(" • Para deshabilitar montaje: zfs set mountpoint=none pool/dataset")
self.console.print(" • Para montar manualmente: zfs mount pool/dataset")
self.console.print(" • Para ver puntos de montaje: zfs get mountpoint")
self.console.print("✅ Configuración ZFS completada", style="green")
def _configure_btrfs_fstab(self, disks: List[Disk]):
"""Configura fstab para BTRFS"""
self.console.print("🌿 Configurando BTRFS para montaje automático...", style="green")
try:
# Para BTRFS RAID, necesitamos el UUID del filesystem, no del dispositivo individual
device_path = f"/dev/{disks[0].name}"
# Intentar obtener UUID del filesystem BTRFS
result = self.system.run_command(['blkid', '-s', 'UUID', '-o', 'value', device_path], check=False)
uuid = result.stdout.strip() if result.returncode == 0 else None
if not uuid:
# Si no hay UUID, el filesystem podría no estar montado aún
self.console.print("⚠️ No se encontró UUID, intentando detectar filesystem BTRFS...", style="yellow")
try:
# Usar btrfs filesystem show para obtener UUID
result = self.system.run_command(['btrfs', 'filesystem', 'show', device_path])
for line in result.stdout.split('\n'):
if 'uuid:' in line:
uuid = line.split('uuid:')[1].strip()
break
except subprocess.CalledProcessError:
pass
if not uuid:
raise Exception("No se pudo obtener UUID del filesystem BTRFS")
# Obtener o definir punto de montaje
mount_point = "/storage" # Punto de montaje por defecto
try:
result = self.system.run_command(['findmnt', '-n', '-o', 'TARGET', device_path], check=False)
if result.returncode == 0 and result.stdout.strip():
mount_point = result.stdout.strip()
self.console.print(f"📁 Punto de montaje detectado: {mount_point}")
else:
self.console.print(f"📁 Usando punto de montaje por defecto: {mount_point}")
# Crear directorio si no existe
if self.system.run_command_safe(['mkdir', '-p', mount_point]):
self.console.print(f" ✅ Directorio {mount_point} creado")
except subprocess.CalledProcessError:
self.console.print(f"📁 Usando punto de montaje por defecto: {mount_point}")
self.system.run_command_safe(['mkdir', '-p', mount_point])
# Crear entrada fstab optimizada para BTRFS
fstab_options = []
fstab_options.append("defaults")
fstab_options.append("compress=zstd") # Compresión moderna y eficiente
fstab_options.append("noatime") # Mejor rendimiento
fstab_options.append("space_cache=v2") # Cache de espacio v2
fstab_entry = f"UUID={uuid} {mount_point} btrfs {','.join(fstab_options)} 0 2\n"
# Crear backup de fstab
if self.system.run_command_safe(['cp', '/etc/fstab', '/etc/fstab.backup']):
self.console.print(" ✅ Backup de /etc/fstab creado")
# Verificar si ya existe una entrada para este UUID
try:
with open('/etc/fstab', 'r') as f:
fstab_content = f.read()
if uuid in fstab_content:
self.console.print("⚠️ Ya existe una entrada para este UUID en fstab", style="yellow")
if not self.console.confirm("¿Sobrescribir entrada existente?", default=False):
self.console.print("⏭️ Manteniendo configuración existente", style="blue")
return
# Remover entrada existente
lines = fstab_content.split('\n')
new_lines = [line for line in lines if uuid not in line]
fstab_content = '\n'.join(new_lines)
with open('/etc/fstab', 'w') as f:
f.write(fstab_content)
self.console.print(" 🔄 Entrada anterior removida")
# Añadir nueva entrada a fstab
with open('/etc/fstab', 'a') as f:
f.write(f"\n# BTRFS RAID configurado por raid_manager.py - {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(fstab_entry)
self.console.print("✅ Entrada añadida a /etc/fstab", style="green")
self.console.print(f" 📄 UUID: {uuid}")
self.console.print(f" 📁 Punto de montaje: {mount_point}")
self.console.print(f" ⚙️ Opciones: {','.join(fstab_options)}")
# Verificar que el montaje funciona
if self.console.confirm("¿Probar montaje automático?", default=True):
if self.system.run_command_safe(['mount', '-a']):
self.console.print("✅ Montaje automático verificado", style="green")
else:
self.console.print("⚠️ Error en montaje automático - revisar configuración", style="yellow")
except Exception as e:
raise Exception(f"Error escribiendo fstab: {e}")
except Exception as e:
self.console.print(f"❌ Error configurando fstab: {e}", style="red")
self.console.print("💡 Puedes configurar el montaje manualmente después", style="blue")
def _show_final_summary(self, fs_type: FilesystemType, raid_type: RAIDType, disks: List[Disk]):
"""Muestra el resumen final de la configuración"""
self.console.print_panel("¡RAID configurado exitosamente!", title="🎉 ¡Completado!")
if RICH_AVAILABLE:
# Crear tabla de resumen final
summary_table = Table(title="📋 Configuración Final", show_header=False)
summary_table.add_column("Aspecto", style="bold cyan", width=20)
summary_table.add_column("Detalle", style="white")
summary_table.add_row("Filesystem", fs_type.value.upper())
summary_table.add_row("Tipo RAID", raid_type.value)
summary_table.add_row("Discos utilizados", f"{len(disks)} discos")
disk_list = ", ".join([f"{disk.name} ({disk.size_human})" for disk in disks])
summary_table.add_row("Detalle de discos", disk_list)
# Estado del sistema
if fs_type == FilesystemType.ZFS:
try:
result = self.system.run_command(['zpool', 'list'])
summary_table.add_row("Estado ZFS", "✅ Online")
except:
summary_table.add_row("Estado ZFS", "⚠️ Verificar manualmente")
else:
summary_table.add_row("Estado BTRFS", "✅ Montado")
self.console.console.print(summary_table)
# Comandos útiles
self.console.print("\n💡 Comandos útiles:", style="bold blue")
if fs_type == FilesystemType.ZFS:
useful_commands = [
"zpool status - Ver estado del pool",
"zfs list - Listar datasets",
"zpool iostat 1 - Monitor de I/O en tiempo real",
"zfs get all <pool> - Ver todas las propiedades"
]
else:
useful_commands = [
"btrfs filesystem show - Ver filesystems BTRFS",
"btrfs filesystem usage <mount> - Ver uso del espacio",
"btrfs device stats <mount> - Estadísticas de dispositivos",
"btrfs scrub start <mount> - Iniciar verificación de integridad"
]
for cmd in useful_commands:
self.console.print(f" • {cmd}", style="blue")
# Advertencias finales
warnings = [
"🔄 Reinicia el sistema para asegurar el montaje automático",
"📊 Monitorea el rendimiento inicial del RAID",
"💾 Configura backups regulares de tus datos importantes"
]
self.console.print("\n⚠️ Recomendaciones importantes:", style="bold yellow")
for warning in warnings:
self.console.print(f" {warning}", style="yellow")
def _configure_cache_devices(self, pool_name: str, pool_disks: List[Disk]):
"""Configura dispositivos de cache (SLOG/L2ARC) para el pool ZFS"""
self.console.print_panel(
f"Configuración de dispositivos de cache para '{pool_name}'\n"
"Los dispositivos de cache mejoran el rendimiento del pool ZFS",
title="🚀 Cache Devices",
style="blue"
)
# Detectar dispositivos disponibles para cache
cache_devices = self._detect_cache_devices(pool_disks)
if not cache_devices['nvme'] and not cache_devices['ssd']:
self.console.print_panel(
"⚠️ NO SE DETECTARON DISPOSITIVOS NVMe O SSD ADECUADOS\n\n"
"• Los dispositivos de cache deben ser más rápidos que el almacenamiento principal\n"
"• Usar dispositivos lentos como cache puede REDUCIR el rendimiento\n"
"• Se recomienda conseguir un dispositivo NVMe o SSD para cache",
title="❌ Sin dispositivos de cache",
style="yellow"
)
return
# Mostrar información sobre cache devices
self._show_cache_info()
# Mostrar dispositivos disponibles
self._show_available_cache_devices(cache_devices)
# Preguntar si quiere configurar cache devices
if not self.console.confirm("¿Deseas configurar dispositivos de cache para mejorar el rendimiento?", default=True):
self.console.print("⏭️ Saltando configuración de cache devices", style="yellow")
return
# Menú de opciones de cache
self._show_cache_menu(pool_name, cache_devices)
def _detect_cache_devices(self, pool_disks: List[Disk]) -> Dict[str, List[Disk]]:
"""Detecta dispositivos disponibles para cache (NVMe/SSD)"""
all_disks = self.disk_manager.detect_disks()
pool_disk_names = {disk.name for disk in pool_disks}
cache_devices = {
'nvme': [],
'ssd': [],
'other': []
}
for disk in all_disks:
# Excluir discos del sistema y discos usados en el pool principal
if disk.is_system or disk.name in pool_disk_names:
continue
# Clasificar por tipo
if disk.name.startswith('nvme'):
cache_devices['nvme'].append(disk)
elif self._is_ssd(disk):
cache_devices['ssd'].append(disk)
else:
cache_devices['other'].append(disk)
return cache_devices
def _is_ssd(self, disk: Disk) -> bool:
"""Verifica si un disco es SSD"""
try:
# Verificar ROTA (rotational) - 0 significa SSD
result = self.system.run_command(['lsblk', '-dpno', 'ROTA', f'/dev/{disk.name}'])
return result.stdout.strip() == '0'
except subprocess.CalledProcessError:
return False
def _show_cache_info(self):
"""Muestra información sobre cache devices"""
info_text = (
"🚀 L2ARC (Level 2 Adaptive Replacement Cache):\n"
" • Cache de segundo nivel para lecturas frecuentes\n"
" • Ideal: SSD rápido (NVMe > SATA SSD)\n"
" • Mejora rendimiento de lectura en datasets accedidos frecuentemente\n"
" • No es crítico - si falla, el pool sigue funcionando\n\n"
"📝 SLOG (Separate Intent Log):\n"
" • Log de transacciones para escrituras síncronas\n"
" • Ideal: SSD con baja latencia (NVMe recomendado)\n"
" • Mejora rendimiento de escrituras síncronas (bases de datos, VMs)\n"
" • Crítico para integridad - usar dispositivos confiables"
)
self.console.print_panel(info_text, title="💡 Información sobre Cache Devices")
def _show_available_cache_devices(self, cache_devices: Dict[str, List[Disk]]):
"""Muestra dispositivos disponibles para cache"""
if RICH_AVAILABLE:
table = Table(title="💾 Dispositivos Disponibles para Cache")
table.add_column("Tipo", style="cyan")
table.add_column("Dispositivo", style="green")
table.add_column("Tamaño", style="yellow")
table.add_column("Modelo", style="blue")
table.add_column("Recomendación", style="magenta")
# Dispositivos NVMe
for disk in cache_devices['nvme']:
table.add_row(
"🚀 NVMe",
disk.name,
disk.size_human,
disk.model,
"✅ EXCELENTE"
)
# Dispositivos SSD
for disk in cache_devices['ssd']:
table.add_row(
"💾 SSD",
disk.name,
disk.size_human,
disk.model,
"⚠️ ACEPTABLE"
)
# Otros dispositivos (no recomendados)
for disk in cache_devices['other']:
table.add_row(
"🐌 HDD",
disk.name,
disk.size_human,
disk.model,
"❌ NO RECOMENDADO"
)
self.console.console.print(table)
else:
print("\n💾 Dispositivos Disponibles para Cache:")
for disk in cache_devices['nvme']:
print(f" 🚀 {disk.name} - {disk.size_human} - {disk.model} (NVMe - EXCELENTE)")
for disk in cache_devices['ssd']:
print(f" 💾 {disk.name} - {disk.size_human} - {disk.model} (SSD - ACEPTABLE)")
def _show_cache_menu(self, pool_name: str, cache_devices: Dict[str, List[Disk]]):
"""Muestra menú de opciones de cache"""
self.console.print("\n🎯 Opciones de configuración de cache:")
self.console.print(" 1. Solo L2ARC (cache de lectura)")
self.console.print(" 2. Solo SLOG (log de escritura)")
self.console.print(" 3. L2ARC y SLOG en dispositivos separados")
self.console.print(" 4. L2ARC y SLOG particionados en el mismo dispositivo")
self.console.print(" 0. Saltar configuración de cache")
while True:
choice = self.console.prompt("👉 Selecciona opción", "1")
if choice == "0":
self.console.print("⏭️ Saltando configuración de cache", style="yellow")
break
elif choice == "1":
self._setup_l2arc_only(pool_name, cache_devices)
break
elif choice == "2":
self._setup_slog_only(pool_name, cache_devices)
break
elif choice == "3":
self._setup_separate_cache_devices(pool_name, cache_devices)
break
elif choice == "4":
self._setup_partitioned_cache(pool_name, cache_devices)
break
else:
self.console.print("❌ Opción inválida", style="red")
def _setup_l2arc_only(self, pool_name: str, cache_devices: Dict[str, List[Disk]]):
"""Configura solo L2ARC"""
self.console.print_panel("Configurando L2ARC (cache de lectura)", title="🚀 L2ARC")
# Seleccionar dispositivo
device = self._select_cache_device(cache_devices, "L2ARC")
if not device:
return
# Limpiar dispositivo si es necesario
if not self._prepare_cache_device(device):
return
# Agregar L2ARC al pool
try:
self.console.print(f"📦 Agregando {device.name} como L2ARC al pool {pool_name}...")
self.system.run_command(['zpool', 'add', pool_name, 'cache', f'/dev/{device.name}'])
self.console.print("✅ L2ARC configurado exitosamente", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error configurando L2ARC: {e}", style="red")
def _setup_slog_only(self, pool_name: str, cache_devices: Dict[str, List[Disk]]):
"""Configura solo SLOG"""
self.console.print_panel("Configurando SLOG (log de escritura)", title="📝 SLOG")
# Seleccionar dispositivo
device = self._select_cache_device(cache_devices, "SLOG")
if not device:
return
# Limpiar dispositivo si es necesario
if not self._prepare_cache_device(device):
return
# Agregar SLOG al pool
try:
self.console.print(f"📦 Agregando {device.name} como SLOG al pool {pool_name}...")
self.system.run_command(['zpool', 'add', pool_name, 'log', f'/dev/{device.name}'])
self.console.print("✅ SLOG configurado exitosamente", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error configurando SLOG: {e}", style="red")
def _setup_separate_cache_devices(self, pool_name: str, cache_devices: Dict[str, List[Disk]]):
"""Configura L2ARC y SLOG en dispositivos separados"""
self.console.print_panel("Configurando L2ARC y SLOG en dispositivos separados", title="🔄 Dual Cache")
# Verificar que hay al menos 2 dispositivos
available_count = len(cache_devices['nvme']) + len(cache_devices['ssd'])
if available_count < 2:
self.console.print("❌ Se necesitan al menos 2 dispositivos para configuración separada", style="red")
return
# Seleccionar dispositivo para L2ARC
self.console.print("\n🚀 Seleccionar dispositivo para L2ARC:")
l2arc_device = self._select_cache_device(cache_devices, "L2ARC")
if not l2arc_device:
return
# Remover el dispositivo seleccionado de las opciones
for device_type in cache_devices:
cache_devices[device_type] = [d for d in cache_devices[device_type] if d.name != l2arc_device.name]
# Seleccionar dispositivo para SLOG
self.console.print("\n📝 Seleccionar dispositivo para SLOG:")
slog_device = self._select_cache_device(cache_devices, "SLOG")
if not slog_device:
return
# Preparar dispositivos
if not self._prepare_cache_device(l2arc_device) or not self._prepare_cache_device(slog_device):
return
# Configurar L2ARC y SLOG
try:
self.console.print(f"📦 Configurando cache devices...")
self.system.run_command(['zpool', 'add', pool_name, 'cache', f'/dev/{l2arc_device.name}'])
self.system.run_command(['zpool', 'add', pool_name, 'log', f'/dev/{slog_device.name}'])
self.console.print("✅ L2ARC y SLOG configurados exitosamente", style="green")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error configurando cache devices: {e}", style="red")
def _setup_partitioned_cache(self, pool_name: str, cache_devices: Dict[str, List[Disk]]):
"""Configura L2ARC y SLOG particionados en el mismo dispositivo"""
self.console.print_panel("Configurando L2ARC y SLOG particionados en el mismo dispositivo", title="🔀 Cache Particionado")
# Seleccionar dispositivo
device = self._select_cache_device(cache_devices, "Cache particionado")
if not device:
return
# Verificar tamaño mínimo (al menos 4GB para particionar)
if device.size < 4 * 1024**3: # 4GB
self.console.print("❌ El dispositivo debe tener al menos 4GB para particionado", style="red")
return
# Limpiar dispositivo
if not self._prepare_cache_device(device):
return
# Crear particiones
try:
self.console.print(f"🔧 Particionando {device.name}...")
# Calcular tamaños (SLOG: 10% o máximo 32GB, resto para L2ARC)
total_size = device.size
slog_size = min(int(total_size * 0.1), 32 * 1024**3) # 10% o 32GB máximo
# Limpiar tabla de particiones primero
self.console.print(" • Limpiando tabla de particiones...")
self.system.run_command(['sgdisk', '--zap-all', f'/dev/{device.name}'])
# Crear nueva tabla GPT y particiones
self.console.print(" • Creando particiones...")
# Crear SLOG (partición 1)
self.system.run_command(['sgdisk', '-n', f'1:0:+{slog_size // 512}', f'/dev/{device.name}'])
# Crear L2ARC (partición 2 - resto del espacio)
self.system.run_command(['sgdisk', '-n', '2:0:0', f'/dev/{device.name}'])
# Establecer etiquetas para identificar las particiones
self.system.run_command(['sgdisk', '-c', '1:ZFS-SLOG', f'/dev/{device.name}'])
self.system.run_command(['sgdisk', '-c', '2:ZFS-L2ARC', f'/dev/{device.name}'])
# Notificar al kernel sobre cambios en particiones
self.console.print(" • Actualizando tabla de particiones...")
self.system.run_command(['partprobe', f'/dev/{device.name}'])
self.system.run_command(['udevadm', 'settle'])
# Determinar nombres de particiones según el tipo de dispositivo
if device.name.startswith('nvme'):
slog_partition = f"{device.name}p1"
l2arc_partition = f"{device.name}p2"
else:
slog_partition = f"{device.name}1"
l2arc_partition = f"{device.name}2"
# Esperar a que las particiones estén disponibles
self.console.print(" • Esperando a que las particiones estén disponibles...")
import time
max_wait = 10
for i in range(max_wait):
if (Path(f'/dev/{slog_partition}').exists() and
Path(f'/dev/{l2arc_partition}').exists()):
break
time.sleep(1)
if i == max_wait - 1:
raise Exception(f"Las particiones no están disponibles después de {max_wait} segundos")
# Agregar particiones al pool
self.console.print("📦 Agregando particiones al pool...")
self.system.run_command(['zpool', 'add', pool_name, 'log', f'/dev/{slog_partition}'])
self.system.run_command(['zpool', 'add', pool_name, 'cache', f'/dev/{l2arc_partition}'])
self.console.print("✅ Cache particionado configurado exitosamente", style="green")
self.console.print(f" 📝 SLOG: {slog_partition} ({slog_size // (1024**3)}GB)")
self.console.print(f" 🚀 L2ARC: {l2arc_partition} ({(total_size - slog_size) // (1024**3)}GB)")
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error configurando cache particionado: {e}", style="red")
except Exception as e:
self.console.print(f"❌ Error configurando cache particionado: {e}", style="red")
def _select_cache_device(self, cache_devices: Dict[str, List[Disk]], purpose: str) -> Optional[Disk]:
"""Selecciona un dispositivo para cache"""
available_devices = cache_devices['nvme'] + cache_devices['ssd']
if not available_devices:
self.console.print(f"❌ No hay dispositivos disponibles para {purpose}", style="red")
return None
if len(available_devices) == 1:
device = available_devices[0]
device_type = "NVMe" if device.name.startswith('nvme') else "SSD"
if self.console.confirm(f"¿Usar {device.name} ({device.size_human} {device_type}) para {purpose}?", default=True):
return device
return None
# Mostrar opciones
self.console.print(f"\n💾 Dispositivos disponibles para {purpose}:")
for i, device in enumerate(available_devices, 1):
device_type = "🚀 NVMe" if device.name.startswith('nvme') else "💾 SSD"
self.console.print(f" {i}. {device_type} {device.name} - {device.size_human} - {device.model}")
while True:
choice = self.console.prompt("👉 Selecciona dispositivo", "1")
try:
index = int(choice) - 1
if 0 <= index < len(available_devices):
return available_devices[index]
else:
self.console.print("❌ Opción inválida", style="red")
except ValueError:
if choice.lower() == 'q':
return None
self.console.print("❌ Por favor ingresa un número válido", style="red")
def _prepare_cache_device(self, device: Disk) -> bool:
"""Prepara un dispositivo para usar como cache (limpia si es necesario)"""
# Verificar si el dispositivo tiene datos
if device.has_partitions or device.filesystem_type:
self.console.print_panel(
f"⚠️ ADVERTENCIA: El dispositivo {device.name} contiene datos\n"
f"Configurarlo como cache DESTRUIRÁ todos los datos existentes\n\n"
f"Datos detectados:\n"
f"• Particiones: {'Sí' if device.has_partitions else 'No'}\n"
f"• Filesystem: {device.filesystem_type or 'Ninguno'}\n"
f"• Puntos de montaje: {', '.join(device.mount_points) if device.mount_points else 'Ninguno'}",
title="🚨 Confirmación Destructiva",
style="red"
)
if not self.console.confirm(f"¿DESTRUIR todos los datos en {device.name} para configurar cache?", default=False):
self.console.print("❌ Operación cancelada", style="yellow")
return False
# Limpiar dispositivo
try:
self.console.print(f"🧹 Limpiando dispositivo {device.name}...")
# Desmontar particiones si están montadas
if device.mount_points:
for mount_point in device.mount_points:
try:
self.system.run_command(['umount', mount_point])
except subprocess.CalledProcessError:
pass
# Limpiar firmas de filesystem
self.system.run_command(['wipefs', '-a', f'/dev/{device.name}'])
# Limpiar tabla de particiones
self.system.run_command(['sgdisk', '-Z', f'/dev/{device.name}'])
self.console.print(f"✅ Dispositivo {device.name} preparado", style="green")
return True
except subprocess.CalledProcessError as e:
self.console.print(f"❌ Error preparando dispositivo: {e}", style="red")
return False
def manage_existing(self):
"""Gestiona pools/filesystems existentes"""
pass
def disk_tools(self):
"""Herramientas de disco"""
pass
def system_configuration(self):
"""Configuración del sistema"""
pass
def main():
"""Función principal"""
parser = argparse.ArgumentParser(description="RAID Manager para Raspberry Pi")
parser.add_argument("--debug", action="store_true", help="Modo debug")
parser.add_argument("--config", type=str, help="Archivo de configuración")
parser.add_argument("--skip-requirements", action="store_true",
help="Omitir verificación de requisitos (no recomendado)")
args = parser.parse_args()
# Verificar permisos
if os.geteuid() == 0:
print("❌ No ejecutes este script como root. Usa sudo cuando sea necesario.")
print("💡 El script solicitará sudo automáticamente cuando lo necesite.")
sys.exit(1)
try:
raid_manager = RAIDManager()
# Ejecutar con verificación de requisitos (a menos que se omita)
if args.skip_requirements:
print("⚠️ Omitiendo verificación de requisitos...")
raid_manager.main_menu()
else:
success = raid_manager.run()
if not success:
sys.exit(1)
except KeyboardInterrupt:
print("\n👋 Script interrumpido por el usuario")
sys.exit(0)
except Exception as e:
print(f"❌ Error inesperado: {e}")
if args.debug:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()