diff --git a/enhanced_simple_backup.sh b/enhanced_simple_backup.sh index 15759a3..805fcaf 100755 --- a/enhanced_simple_backup.sh +++ b/enhanced_simple_backup.sh @@ -24,25 +24,28 @@ usage() { echo " $0 vg-to-raw SOURCE_VG TARGET_DEVICE" echo " $0 lv-to-borg SOURCE_LV REPO_PATH [--new-repo] [--encryption MODE] [--passphrase PASS]" echo " $0 vg-to-borg SOURCE_VG REPO_PATH [--new-repo] [--encryption MODE] [--passphrase PASS]" + echo " $0 files-to-borg SOURCE_LV REPO_PATH [--new-repo] [--encryption MODE] [--passphrase PASS]" echo "" echo "Modes:" echo " lv-to-lv - Update existing LV backup (SOURCE_LV → TARGET_LV)" echo " lv-to-raw - Create fresh backup (SOURCE_LV → raw device)" echo " vg-to-raw - Clone entire VG (SOURCE_VG → raw device)" - echo " lv-to-borg - Backup LV to Borg repository" - echo " vg-to-borg - Backup entire VG to Borg repository" + echo " lv-to-borg - Backup LV to Borg repository (block-level)" + echo " vg-to-borg - Backup entire VG to Borg repository (block-level)" + echo " files-to-borg - Backup LV files to Borg repository (file-level, space-efficient)" echo "" echo "Borg Options:" echo " --new-repo Create new repository" echo " --encryption MODE Encryption mode: none, repokey, keyfile (default: repokey)" echo " --passphrase PASS Repository passphrase" + echo " --generous-snapshots Use 25% of LV size for snapshots (for very active systems)" echo "" echo "Examples:" echo " $0 lv-to-lv /dev/internal-vg/root /dev/backup-vg/root" echo " $0 lv-to-raw /dev/internal-vg/root /dev/sdb" echo " $0 vg-to-raw internal-vg /dev/sdb" echo " $0 lv-to-borg /dev/internal-vg/root /path/to/borg/repo --new-repo" - echo " $0 vg-to-borg internal-vg /path/to/borg/repo --encryption repokey --passphrase mypass" + echo " $0 files-to-borg /dev/internal-vg/home /path/to/borg/repo --new-repo" echo "" echo "List available sources/targets:" echo " ./list_drives.sh" @@ -95,6 +98,7 @@ shift 3 NEW_REPO=false ENCRYPTION="repokey" PASSPHRASE="" +GENEROUS_SNAPSHOTS=false while [ $# -gt 0 ]; do case "$1" in @@ -110,6 +114,10 @@ while [ $# -gt 0 ]; do PASSPHRASE="$2" shift 2 ;; + --generous-snapshots) + GENEROUS_SNAPSHOTS=true + shift + ;; *) error "Unknown option: $1" ;; @@ -123,7 +131,7 @@ fi # Validate mode case "$MODE" in - "lv-to-lv"|"lv-to-raw"|"vg-to-raw"|"lv-to-borg"|"vg-to-borg") + "lv-to-lv"|"lv-to-raw"|"vg-to-raw"|"lv-to-borg"|"vg-to-borg"|"files-to-borg") ;; *) error "Invalid mode: $MODE" @@ -300,10 +308,22 @@ case "$MODE" in # Get LV size to determine appropriate snapshot size LV_SIZE_BYTES=$(lvs --noheadings -o lv_size --units b "$SOURCE" | tr -d ' ' | sed 's/B$//') - # Use 10% of LV size or minimum 1GB for snapshot - SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES / 10)) - if [ $SNAPSHOT_SIZE_BYTES -lt 1073741824 ]; then - SNAPSHOT_SIZE_BYTES=1073741824 # 1GB minimum + # 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 + 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 fi SNAPSHOT_SIZE_GB=$((SNAPSHOT_SIZE_BYTES / 1073741824)) SNAPSHOT_SIZE="${SNAPSHOT_SIZE_GB}G" @@ -411,10 +431,22 @@ case "$MODE" in # Get LV size to determine appropriate snapshot size LV_SIZE_BYTES=$(lvs --noheadings -o lv_size --units b "$LV_PATH" | tr -d ' ' | sed 's/B$//') - # Use 10% of LV size or minimum 1GB for snapshot - SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES / 10)) - if [ $SNAPSHOT_SIZE_BYTES -lt 1073741824 ]; then - SNAPSHOT_SIZE_BYTES=1073741824 # 1GB minimum + # 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 + 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 fi SNAPSHOT_SIZE_GB=$((SNAPSHOT_SIZE_BYTES / 1073741824)) SNAPSHOT_SIZE="${SNAPSHOT_SIZE_GB}G" @@ -445,6 +477,101 @@ case "$MODE" in log "VG to Borg backup completed successfully" ;; + + "files-to-borg") + # Files to Borg repository backup + if [ ! -e "$SOURCE" ]; then + error "Source LV does not exist: $SOURCE" + fi + + # 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_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME" + + # Get LV size to determine appropriate snapshot size + 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 + 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 + fi + SNAPSHOT_SIZE_GB=$((SNAPSHOT_SIZE_BYTES / 1073741824)) + SNAPSHOT_SIZE="${SNAPSHOT_SIZE_GB}G" + + info "This will backup LV files to Borg repository (space-efficient)" + echo "" + echo -e "${YELLOW}Source LV: $SOURCE${NC}" + echo -e "${YELLOW}Repository: $TARGET${NC}" + echo -e "${BLUE}Snapshot size: $SNAPSHOT_SIZE${NC}" + echo -e "${BLUE}Mode: File-level backup (skips empty blocks)${NC}" + if [ "$NEW_REPO" = true ]; then + echo -e "${BLUE}Will create new repository${NC}" + else + echo -e "${BLUE}Will add to existing repository${NC}" + fi + echo -e "${BLUE}Encryption: $ENCRYPTION${NC}" + echo "" + read -p "Continue? (yes/no): " confirm + + if [ "$confirm" != "yes" ]; then + echo "Backup cancelled." + exit 0 + fi + + # Initialize repository if needed + if [ "$NEW_REPO" = true ]; then + log "Creating new Borg repository: $TARGET" + if [ "$ENCRYPTION" = "none" ]; then + borg init --encryption=none "$TARGET" || error "Failed to initialize repository" + else + borg init --encryption="$ENCRYPTION" "$TARGET" || error "Failed to initialize repository" + fi + log "Repository initialized successfully" + fi + + log "Creating snapshot of source LV" + lvcreate -L"$SNAPSHOT_SIZE" -s -n "$SNAPSHOT_NAME" "$SOURCE" || error "Failed to create snapshot" + + # 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" + + # Create Borg archive + ARCHIVE_NAME="files_${LV_NAME}_$(date +%Y%m%d_%H%M%S)" + log "Creating Borg archive (file-level): $ARCHIVE_NAME" + log "Backing up files from mounted snapshot..." + log "This is space-efficient and skips empty blocks" + + borg create --progress --stats --compression auto,zstd "$TARGET::$ARCHIVE_NAME" "$TEMP_MOUNT" || error "Borg file-level backup failed" + + log "File-level Borg backup completed successfully" + + # Cleanup + log "Cleaning up mount point and snapshot" + umount "$TEMP_MOUNT" || warn "Failed to unmount" + rmdir "$TEMP_MOUNT" + lvremove -f "$SNAPSHOT_PATH" || warn "Failed to remove snapshot" + SNAPSHOT_PATH="" + + log "Files to Borg backup completed successfully" + ;; esac echo "" diff --git a/simple_backup_gui.py b/simple_backup_gui.py index 2f62dab..fe5d863 100755 --- a/simple_backup_gui.py +++ b/simple_backup_gui.py @@ -45,12 +45,15 @@ class SimpleBackupGUI: ttk.Radiobutton(mode_frame, text="Entire VG → Device", variable=self.mode_var, value="vg_to_raw", command=self.on_mode_change).pack(side=tk.LEFT, padx=5) - ttk.Radiobutton(mode_frame, text="LV → Borg Repo", + ttk.Radiobutton(mode_frame, text="LV → Borg (block)", variable=self.mode_var, value="lv_to_borg", command=self.on_mode_change).pack(side=tk.LEFT, padx=5) - ttk.Radiobutton(mode_frame, text="VG → Borg Repo", + ttk.Radiobutton(mode_frame, text="VG → Borg (block)", variable=self.mode_var, value="vg_to_borg", command=self.on_mode_change).pack(side=tk.LEFT, padx=5) + ttk.Radiobutton(mode_frame, text="Files → Borg", + variable=self.mode_var, value="files_to_borg", + command=self.on_mode_change).pack(side=tk.LEFT, padx=5) # Source selection ttk.Label(main_frame, text="Source:").grid(row=1, column=0, sticky=tk.W, pady=5) @@ -88,14 +91,32 @@ class SimpleBackupGUI: values=["none", "repokey", "keyfile"], state="readonly", width=15) encryption_combo.grid(row=2, column=1, sticky=tk.W, pady=2) + # Snapshot size option + ttk.Label(self.borg_frame, text="Snapshot Size:").grid(row=3, column=0, sticky=tk.W, pady=2) + self.snapshot_size_var = tk.StringVar(value="auto") + snapshot_combo = ttk.Combobox(self.borg_frame, textvariable=self.snapshot_size_var, + values=["auto", "conservative", "generous"], state="readonly", width=15) + snapshot_combo.grid(row=3, column=1, sticky=tk.W, pady=2) + + # Help text for snapshot sizes + help_label = ttk.Label(self.borg_frame, text="auto: smart sizing, conservative: 5%, generous: 25%", + font=("TkDefaultFont", 8), foreground="gray") + help_label.grid(row=3, column=2, sticky=tk.W, padx=(5, 0), pady=2) + + # Read-only mode for full filesystems + self.readonly_mode = tk.BooleanVar() + ttk.Checkbutton(self.borg_frame, text="Read-only mode (for very full filesystems)", + variable=self.readonly_mode).grid(row=5, column=0, columnspan=3, sticky=tk.W, pady=2) + # Passphrase - ttk.Label(self.borg_frame, text="Passphrase:").grid(row=3, column=0, sticky=tk.W, pady=2) + ttk.Label(self.borg_frame, text="Passphrase:").grid(row=4, column=0, sticky=tk.W, pady=2) self.passphrase_var = tk.StringVar() passphrase_entry = ttk.Entry(self.borg_frame, textvariable=self.passphrase_var, show="*", width=40) - passphrase_entry.grid(row=3, column=1, sticky=(tk.W, tk.E), pady=2) + passphrase_entry.grid(row=4, column=1, sticky=(tk.W, tk.E), pady=2) # Configure borg frame grid self.borg_frame.columnconfigure(1, weight=1) + self.borg_frame.columnconfigure(2, weight=0) # Refresh button ttk.Button(main_frame, text="Refresh", command=self.refresh_drives).grid(row=4, column=0, pady=10) @@ -165,6 +186,26 @@ class SimpleBackupGUI: self.log_text.see(tk.END) self.root.update_idletasks() + def calculate_snapshot_size(self, lv_size_bytes): + """Calculate appropriate snapshot size based on LV size and user preference""" + 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) + + snapshot_size_gb = snapshot_size_bytes // (1024**3) + return f"{snapshot_size_gb}G" + def run_command(self, cmd, show_output=True): """Run a command and return result""" try: @@ -254,7 +295,7 @@ class SimpleBackupGUI: else: self.log("No target LVs found") self.target_combo['values'] = [] - elif mode in ["lv_to_borg", "vg_to_borg"]: + elif mode in ["lv_to_borg", "vg_to_borg", "files_to_borg"]: # No target combo for Borg modes - repository path is used self.target_combo['values'] = [] self.log("Using Borg repository path instead of target device") @@ -283,7 +324,7 @@ class SimpleBackupGUI: mode = self.mode_var.get() # Validate inputs based on mode - if mode in ["lv_to_borg", "vg_to_borg"]: + if mode in ["lv_to_borg", "vg_to_borg", "files_to_borg"]: if not self.source_var.get() or not self.repo_path_var.get(): messagebox.showerror("Error", "Please select source and repository path") return @@ -299,14 +340,22 @@ class SimpleBackupGUI: # Build confirmation message for Borg if mode == "lv_to_borg": - msg = f"Borg backup of LV:\n\nSource LV: {source}\nRepository: {repo_path}\n\n" + msg = f"Borg backup of LV (block-level):\n\nSource LV: {source}\nRepository: {repo_path}\n\n" + if self.create_new_repo.get(): + msg += "This will create a new Borg repository.\n" + else: + msg += "This will add to existing Borg repository.\n" + msg += f"Encryption: {self.encryption_var.get()}\n\nContinue?" + elif mode == "files_to_borg": + msg = f"Borg backup of LV (file-level):\n\nSource LV: {source}\nRepository: {repo_path}\n\n" + msg += "This will backup files from the LV (space-efficient).\n" if self.create_new_repo.get(): msg += "This will create a new Borg repository.\n" else: msg += "This will add to existing Borg repository.\n" msg += f"Encryption: {self.encryption_var.get()}\n\nContinue?" else: # vg_to_borg - msg = f"Borg backup of entire VG:\n\nSource VG: {source}\nRepository: {repo_path}\n\n" + msg = f"Borg backup of entire VG (block-level):\n\nSource VG: {source}\nRepository: {repo_path}\n\n" if self.create_new_repo.get(): msg += "This will create a new Borg repository.\n" else: @@ -362,6 +411,8 @@ class SimpleBackupGUI: self.backup_vg_to_raw(source, target) elif mode == "lv_to_borg": self.backup_lv_to_borg(source, target) + elif mode == "files_to_borg": + self.backup_files_to_borg(source, target) elif mode == "vg_to_borg": self.backup_vg_to_borg(source, target) @@ -522,10 +573,7 @@ class SimpleBackupGUI: 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', '')) - # Use 10% of LV size or minimum 1GB for snapshot - snapshot_size_bytes = max(lv_size_bytes // 10, 1024**3) # Min 1GB - snapshot_size_gb = snapshot_size_bytes // (1024**3) - snapshot_size = f"{snapshot_size_gb}G" + snapshot_size = self.calculate_snapshot_size(lv_size_bytes) else: snapshot_size = "2G" # Default fallback @@ -624,10 +672,7 @@ class SimpleBackupGUI: success, lv_size_output = self.run_command(f"lvs --noheadings -o lv_size --units b {lv_path}", show_output=False) if success: lv_size_bytes = int(lv_size_output.strip().replace('B', '')) - # Use 10% of LV size or minimum 1GB for snapshot - snapshot_size_bytes = max(lv_size_bytes // 10, 1024**3) # Min 1GB - snapshot_size_gb = snapshot_size_bytes // (1024**3) - snapshot_size = f"{snapshot_size_gb}G" + snapshot_size = self.calculate_snapshot_size(lv_size_bytes) else: snapshot_size = "2G" # Default fallback @@ -692,6 +737,98 @@ class SimpleBackupGUI: self.current_snapshot = None + def backup_files_to_borg(self, source_lv, repo_path): + """Backup LV files to Borg repository (file-level via snapshot mount)""" + self.log("Mode: LV to Borg Repository (file-level backup)") + self.log("This will mount an LV snapshot and backup files (space-efficient)") + + # Set up environment for Borg + borg_env = os.environ.copy() + if self.passphrase_var.get(): + borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get() + + # Initialize repository if needed + if self.create_new_repo.get(): + self.log(f"Creating new Borg repository: {repo_path}") + encryption = self.encryption_var.get() + if encryption == "none": + init_cmd = f"borg init --encryption=none {repo_path}" + else: + init_cmd = f"borg init --encryption={encryption} {repo_path}" + + result = subprocess.run(init_cmd, shell=True, capture_output=True, text=True, env=borg_env) + if result.returncode != 0: + raise Exception(f"Failed to initialize Borg repository: {result.stderr}") + self.log("Repository initialized successfully") + + # Create snapshot + vg_name = source_lv.split('/')[2] + lv_name = source_lv.split('/')[3] + snapshot_name = f"{lv_name}_files_snap" + 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) + else: + snapshot_size = "2G" # Default fallback + + self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})") + 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}") + + # Create temporary mount point + import tempfile + temp_mount = tempfile.mkdtemp(prefix="borg_files_backup_") + + 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}") + + # Create Borg backup of files + archive_name = f"files_{lv_name}_{time.strftime('%Y%m%d_%H%M%S')}" + self.log(f"Creating Borg archive (file-level): {archive_name}") + self.log("Backing up files from mounted snapshot...") + self.log("This is space-efficient and skips empty blocks") + + borg_cmd = f"borg create --progress --stats --compression auto,zstd {repo_path}::{archive_name} {temp_mount}" + + # Run borg with environment + process = subprocess.Popen(borg_cmd, shell=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, env=borg_env) + + # Stream output + for line in process.stdout: + self.log(line.strip()) + + process.wait() + if process.returncode != 0: + raise Exception("Borg file-level backup failed") + + self.log("File-level Borg backup completed successfully") + + finally: + # Cleanup: unmount and remove temp directory + self.log("Cleaning up mount point") + self.run_command(f"umount {temp_mount}", show_output=False) + os.rmdir(temp_mount) + + # Remove snapshot + self.log("Cleaning up snapshot") + success, output = self.run_command(f"lvremove -f {self.current_snapshot}") + if not success: + self.log(f"Warning: Failed to remove snapshot: {output}") + else: + self.log("Snapshot cleaned up") + + self.current_snapshot = None + def cleanup_on_error(self): """Clean up on error""" if self.current_snapshot: