diff --git a/README.md b/README.md index d0efeff..56891cf 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ sudo ./enhanced_simple_backup.sh vg-to-borg internal-vg /path/to/borg/repo --pas - Automatic snapshot cleanup on errors - Emergency stop functionality (GUI) - Input validation and error handling +- Timestamped snapshot names to avoid collisions on retries +- Read-only snapshot mounts with filesystem-aware safety flags (ext4: noload, xfs: norecovery) +- Strict shell mode in CLI (set -euo pipefail) for reliable error propagation +- Pre-flight checks for VG free space before creating snapshots ## What This System Does NOT Do @@ -115,6 +119,11 @@ Previous versions of this project included complex migration logic that occasion ✅ Predictable behavior ✅ No hidden "smart" features +Notes and limitations: +- Multi-PV VGs: Raw clone and VG→Borg flows require careful handling. If a VG spans multiple PVs, the simple raw clone path is not supported; use block-level per-LV backups or full-disk cloning tools. +- File-level backups are for data safety, not bootability. Use block-level backups for exact bootable images. +- Ensure the Borg repository is not located on the same LV being snapshotted. + ## Recovery ### From LV Backup diff --git a/enhanced_simple_backup.sh b/enhanced_simple_backup.sh index 805fcaf..08dabab 100755 --- a/enhanced_simple_backup.sh +++ b/enhanced_simple_backup.sh @@ -6,7 +6,8 @@ # 2. LV → Raw: Create fresh backup on raw device # 3. VG → Raw: Clone entire volume group to raw device -set -e +set -euo pipefail +IFS=$'\n\t' # Colors RED='\033[0;31m' @@ -170,7 +171,7 @@ case "$MODE" in # Extract VG and LV names VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ') LV_NAME=$(lvs --noheadings -o lv_name "$SOURCE" | tr -d ' ') - SNAPSHOT_NAME="${LV_NAME}_backup_snap" + SNAPSHOT_NAME="${LV_NAME}_backup_snap_$(date +%s)" SNAPSHOT_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME" info "This will update the existing backup LV" @@ -216,7 +217,7 @@ case "$MODE" in # Extract VG and LV names VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ') LV_NAME=$(lvs --noheadings -o lv_name "$SOURCE" | tr -d ' ') - SNAPSHOT_NAME="${LV_NAME}_backup_snap" + SNAPSHOT_NAME="${LV_NAME}_backup_snap_$(date +%s)" SNAPSHOT_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME" info "This will create a fresh backup on raw device" @@ -303,7 +304,7 @@ case "$MODE" in # Extract VG and LV names VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ') LV_NAME=$(lvs --noheadings -o lv_name "$SOURCE" | tr -d ' ') - SNAPSHOT_NAME="${LV_NAME}_borg_snap" + SNAPSHOT_NAME="${LV_NAME}_borg_snap_$(date +%s)" SNAPSHOT_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME" # Get LV size to determine appropriate snapshot size @@ -425,7 +426,7 @@ case "$MODE" in # Process each LV one by one to avoid space issues for LV_NAME in $LV_LIST; do - SNAPSHOT_NAME="${LV_NAME}_borg_snap" + SNAPSHOT_NAME="${LV_NAME}_borg_snap_$(date +%s)" SNAPSHOT_PATH="/dev/$SOURCE/$SNAPSHOT_NAME" LV_PATH="/dev/$SOURCE/$LV_NAME" @@ -487,27 +488,21 @@ case "$MODE" in # Extract VG and LV names VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ') LV_NAME=$(lvs --noheadings -o lv_name "$SOURCE" | tr -d ' ') - SNAPSHOT_NAME="${LV_NAME}_files_snap" + SNAPSHOT_NAME="${LV_NAME}_files_snap_$(date +%s)" SNAPSHOT_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME" - # Get LV size to determine appropriate snapshot size + # Get LV size to determine appropriate snapshot size (file-level needs much smaller) LV_SIZE_BYTES=$(lvs --noheadings -o lv_size --units b "$SOURCE" | tr -d ' ' | sed 's/B$//') - # Use different percentages based on options if [ "$GENEROUS_SNAPSHOTS" = true ]; then - SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES / 4)) # 25% - MIN_SIZE=$((2 * 1024 * 1024 * 1024)) # 2GB minimum + # File-level generous: 5% with 1G min, 20G max + SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES * 5 / 100)) + [ $SNAPSHOT_SIZE_BYTES -lt $((1 * 1024 * 1024 * 1024)) ] && SNAPSHOT_SIZE_BYTES=$((1 * 1024 * 1024 * 1024)) + [ $SNAPSHOT_SIZE_BYTES -gt $((20 * 1024 * 1024 * 1024)) ] && SNAPSHOT_SIZE_BYTES=$((20 * 1024 * 1024 * 1024)) else - # Auto mode: 10% normally, 15% for large LVs - if [ $LV_SIZE_BYTES -gt $((50 * 1024 * 1024 * 1024)) ]; then - SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES * 15 / 100)) # 15% for >50GB - else - SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES / 10)) # 10% normally - fi - MIN_SIZE=$((1024 * 1024 * 1024)) # 1GB minimum - fi - - if [ $SNAPSHOT_SIZE_BYTES -lt $MIN_SIZE ]; then - SNAPSHOT_SIZE_BYTES=$MIN_SIZE + # Auto: 3% with 1G min, 15G max + SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES * 3 / 100)) + [ $SNAPSHOT_SIZE_BYTES -lt $((1 * 1024 * 1024 * 1024)) ] && SNAPSHOT_SIZE_BYTES=$((1 * 1024 * 1024 * 1024)) + [ $SNAPSHOT_SIZE_BYTES -gt $((15 * 1024 * 1024 * 1024)) ] && SNAPSHOT_SIZE_BYTES=$((15 * 1024 * 1024 * 1024)) fi SNAPSHOT_SIZE_GB=$((SNAPSHOT_SIZE_BYTES / 1073741824)) SNAPSHOT_SIZE="${SNAPSHOT_SIZE_GB}G" @@ -549,9 +544,17 @@ case "$MODE" in # Create temporary mount point TEMP_MOUNT=$(mktemp -d -t borg_files_backup_XXXXXX) - # Mount snapshot - log "Mounting snapshot to $TEMP_MOUNT" - mount "$SNAPSHOT_PATH" "$TEMP_MOUNT" || error "Failed to mount snapshot" + # Mount snapshot read-only with safe FS options + FS_TYPE=$(blkid -o value -s TYPE "$SNAPSHOT_PATH" 2>/dev/null || echo "") + if [ "$FS_TYPE" = "ext4" ] || [ "$FS_TYPE" = "ext3" ]; then + MNT_OPTS="ro,noload" + elif [ "$FS_TYPE" = "xfs" ]; then + MNT_OPTS="ro,norecovery" + else + MNT_OPTS="ro" + fi + log "Mounting snapshot to $TEMP_MOUNT (opts: $MNT_OPTS)" + mount -o "$MNT_OPTS" "$SNAPSHOT_PATH" "$TEMP_MOUNT" || error "Failed to mount snapshot" # Create Borg archive ARCHIVE_NAME="files_${LV_NAME}_$(date +%Y%m%d_%H%M%S)" diff --git a/simple_backup_gui.py b/simple_backup_gui.py index 7e8286d..5e2f3d9 100755 --- a/simple_backup_gui.py +++ b/simple_backup_gui.py @@ -11,18 +11,60 @@ import subprocess import threading import os import time +import json class SimpleBackupGUI: def __init__(self, root): self.root = root self.root.title("Simple LVM Backup") - self.root.geometry("600x400") + self.root.geometry("1200x800") # Made much bigger: was 900x600 # State tracking self.backup_running = False self.current_snapshot = None + # Settings file for remembering preferences + if 'SUDO_USER' in os.environ: + user_home = f"/home/{os.environ['SUDO_USER']}" + else: + user_home = os.path.expanduser("~") + self.settings_file = os.path.join(user_home, ".simple_backup_settings.json") + self.settings = self.load_settings() + self.setup_ui() + + def load_settings(self): + """Load settings from file""" + try: + if os.path.exists(self.settings_file): + with open(self.settings_file, 'r') as f: + return json.load(f) + except Exception as e: + # Use print since log method isn't available yet + print(f"Could not load settings: {e}") + return {} + + def save_settings(self): + """Save settings to file""" + try: + with open(self.settings_file, 'w') as f: + json.dump(self.settings, f, indent=2) + except Exception as e: + if hasattr(self, 'log'): + self.log(f"Could not save settings: {e}") + else: + print(f"Could not save settings: {e}") + + def update_borg_settings(self): + """Update Borg settings when backup starts""" + if hasattr(self, 'repo_path_var'): + self.settings['last_borg_repo'] = self.repo_path_var.get() + self.settings['last_encryption'] = getattr(self, 'encryption_var', tk.StringVar()).get() + self.settings['last_snapshot_size'] = getattr(self, 'snapshot_size_var', tk.StringVar()).get() + self.log(f"Saving settings: repo={self.settings.get('last_borg_repo', 'none')}") + self.save_settings() + else: + self.log("repo_path_var not available for settings update") self.refresh_drives() # Initialize with correct widgets for default mode def setup_ui(self): @@ -100,6 +142,14 @@ class SimpleBackupGUI: # Repo path ttk.Label(self.borg_frame, text="Repository Path:").grid(row=0, column=0, sticky=tk.W, pady=2) self.repo_path_var = tk.StringVar() + # Load saved repository path + if 'last_borg_repo' in self.settings: + saved_repo = self.settings['last_borg_repo'] + self.repo_path_var.set(saved_repo) + self.log(f"Loaded saved repository: {saved_repo}") + else: + self.log("No saved repository found") + repo_entry = ttk.Entry(self.borg_frame, textvariable=self.repo_path_var, width=40) repo_entry.grid(row=0, column=1, sticky=(tk.W, tk.E), pady=2) ttk.Button(self.borg_frame, text="Browse", command=self.browse_repo).grid(row=0, column=2, padx=5, pady=2) @@ -156,17 +206,26 @@ class SimpleBackupGUI: command=self.emergency_stop, state="disabled") self.stop_btn.grid(row=4, column=2, pady=10) + # Management buttons row + mgmt_frame = ttk.Frame(main_frame) + mgmt_frame.grid(row=5, column=0, columnspan=3, pady=10) + + ttk.Button(mgmt_frame, text="Manage Borg Repo", + command=self.manage_borg_repo).pack(side=tk.LEFT, padx=5) + ttk.Button(mgmt_frame, text="Manage LVM Snapshots", + command=self.manage_snapshots).pack(side=tk.LEFT, padx=5) + # Progress area - ttk.Label(main_frame, text="Progress:").grid(row=5, column=0, sticky=tk.W, pady=(20, 5)) + ttk.Label(main_frame, text="Progress:").grid(row=6, column=0, sticky=tk.W, pady=(20, 5)) self.progress = ttk.Progressbar(main_frame, mode='indeterminate') - self.progress.grid(row=6, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) + self.progress.grid(row=7, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5) # Log area - ttk.Label(main_frame, text="Log:").grid(row=7, column=0, sticky=tk.W, pady=(10, 5)) + ttk.Label(main_frame, text="Log:").grid(row=8, column=0, sticky=tk.W, pady=(10, 5)) log_frame = ttk.Frame(main_frame) - log_frame.grid(row=8, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) + log_frame.grid(row=9, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=5) self.log_text = tk.Text(log_frame, height=15, width=70) scrollbar = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview) @@ -179,7 +238,7 @@ class SimpleBackupGUI: self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) main_frame.columnconfigure(1, weight=1) - main_frame.rowconfigure(8, weight=1) + main_frame.rowconfigure(9, weight=1) # Updated for new log row log_frame.columnconfigure(0, weight=1) log_frame.rowconfigure(0, weight=1) @@ -206,29 +265,81 @@ class SimpleBackupGUI: def log(self, message): """Add message to log""" timestamp = time.strftime("%H:%M:%S") - self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") - self.log_text.see(tk.END) - self.root.update_idletasks() + # Check if log_text widget exists yet + if hasattr(self, 'log_text'): + self.log_text.insert(tk.END, f"[{timestamp}] {message}\n") + self.log_text.see(tk.END) + self.root.update_idletasks() + else: + # Print to console if GUI log not available yet + print(f"[{timestamp}] {message}") - def calculate_snapshot_size(self, lv_size_bytes): - """Calculate appropriate snapshot size based on LV size and user preference""" + def calculate_snapshot_size(self, lv_size_bytes, backup_mode="block"): + """Calculate appropriate snapshot size based on LV size, user preference, and backup mode""" mode = self.snapshot_size_var.get() - if mode == "conservative": - # 5% of LV size, minimum 1GB - snapshot_size_bytes = max(lv_size_bytes // 20, 1024**3) - elif mode == "generous": - # 25% of LV size, minimum 2GB - snapshot_size_bytes = max(lv_size_bytes // 4, 2 * 1024**3) - else: # auto - # 10% of LV size, minimum 1GB, but increase for very large LVs - base_percentage = 10 - if lv_size_bytes > 50 * 1024**3: # >50GB - base_percentage = 15 # Use 15% for large, potentially active LVs - snapshot_size_bytes = max(lv_size_bytes * base_percentage // 100, 1024**3) + # For file-level backups, use much smaller snapshots + if backup_mode == "file": + if mode == "conservative": + # 2% of LV size, minimum 512MB, maximum 10GB + snapshot_size_bytes = max(min(lv_size_bytes // 50, 10 * 1024**3), 512 * 1024**2) + elif mode == "generous": + # 5% of LV size, minimum 1GB, maximum 20GB + snapshot_size_bytes = max(min(lv_size_bytes // 20, 20 * 1024**3), 1024**3) + else: # auto + # 3% of LV size, minimum 1GB, maximum 15GB + snapshot_size_bytes = max(min(lv_size_bytes * 3 // 100, 15 * 1024**3), 1024**3) + else: + # Original logic for block-level backups + if mode == "conservative": + # 5% of LV size, minimum 1GB + snapshot_size_bytes = max(lv_size_bytes // 20, 1024**3) + elif mode == "generous": + # 25% of LV size, minimum 2GB + snapshot_size_bytes = max(lv_size_bytes // 4, 2 * 1024**3) + else: # auto + # 10% of LV size, minimum 1GB, but increase for very large LVs + base_percentage = 10 + if lv_size_bytes > 50 * 1024**3: # >50GB + base_percentage = 15 # Use 15% for large, potentially active LVs + snapshot_size_bytes = max(lv_size_bytes * base_percentage // 100, 1024**3) snapshot_size_gb = snapshot_size_bytes // (1024**3) return f"{snapshot_size_gb}G" + + def _parse_size_to_bytes(self, size_str): + """Parse simple size strings like '10G' to bytes (supports G and M).""" + try: + s = size_str.strip().upper() + if s.endswith('G'): + return int(s[:-1]) * (1024 ** 3) + if s.endswith('M'): + return int(s[:-1]) * (1024 ** 2) + # Fallback: raw int bytes + return int(s) + except Exception: + return None + + def _vg_free_bytes(self, vg_name): + """Return VG free space in bytes or None on failure.""" + ok, out = self.run_command(f"vgs --noheadings -o vg_free --units b {vg_name}", show_output=False) + if not ok or not out.strip(): + return None + try: + return int(out.strip().replace('B', '').strip()) + except Exception: + return None + + def _ensure_vg_has_space(self, vg_name, snapshot_size_str): + """Raise Exception if VG does not have enough free space for requested snapshot size.""" + needed = self._parse_size_to_bytes(snapshot_size_str) + if needed is None: + return # cannot parse; skip strict check + free_b = self._vg_free_bytes(vg_name) + if free_b is None: + return # unknown; skip strict check + if free_b < needed: + raise Exception(f"Insufficient VG free space: need {snapshot_size_str}, free {free_b // (1024**3)}G in VG {vg_name}") def run_command(self, cmd, show_output=True): """Run a command and return result""" @@ -252,6 +363,81 @@ class SimpleBackupGUI: self.log(f"ERROR: {str(e)}") return False, str(e) + def run_interactive_command(self, cmd): + """Run an interactive command in a terminal window""" + try: + self.log(f"Running interactive: {cmd}") + + # Try different terminal emulators + terminals = [ + f"x-terminal-emulator -e bash -c '{cmd}; read -p \"Press Enter to continue...\"'", + f"xterm -e bash -c '{cmd}; read -p \"Press Enter to continue...\"'", + f"konsole -e bash -c '{cmd}; read -p \"Press Enter to continue...\"'", + f"gnome-terminal --wait -- bash -c '{cmd}; read -p \"Press Enter to continue...\"'", + f"xfce4-terminal -e 'bash -c \"{cmd}; read -p \\\"Press Enter to continue...\\\"\"; bash'" + ] + + for terminal_cmd in terminals: + try: + result = subprocess.run(terminal_cmd, shell=True, timeout=300) # 5 minute timeout + if result.returncode == 0: + return True + except (subprocess.TimeoutExpired, FileNotFoundError): + continue + + # If no terminal worked, try a simpler approach + self.log("No terminal emulator found, trying direct execution...") + result = subprocess.run(cmd, shell=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + return result.returncode == 0 + + except Exception as e: + self.log(f"ERROR running interactive command: {str(e)}") + return False + + def get_luks_passphrase(self): + """Get LUKS passphrase via GUI dialog""" + from tkinter import simpledialog + try: + passphrase = simpledialog.askstring( + "LUKS Passphrase", + "Enter LUKS passphrase for encrypted volume:", + show='*' # Hide password input + ) + return passphrase + except Exception as e: + self.log(f"Error getting passphrase: {e}") + return None + + def open_luks_device(self, device_path, luks_name, passphrase): + """Open LUKS device with provided passphrase""" + try: + # Avoid exposing secrets via shell; pass via stdin + result = subprocess.run( + ["cryptsetup", "luksOpen", device_path, luks_name], + input=(passphrase or "") + "\n", + text=True, + capture_output=True, + ) + if result.returncode == 0: + self.log("LUKS device opened successfully") + return True + self.log(f"Failed to open LUKS device: {result.stderr.strip()}") + return False + except Exception as e: + self.log(f"Error opening LUKS device: {e}") + return False + + def _fs_mount_opts(self, device_path): + """Return safe read-only mount options based on filesystem type.""" + fs_ok, fs_type = self.run_command(f"blkid -o value -s TYPE {device_path}", show_output=False) + fs_type = fs_type.strip().lower() if fs_ok and fs_type else "" + if fs_type == "ext4" or fs_type == "ext3": + return "ro,noload" + if fs_type == "xfs": + return "ro,norecovery" + # default safe read-only + return "ro" + def refresh_drives(self): """Refresh available drives based on selected mode""" mode = self.mode_var.get() @@ -432,6 +618,9 @@ class SimpleBackupGUI: self.stop_btn.config(state="normal") self.progress.start() + # Save settings before starting backup + self.update_borg_settings() + thread = threading.Thread(target=self.multi_lv_backup_worker, args=(mode, selected_sources, repo_path)) thread.daemon = True thread.start() @@ -493,6 +682,9 @@ class SimpleBackupGUI: self.stop_btn.config(state="normal") self.progress.start() + # Save settings before starting backup + self.update_borg_settings() + thread = threading.Thread(target=self.backup_worker, args=(mode, source, target)) thread.daemon = True thread.start() @@ -500,6 +692,11 @@ class SimpleBackupGUI: def backup_worker(self, mode, source, target): """The actual backup work""" try: + # Check if stop was requested + if not self.backup_running: + self.log("Backup stopped before starting") + return + self.log(f"=== Starting {mode} backup ===") if mode == "lv_to_lv": @@ -530,11 +727,21 @@ class SimpleBackupGUI: def multi_lv_backup_worker(self, mode, selected_sources, repo_path): """Backup multiple LVs sequentially""" try: + # Check if stop was requested + if not self.backup_running: + self.log("Multi-LV backup stopped before starting") + return + self.log(f"=== Starting multi-LV {mode} backup ===") self.log(f"Selected {len(selected_sources)} logical volumes") # Initialize repository once if needed if self.create_new_repo.get(): + # Check for stop again before repo creation + if not self.backup_running: + self.log("Backup stopped during repository initialization") + return + borg_env = os.environ.copy() if self.passphrase_var.get(): borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get() @@ -601,10 +808,15 @@ class SimpleBackupGUI: # Create snapshot of source vg_name = source_lv.split('/')[2] lv_name = source_lv.split('/')[3] - snapshot_name = f"{lv_name}_backup_snap" + snapshot_name = f"{lv_name}_backup_snap_{int(time.time())}" self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}" self.log(f"Creating snapshot: {snapshot_name}") + # Pre-flight VG free check for 1G default + try: + self._ensure_vg_has_space(vg_name, "1G") + except Exception as e: + raise Exception(str(e)) success, output = self.run_command(f"lvcreate -L1G -s -n {snapshot_name} {source_lv}") if not success: raise Exception(f"Failed to create snapshot: {output}") @@ -639,10 +851,15 @@ class SimpleBackupGUI: # Create snapshot of source vg_name = source_lv.split('/')[2] lv_name = source_lv.split('/')[3] - snapshot_name = f"{lv_name}_backup_snap" + snapshot_name = f"{lv_name}_backup_snap_{int(time.time())}" self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}" self.log(f"Creating snapshot: {snapshot_name}") + # Pre-flight VG free check for 1G default + try: + self._ensure_vg_has_space(vg_name, "1G") + except Exception as e: + raise Exception(str(e)) success, output = self.run_command(f"lvcreate -L1G -s -n {snapshot_name} {source_lv}") if not success: raise Exception(f"Failed to create snapshot: {output}") @@ -732,7 +949,7 @@ class SimpleBackupGUI: # Create snapshot vg_name = source_lv.split('/')[2] lv_name = source_lv.split('/')[3] - snapshot_name = f"{lv_name}_borg_snap" + snapshot_name = f"{lv_name}_borg_snap_{int(time.time())}" self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}" # Get LV size to determine appropriate snapshot size @@ -744,6 +961,11 @@ class SimpleBackupGUI: snapshot_size = "2G" # Default fallback self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})") + # Pre-flight VG free space + try: + self._ensure_vg_has_space(vg_name, snapshot_size) + except Exception as e: + raise Exception(str(e)) success, output = self.run_command(f"lvcreate -L{snapshot_size} -s -n {snapshot_name} {source_lv}") if not success: raise Exception(f"Failed to create snapshot: {output}") @@ -830,7 +1052,7 @@ class SimpleBackupGUI: try: # Process each LV one by one to avoid space issues for lv_name in lv_names: - snapshot_name = f"{lv_name}_borg_snap" + snapshot_name = f"{lv_name}_borg_snap_{int(time.time())}" snapshot_path = f"/dev/{source_vg}/{snapshot_name}" lv_path = f"/dev/{source_vg}/{lv_name}" @@ -844,6 +1066,12 @@ class SimpleBackupGUI: self.log(f"Processing LV: {lv_name}") self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})") + # Pre-flight VG free space + try: + self._ensure_vg_has_space(source_vg, snapshot_size) + except Exception as e: + self.log(f"Skipping {lv_name}: {e}") + continue success, output = self.run_command(f"lvcreate -L{snapshot_size} -s -n {snapshot_name} {lv_path}") if not success: self.log(f"Warning: Failed to create snapshot for {lv_name}: {output}") @@ -930,18 +1158,25 @@ class SimpleBackupGUI: # Create snapshot vg_name = source_lv.split('/')[2] lv_name = source_lv.split('/')[3] - snapshot_name = f"{lv_name}_files_snap" + # Use timestamp to ensure unique snapshot names + timestamp = int(time.time()) + snapshot_name = f"{lv_name}_files_snap_{timestamp}" self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}" # Get LV size to determine appropriate snapshot size success, lv_size_output = self.run_command(f"lvs --noheadings -o lv_size --units b {source_lv}", show_output=False) if success: lv_size_bytes = int(lv_size_output.strip().replace('B', '')) - snapshot_size = self.calculate_snapshot_size(lv_size_bytes) + snapshot_size = self.calculate_snapshot_size(lv_size_bytes, backup_mode="file") else: snapshot_size = "2G" # Default fallback self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})") + # Pre-flight VG free space + try: + self._ensure_vg_has_space(vg_name, snapshot_size) + except Exception as e: + raise Exception(str(e)) success, output = self.run_command(f"lvcreate -L{snapshot_size} -s -n {snapshot_name} {source_lv}") if not success: raise Exception(f"Failed to create snapshot: {output}") @@ -949,13 +1184,60 @@ class SimpleBackupGUI: # Create temporary mount point import tempfile temp_mount = tempfile.mkdtemp(prefix="borg_files_backup_") + luks_device = None try: - # Mount the snapshot - self.log(f"Mounting snapshot to {temp_mount}") - success, output = self.run_command(f"mount {self.current_snapshot} {temp_mount}") - if not success: - raise Exception(f"Failed to mount snapshot: {output}") + # Check if the snapshot is LUKS encrypted + self.log("Checking if volume is LUKS encrypted...") + + # Resolve the real device path in case of symbolic links + real_device_success, real_device_path = self.run_command(f"readlink -f {self.current_snapshot}", show_output=False) + device_to_check = real_device_path.strip() if real_device_success else self.current_snapshot + self.log(f"Checking device: {device_to_check}") + + check_success, check_output = self.run_command(f"file -s {device_to_check}", show_output=False) + self.log(f"File command result: success={check_success}, output='{check_output}'") + + # Also try cryptsetup isLuks command which is more reliable + luks_check_success, luks_check_output = self.run_command(f"cryptsetup isLuks {device_to_check}", show_output=False) + self.log(f"cryptsetup isLuks result: success={luks_check_success}") + + is_luks = (check_success and "LUKS" in check_output) or luks_check_success + self.log(f"Final LUKS detection result: {is_luks}") + + if is_luks: + self.log("LUKS encryption detected - opening encrypted device") + luks_name = f"borg_backup_{lv_name}_{int(time.time())}" + luks_device = f"/dev/mapper/{luks_name}" + + # Open LUKS device (this will prompt for password) + self.log("LUKS encryption detected - need passphrase") + + # Get passphrase via GUI dialog + passphrase = self.get_luks_passphrase() + if not passphrase: + raise Exception("LUKS passphrase required but not provided") + + self.log("Opening LUKS device with provided passphrase...") + success = self.open_luks_device(device_to_check, luks_name, passphrase) + if not success: + raise Exception(f"Failed to open LUKS device - check passphrase") + + # Mount the decrypted device + self.log(f"Mounting decrypted device to {temp_mount}") + mount_opts = self._fs_mount_opts(luks_device) + success, output = self.run_command(f"mount -o {mount_opts} {luks_device} {temp_mount}") + if not success: + # Clean up LUKS device + self.run_command(f"cryptsetup luksClose {luks_name}", show_output=False) + raise Exception(f"Failed to mount decrypted device: {output}") + else: + # Mount the snapshot directly (non-encrypted) + self.log(f"Mounting snapshot to {temp_mount}") + mount_opts = self._fs_mount_opts(self.current_snapshot) + success, output = self.run_command(f"mount -o {mount_opts} {self.current_snapshot} {temp_mount}") + if not success: + raise Exception(f"Failed to mount snapshot: {output}") # Create Borg backup of files archive_name = f"files_{lv_name}_{time.strftime('%Y%m%d_%H%M%S')}" @@ -983,6 +1265,13 @@ class SimpleBackupGUI: # Cleanup: unmount and remove temp directory self.log("Cleaning up mount point") self.run_command(f"umount {temp_mount}", show_output=False) + + # Close LUKS device if it was opened + if luks_device: + luks_name = luks_device.split('/')[-1] + self.log("Closing LUKS device") + self.run_command(f"cryptsetup luksClose {luks_name}", show_output=False) + os.rmdir(temp_mount) # Remove snapshot @@ -1010,15 +1299,210 @@ class SimpleBackupGUI: """Emergency stop - kill any running processes""" self.log("EMERGENCY STOP requested") - # Kill any dd or pv processes - self.run_command("pkill -f 'dd.*if=.*dev'", show_output=False) - self.run_command("pkill -f 'pv.*dev'", show_output=False) - - self.cleanup_on_error() + # Set stop flag for backup workers to check self.backup_running = False + + # Kill various backup-related processes + processes_to_kill = [ + "pkill -f 'dd.*if=.*dev'", # dd processes + "pkill -f 'pv.*dev'", # pv processes + "pkill -f 'borg create'", # borg backup processes + "pkill -f 'borg init'", # borg init processes + "pkill -f 'lvcreate.*snap'", # snapshot creation + "pkill -f 'cryptsetup luksOpen'", # LUKS operations + ] + + for cmd in processes_to_kill: + self.run_command(cmd, show_output=False) + + # Force cleanup + self.log("Performing emergency cleanup...") + self.cleanup_on_error() + + # Reset UI immediately self.reset_ui_state() - messagebox.showwarning("Stopped", "Backup process stopped. Check log for any cleanup needed.") + messagebox.showwarning("Emergency Stop", + "Backup process stopped!\n\n" + + "- All backup processes killed\n" + + "- Snapshots cleaned up\n" + + "- LUKS devices closed\n\n" + + "Check log for details.") + + def cleanup_on_error(self): + """Clean up resources on error or stop""" + try: + # Clean up any current snapshot + if hasattr(self, 'current_snapshot') and self.current_snapshot: + self.log(f"Emergency cleanup: removing snapshot {self.current_snapshot}") + self.run_command(f"lvremove -f {self.current_snapshot}", show_output=False) + self.current_snapshot = None + + # Clean up any mount points + self.run_command("umount /tmp/borg_files_backup_* 2>/dev/null", show_output=False) + + # Close any LUKS devices we might have opened + self.run_command("cryptsetup luksClose borg_backup_* 2>/dev/null", show_output=False) + + # Remove any temp directories + self.run_command("rmdir /tmp/borg_files_backup_* 2>/dev/null", show_output=False) + + except Exception as e: + self.log(f"Error during cleanup: {e}") + + def manage_snapshots(self): + """Open LVM snapshot management window""" + snapshot_window = tk.Toplevel(self.root) + snapshot_window.title("LVM Snapshot Manager") + snapshot_window.geometry("800x500") + + # Main frame + frame = ttk.Frame(snapshot_window, padding="10") + frame.pack(fill=tk.BOTH, expand=True) + + ttk.Label(frame, text="LVM Snapshots:").pack(anchor=tk.W, pady=(0, 5)) + + # Listbox for snapshots + list_frame = ttk.Frame(frame) + list_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + self.snapshot_listbox = tk.Listbox(list_frame, selectmode=tk.EXTENDED) + snap_scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.snapshot_listbox.yview) + self.snapshot_listbox.configure(yscrollcommand=snap_scrollbar.set) + + self.snapshot_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + snap_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Buttons + btn_frame = ttk.Frame(frame) + btn_frame.pack(fill=tk.X, pady=(10, 0)) + + ttk.Button(btn_frame, text="Refresh", command=self.refresh_snapshots).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(btn_frame, text="Remove Selected", command=self.remove_snapshots).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Remove All", command=self.remove_all_snapshots).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Close", command=snapshot_window.destroy).pack(side=tk.RIGHT) + + # Load snapshots + self.refresh_snapshots() + + def refresh_snapshots(self): + """Refresh the snapshot list""" + try: + self.snapshot_listbox.delete(0, tk.END) + success, output = self.run_command("lvs --noheadings -o lv_name,vg_name,lv_size,origin | grep -E '_snap|snap_'", show_output=False) + + if success and output.strip(): + for line in output.strip().split('\n'): + if line.strip(): + parts = line.strip().split() + if len(parts) >= 4: + lv_name = parts[0] + vg_name = parts[1] + lv_size = parts[2] + origin = parts[3] + display_text = f"/dev/{vg_name}/{lv_name} ({lv_size}) -> {origin}" + self.snapshot_listbox.insert(tk.END, display_text) + else: + self.snapshot_listbox.insert(tk.END, "No snapshots found") + + except Exception as e: + messagebox.showerror("Error", f"Failed to list snapshots: {e}") + + def remove_snapshots(self): + """Remove selected snapshots""" + selected = self.snapshot_listbox.curselection() + if not selected: + messagebox.showwarning("Warning", "Please select snapshots to remove") + return + + snapshot_paths = [] + for idx in selected: + line = self.snapshot_listbox.get(idx) + if "No snapshots found" in line: + continue + # Extract /dev/vg/lv path from display text + path = line.split(' ')[0] + snapshot_paths.append(path) + + if not snapshot_paths: + return + + if messagebox.askyesno("Confirm", f"Remove {len(snapshot_paths)} snapshot(s)?\n\n" + "\n".join(snapshot_paths)): + for path in snapshot_paths: + success, output = self.run_command(f"lvremove -f {path}") + if success: + self.log(f"Removed snapshot: {path}") + else: + self.log(f"Failed to remove {path}: {output}") + + self.refresh_snapshots() + + def remove_all_snapshots(self): + """Remove all snapshots after confirmation""" + if messagebox.askyesno("Confirm", "Remove ALL snapshots? This cannot be undone!"): + success, output = self.run_command("lvs --noheadings -o lv_path | grep -E '_snap|snap_' | xargs -r lvremove -f") + if success: + self.log("All snapshots removed") + else: + self.log(f"Error removing snapshots: {output}") + self.refresh_snapshots() + + def manage_borg_repo(self): + """Open Borg repository management window""" + # Get repo path + if not hasattr(self, 'repo_path_var') or not self.repo_path_var.get().strip(): + messagebox.showerror("Error", "Please set a Borg repository path first") + return + + repo_path = self.repo_path_var.get().strip() + + # Check if repo exists + if not os.path.exists(repo_path): + messagebox.showerror("Error", f"Repository path does not exist: {repo_path}") + return + + borg_window = tk.Toplevel(self.root) + borg_window.title(f"Borg Repository Manager - {repo_path}") + borg_window.geometry("900x600") + + # Main frame + frame = ttk.Frame(borg_window, padding="10") + frame.pack(fill=tk.BOTH, expand=True) + + # Repository info + info_frame = ttk.LabelFrame(frame, text="Repository Information", padding="5") + info_frame.pack(fill=tk.X, pady=(0, 10)) + + ttk.Label(info_frame, text=f"Path: {repo_path}").pack(anchor=tk.W) + + # Archives list + ttk.Label(frame, text="Archives:").pack(anchor=tk.W, pady=(0, 5)) + + list_frame = ttk.Frame(frame) + list_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) + + self.archive_listbox = tk.Listbox(list_frame, selectmode=tk.EXTENDED) + arch_scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.archive_listbox.yview) + self.archive_listbox.configure(yscrollcommand=arch_scrollbar.set) + + self.archive_listbox.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + arch_scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + # Buttons + btn_frame = ttk.Frame(frame) + btn_frame.pack(fill=tk.X, pady=(10, 0)) + + ttk.Button(btn_frame, text="Refresh", command=lambda: self.refresh_archives(repo_path)).pack(side=tk.LEFT, padx=(0, 5)) + ttk.Button(btn_frame, text="Mount Archive", command=lambda: self.mount_archive(repo_path)).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Delete Selected", command=lambda: self.delete_archives(repo_path)).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Repository Info", command=lambda: self.show_repo_info(repo_path)).pack(side=tk.LEFT, padx=5) + ttk.Button(btn_frame, text="Close", command=borg_window.destroy).pack(side=tk.RIGHT) + + # Store reference for other methods + self.current_repo_path = repo_path + + # Load archives + self.refresh_archives(repo_path) def reset_ui_state(self): """Reset UI to normal state""" @@ -1026,6 +1510,137 @@ class SimpleBackupGUI: self.backup_btn.config(state="normal") self.stop_btn.config(state="disabled") self.progress.stop() + + def refresh_archives(self, repo_path): + """Refresh the archive list""" + try: + self.archive_listbox.delete(0, tk.END) + + # Set up environment for Borg + borg_env = os.environ.copy() + if hasattr(self, 'passphrase_var') and self.passphrase_var.get(): + borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get() + + cmd = f"borg list {repo_path}" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, env=borg_env) + + if result.returncode == 0: + if result.stdout.strip(): + for line in result.stdout.strip().split('\n'): + if line.strip(): + self.archive_listbox.insert(tk.END, line.strip()) + else: + self.archive_listbox.insert(tk.END, "No archives found") + else: + self.archive_listbox.insert(tk.END, f"Error: {result.stderr}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to list archives: {e}") + + def mount_archive(self, repo_path): + """Mount selected archive""" + selected = self.archive_listbox.curselection() + if not selected: + messagebox.showwarning("Warning", "Please select an archive to mount") + return + + archive_line = self.archive_listbox.get(selected[0]) + if "No archives found" in archive_line or "Error:" in archive_line: + return + + # Extract archive name (first word) + archive_name = archive_line.split()[0] + + # Ask for mount point + from tkinter import filedialog + mount_point = filedialog.askdirectory(title="Select mount point directory") + if not mount_point: + return + + try: + # Set up environment + borg_env = os.environ.copy() + if hasattr(self, 'passphrase_var') and self.passphrase_var.get(): + borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get() + + cmd = f"borg mount {repo_path}::{archive_name} {mount_point}" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, env=borg_env) + + if result.returncode == 0: + messagebox.showinfo("Success", f"Archive mounted at: {mount_point}\n\nTo unmount: fusermount -u {mount_point}") + self.log(f"Mounted archive {archive_name} at {mount_point}") + else: + messagebox.showerror("Error", f"Failed to mount archive: {result.stderr}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to mount archive: {e}") + + def delete_archives(self, repo_path): + """Delete selected archives""" + selected = self.archive_listbox.curselection() + if not selected: + messagebox.showwarning("Warning", "Please select archives to delete") + return + + archive_names = [] + for idx in selected: + line = self.archive_listbox.get(idx) + if "No archives found" in line or "Error:" in line: + continue + archive_names.append(line.split()[0]) + + if not archive_names: + return + + if messagebox.askyesno("Confirm", f"Delete {len(archive_names)} archive(s)?\n\n" + "\n".join(archive_names) + "\n\nThis cannot be undone!"): + try: + borg_env = os.environ.copy() + if hasattr(self, 'passphrase_var') and self.passphrase_var.get(): + borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get() + + for archive_name in archive_names: + cmd = f"borg delete {repo_path}::{archive_name}" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, env=borg_env) + + if result.returncode == 0: + self.log(f"Deleted archive: {archive_name}") + else: + self.log(f"Failed to delete {archive_name}: {result.stderr}") + + self.refresh_archives(repo_path) + + except Exception as e: + messagebox.showerror("Error", f"Failed to delete archives: {e}") + + def show_repo_info(self, repo_path): + """Show repository information""" + try: + borg_env = os.environ.copy() + if hasattr(self, 'passphrase_var') and self.passphrase_var.get(): + borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get() + + cmd = f"borg info {repo_path}" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, env=borg_env) + + if result.returncode == 0: + info_window = tk.Toplevel(self.root) + info_window.title("Repository Information") + info_window.geometry("600x400") + + text_widget = tk.Text(info_window, wrap=tk.WORD) + scrollbar = ttk.Scrollbar(info_window, orient="vertical", command=text_widget.yview) + text_widget.configure(yscrollcommand=scrollbar.set) + + text_widget.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + + text_widget.insert(tk.END, result.stdout) + text_widget.config(state=tk.DISABLED) + else: + messagebox.showerror("Error", f"Failed to get repository info: {result.stderr}") + + except Exception as e: + messagebox.showerror("Error", f"Failed to get repository info: {e}") def main(): # Check if running as root