Problem: Fixed 500MB snapshot size was too small for large/active LVs, causing I/O errors when snapshot space was exhausted during backup. Solution: Calculate appropriate snapshot size based on LV size: - Use 10% of LV size for snapshot space - Minimum 1GB to handle reasonable activity - Shows snapshot size in logs for transparency Example: - 20GB LV → 2GB snapshot - 100GB LV → 10GB snapshot - 500MB LV → 1GB snapshot (minimum) This prevents snapshot overflow and resulting I/O errors during backup. Changes: - GUI: Calculate snapshot size before creating - CLI: Same dynamic sizing logic - Both show calculated snapshot size in output - Handles both single LV and VG backup modes
739 lines
33 KiB
Python
Executable File
739 lines
33 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Simple LVM Backup GUI
|
|
Just the basics: snapshot -> copy -> cleanup
|
|
No complex logic, just simple reliable backups
|
|
"""
|
|
|
|
import tkinter as tk
|
|
from tkinter import ttk, messagebox
|
|
import subprocess
|
|
import threading
|
|
import os
|
|
import time
|
|
|
|
class SimpleBackupGUI:
|
|
def __init__(self, root):
|
|
self.root = root
|
|
self.root.title("Simple LVM Backup")
|
|
self.root.geometry("600x400")
|
|
|
|
# State tracking
|
|
self.backup_running = False
|
|
self.current_snapshot = None
|
|
|
|
self.setup_ui()
|
|
self.refresh_drives()
|
|
|
|
def setup_ui(self):
|
|
# Main frame
|
|
main_frame = ttk.Frame(self.root, padding="10")
|
|
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
|
|
# Backup mode selection
|
|
ttk.Label(main_frame, text="Backup Mode:").grid(row=0, column=0, sticky=tk.W, pady=5)
|
|
self.mode_var = tk.StringVar(value="lv_to_lv")
|
|
mode_frame = ttk.Frame(main_frame)
|
|
mode_frame.grid(row=0, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5)
|
|
|
|
ttk.Radiobutton(mode_frame, text="LV → LV (update existing)",
|
|
variable=self.mode_var, value="lv_to_lv",
|
|
command=self.on_mode_change).pack(side=tk.LEFT, padx=5)
|
|
ttk.Radiobutton(mode_frame, text="LV → Raw Device (fresh)",
|
|
variable=self.mode_var, value="lv_to_raw",
|
|
command=self.on_mode_change).pack(side=tk.LEFT, padx=5)
|
|
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",
|
|
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",
|
|
variable=self.mode_var, value="vg_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)
|
|
self.source_var = tk.StringVar()
|
|
self.source_combo = ttk.Combobox(main_frame, textvariable=self.source_var, width=50)
|
|
self.source_combo.grid(row=1, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5)
|
|
|
|
# Target selection
|
|
ttk.Label(main_frame, text="Target:").grid(row=2, column=0, sticky=tk.W, pady=5)
|
|
self.target_var = tk.StringVar()
|
|
self.target_combo = ttk.Combobox(main_frame, textvariable=self.target_var, width=50)
|
|
self.target_combo.grid(row=2, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=5)
|
|
|
|
# Borg-specific settings (hidden initially)
|
|
self.borg_frame = ttk.LabelFrame(main_frame, text="Borg Backup Settings", padding="5")
|
|
self.borg_frame.grid(row=3, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5)
|
|
self.borg_frame.grid_remove() # Hide initially
|
|
|
|
# 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()
|
|
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)
|
|
|
|
# Create new repo checkbox
|
|
self.create_new_repo = tk.BooleanVar()
|
|
ttk.Checkbutton(self.borg_frame, text="Create new repository",
|
|
variable=self.create_new_repo).grid(row=1, column=0, columnspan=2, sticky=tk.W, pady=2)
|
|
|
|
# Encryption settings
|
|
ttk.Label(self.borg_frame, text="Encryption Mode:").grid(row=2, column=0, sticky=tk.W, pady=2)
|
|
self.encryption_var = tk.StringVar(value="repokey")
|
|
encryption_combo = ttk.Combobox(self.borg_frame, textvariable=self.encryption_var,
|
|
values=["none", "repokey", "keyfile"], state="readonly", width=15)
|
|
encryption_combo.grid(row=2, column=1, sticky=tk.W, pady=2)
|
|
|
|
# Passphrase
|
|
ttk.Label(self.borg_frame, text="Passphrase:").grid(row=3, 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)
|
|
|
|
# Configure borg frame grid
|
|
self.borg_frame.columnconfigure(1, weight=1)
|
|
|
|
# Refresh button
|
|
ttk.Button(main_frame, text="Refresh", command=self.refresh_drives).grid(row=4, column=0, pady=10)
|
|
|
|
# Backup button
|
|
self.backup_btn = ttk.Button(main_frame, text="Start Backup",
|
|
command=self.start_backup, style="Accent.TButton")
|
|
self.backup_btn.grid(row=4, column=1, pady=10)
|
|
|
|
# Emergency stop
|
|
self.stop_btn = ttk.Button(main_frame, text="Emergency Stop",
|
|
command=self.emergency_stop, state="disabled")
|
|
self.stop_btn.grid(row=4, column=2, pady=10)
|
|
|
|
# Progress area
|
|
ttk.Label(main_frame, text="Progress:").grid(row=5, 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)
|
|
|
|
# Log area
|
|
ttk.Label(main_frame, text="Log:").grid(row=7, 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)
|
|
|
|
self.log_text = tk.Text(log_frame, height=15, width=70)
|
|
scrollbar = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview)
|
|
self.log_text.configure(yscrollcommand=scrollbar.set)
|
|
|
|
self.log_text.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
|
scrollbar.grid(row=0, column=1, sticky=(tk.N, tk.S))
|
|
|
|
# Configure grid weights
|
|
self.root.columnconfigure(0, weight=1)
|
|
self.root.rowconfigure(0, weight=1)
|
|
main_frame.columnconfigure(1, weight=1)
|
|
main_frame.rowconfigure(8, weight=1)
|
|
log_frame.columnconfigure(0, weight=1)
|
|
log_frame.rowconfigure(0, weight=1)
|
|
|
|
def browse_repo(self):
|
|
"""Browse for Borg repository path"""
|
|
from tkinter import filedialog
|
|
path = filedialog.askdirectory(title="Select Borg Repository Directory")
|
|
if path:
|
|
self.repo_path_var.set(path)
|
|
|
|
def on_mode_change(self):
|
|
"""Handle backup mode change"""
|
|
mode = self.mode_var.get()
|
|
|
|
# Show/hide Borg settings
|
|
if mode in ["lv_to_borg", "vg_to_borg"]:
|
|
self.borg_frame.grid()
|
|
# Clear target combo for Borg modes (repo path is used instead)
|
|
self.target_combo['values'] = []
|
|
self.target_var.set("")
|
|
else:
|
|
self.borg_frame.grid_remove()
|
|
self.refresh_drives()
|
|
|
|
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()
|
|
|
|
def run_command(self, cmd, show_output=True):
|
|
"""Run a command and return result"""
|
|
try:
|
|
if show_output:
|
|
self.log(f"Running: {cmd}")
|
|
|
|
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
|
|
|
if result.returncode != 0:
|
|
if show_output:
|
|
self.log(f"ERROR: {result.stderr.strip()}")
|
|
return False, result.stderr.strip()
|
|
else:
|
|
if show_output and result.stdout.strip():
|
|
self.log(f"Output: {result.stdout.strip()}")
|
|
return True, result.stdout.strip()
|
|
|
|
except Exception as e:
|
|
if show_output:
|
|
self.log(f"ERROR: {str(e)}")
|
|
return False, str(e)
|
|
|
|
def refresh_drives(self):
|
|
"""Refresh available drives based on selected mode"""
|
|
mode = self.mode_var.get()
|
|
self.log(f"Refreshing drives for mode: {mode}")
|
|
|
|
# Source options
|
|
if mode == "vg_to_raw":
|
|
# Show volume groups
|
|
success, output = self.run_command("vgs --noheadings -o vg_name,vg_size,pv_count", show_output=False)
|
|
if success:
|
|
source_list = []
|
|
for line in output.strip().split('\n'):
|
|
if line.strip():
|
|
parts = line.strip().split()
|
|
if len(parts) >= 3:
|
|
vg_name = parts[0]
|
|
vg_size = parts[1]
|
|
pv_count = parts[2]
|
|
source_list.append(f"{vg_name} ({vg_size}) [{pv_count} PVs]")
|
|
|
|
self.source_combo['values'] = source_list
|
|
self.log(f"Found {len(source_list)} volume groups")
|
|
else:
|
|
self.log("No volume groups found")
|
|
self.source_combo['values'] = []
|
|
else:
|
|
# Show logical volumes (lv_to_lv and lv_to_raw)
|
|
success, output = self.run_command("lvs --noheadings -o lv_path,lv_size,vg_name", show_output=False)
|
|
if success:
|
|
lv_list = []
|
|
for line in output.strip().split('\n'):
|
|
if line.strip():
|
|
parts = line.strip().split()
|
|
if len(parts) >= 3:
|
|
lv_path = parts[0]
|
|
lv_size = parts[1]
|
|
vg_name = parts[2]
|
|
lv_list.append(f"{lv_path} ({lv_size}) [VG: {vg_name}]")
|
|
|
|
self.source_combo['values'] = lv_list
|
|
self.log(f"Found {len(lv_list)} logical volumes")
|
|
else:
|
|
self.log("No LVM volumes found")
|
|
self.source_combo['values'] = []
|
|
|
|
# Target options
|
|
if mode == "lv_to_lv":
|
|
# Show existing logical volumes on external drives
|
|
success, output = self.run_command("lvs --noheadings -o lv_path,lv_size,vg_name", show_output=False)
|
|
if success:
|
|
target_list = []
|
|
for line in output.strip().split('\n'):
|
|
if line.strip():
|
|
parts = line.strip().split()
|
|
if len(parts) >= 3:
|
|
lv_path = parts[0]
|
|
lv_size = parts[1]
|
|
vg_name = parts[2]
|
|
# Filter out internal VGs (you might want to customize this)
|
|
if "migration" in vg_name or "external" in vg_name or "backup" in vg_name:
|
|
target_list.append(f"{lv_path} ({lv_size}) [VG: {vg_name}]")
|
|
|
|
self.target_combo['values'] = target_list
|
|
self.log(f"Found {len(target_list)} target logical volumes")
|
|
else:
|
|
self.log("No target LVs found")
|
|
self.target_combo['values'] = []
|
|
elif mode in ["lv_to_borg", "vg_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")
|
|
else:
|
|
# Show raw block devices (lv_to_raw and vg_to_raw)
|
|
success, output = self.run_command("lsblk -dno NAME,SIZE,MODEL | grep -E '^sd|^nvme'", show_output=False)
|
|
if success:
|
|
drive_list = []
|
|
for line in output.strip().split('\n'):
|
|
if line.strip():
|
|
parts = line.strip().split(None, 2)
|
|
if len(parts) >= 2:
|
|
name = parts[0]
|
|
size = parts[1]
|
|
model = parts[2] if len(parts) > 2 else "Unknown"
|
|
drive_list.append(f"/dev/{name} ({size}) {model}")
|
|
|
|
self.target_combo['values'] = drive_list
|
|
self.log(f"Found {len(drive_list)} target drives")
|
|
else:
|
|
self.log("No block devices found")
|
|
self.target_combo['values'] = []
|
|
|
|
def start_backup(self):
|
|
"""Start the backup process"""
|
|
mode = self.mode_var.get()
|
|
|
|
# Validate inputs based on mode
|
|
if mode in ["lv_to_borg", "vg_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
|
|
|
|
# Check if borg is installed
|
|
success, _ = self.run_command("which borg", show_output=False)
|
|
if not success:
|
|
messagebox.showerror("Error", "Borg Backup is not installed. Please install it first:\nsudo apt install borgbackup")
|
|
return
|
|
|
|
source = self.source_var.get().split()[0]
|
|
repo_path = self.repo_path_var.get()
|
|
|
|
# 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"
|
|
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"
|
|
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"
|
|
msg += "This will backup ALL logical volumes in the VG.\n\nContinue?"
|
|
|
|
target = repo_path
|
|
else:
|
|
# Original validation for non-Borg modes
|
|
if not self.source_var.get() or not self.target_var.get():
|
|
messagebox.showerror("Error", "Please select both source and target")
|
|
return
|
|
|
|
source = self.source_var.get().split()[0]
|
|
target = self.target_var.get().split()[0]
|
|
|
|
# Build confirmation message based on mode
|
|
if mode == "lv_to_lv":
|
|
msg = f"Update existing LV backup:\n\nSource LV: {source}\nTarget LV: {target}\n\n"
|
|
msg += "This will overwrite the target LV with current source data.\n\nContinue?"
|
|
elif mode == "lv_to_raw":
|
|
msg = f"Create fresh backup:\n\nSource LV: {source}\nTarget Device: {target}\n\n"
|
|
msg += "WARNING: Target device will be completely overwritten!\n\nContinue?"
|
|
elif mode == "vg_to_raw":
|
|
msg = f"Clone entire Volume Group:\n\nSource VG: {source}\nTarget Device: {target}\n\n"
|
|
msg += "WARNING: Target device will be completely overwritten!\n"
|
|
msg += "This will clone ALL logical volumes in the VG.\n\nContinue?"
|
|
|
|
if not messagebox.askyesno("Confirm Backup", msg):
|
|
return
|
|
|
|
# Start backup in thread
|
|
self.backup_running = True
|
|
self.backup_btn.config(state="disabled")
|
|
self.stop_btn.config(state="normal")
|
|
self.progress.start()
|
|
|
|
thread = threading.Thread(target=self.backup_worker, args=(mode, source, target))
|
|
thread.daemon = True
|
|
thread.start()
|
|
|
|
def backup_worker(self, mode, source, target):
|
|
"""The actual backup work"""
|
|
try:
|
|
self.log(f"=== Starting {mode} backup ===")
|
|
|
|
if mode == "lv_to_lv":
|
|
self.backup_lv_to_lv(source, target)
|
|
elif mode == "lv_to_raw":
|
|
self.backup_lv_to_raw(source, target)
|
|
elif mode == "vg_to_raw":
|
|
self.backup_vg_to_raw(source, target)
|
|
elif mode == "lv_to_borg":
|
|
self.backup_lv_to_borg(source, target)
|
|
elif mode == "vg_to_borg":
|
|
self.backup_vg_to_borg(source, target)
|
|
|
|
self.log("=== Backup completed successfully! ===")
|
|
self.root.after(0, lambda: messagebox.showinfo("Success", "Backup completed successfully!"))
|
|
|
|
except Exception as e:
|
|
self.log(f"ERROR: {str(e)}")
|
|
self.cleanup_on_error()
|
|
self.root.after(0, lambda: messagebox.showerror("Backup Failed", str(e)))
|
|
|
|
finally:
|
|
# Reset UI state
|
|
self.root.after(0, self.reset_ui_state)
|
|
|
|
def backup_lv_to_lv(self, source_lv, target_lv):
|
|
"""Backup LV to existing LV"""
|
|
self.log("Mode: LV to LV (updating existing backup)")
|
|
|
|
# Create snapshot of source
|
|
vg_name = source_lv.split('/')[2]
|
|
lv_name = source_lv.split('/')[3]
|
|
snapshot_name = f"{lv_name}_backup_snap"
|
|
self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}"
|
|
|
|
self.log(f"Creating snapshot: {snapshot_name}")
|
|
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}")
|
|
|
|
# Copy snapshot to target LV
|
|
self.log(f"Copying {self.current_snapshot} to {target_lv}")
|
|
|
|
success, _ = self.run_command("which pv", show_output=False)
|
|
if success:
|
|
copy_cmd = f"pv {self.current_snapshot} | dd of={target_lv} bs=4M"
|
|
else:
|
|
copy_cmd = f"dd if={self.current_snapshot} of={target_lv} bs=4M status=progress"
|
|
|
|
success, output = self.run_command(copy_cmd)
|
|
if not success:
|
|
raise Exception(f"Failed to copy data: {output}")
|
|
|
|
# Cleanup
|
|
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 backup_lv_to_raw(self, source_lv, target_device):
|
|
"""Backup LV to raw device (fresh backup)"""
|
|
self.log("Mode: LV to Raw Device (fresh backup)")
|
|
|
|
# Create snapshot of source
|
|
vg_name = source_lv.split('/')[2]
|
|
lv_name = source_lv.split('/')[3]
|
|
snapshot_name = f"{lv_name}_backup_snap"
|
|
self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}"
|
|
|
|
self.log(f"Creating snapshot: {snapshot_name}")
|
|
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}")
|
|
|
|
# Copy snapshot to target device
|
|
self.log(f"Copying {self.current_snapshot} to {target_device}")
|
|
self.log("This will create a raw block-level copy")
|
|
|
|
success, _ = self.run_command("which pv", show_output=False)
|
|
if success:
|
|
copy_cmd = f"pv {self.current_snapshot} | dd of={target_device} bs=4M"
|
|
else:
|
|
copy_cmd = f"dd if={self.current_snapshot} of={target_device} bs=4M status=progress"
|
|
|
|
success, output = self.run_command(copy_cmd)
|
|
if not success:
|
|
raise Exception(f"Failed to copy data: {output}")
|
|
|
|
# Cleanup
|
|
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 backup_vg_to_raw(self, source_vg, target_device):
|
|
"""Backup entire VG to raw device"""
|
|
self.log("Mode: Entire VG to Raw Device")
|
|
self.log("This will create a complete clone including LVM metadata")
|
|
|
|
# Get the physical volume(s) that make up this VG
|
|
success, output = self.run_command(f"vgs --noheadings -o pv_name {source_vg}", show_output=False)
|
|
if not success:
|
|
raise Exception(f"Failed to get PV info for VG {source_vg}")
|
|
|
|
# For simplicity, we'll use the first PV as the source
|
|
# In a real implementation, you might want to handle multiple PVs
|
|
pv_list = output.strip().split()
|
|
if not pv_list:
|
|
raise Exception(f"No physical volumes found for VG {source_vg}")
|
|
|
|
source_pv = pv_list[0]
|
|
self.log(f"Source PV: {source_pv}")
|
|
|
|
# Copy the entire physical volume
|
|
self.log(f"Copying entire PV {source_pv} to {target_device}")
|
|
self.log("This preserves LVM metadata and all logical volumes")
|
|
|
|
success, _ = self.run_command("which pv", show_output=False)
|
|
if success:
|
|
copy_cmd = f"pv {source_pv} | dd of={target_device} bs=4M"
|
|
else:
|
|
copy_cmd = f"dd if={source_pv} of={target_device} bs=4M status=progress"
|
|
|
|
success, output = self.run_command(copy_cmd)
|
|
if not success:
|
|
raise Exception(f"Failed to copy PV: {output}")
|
|
|
|
self.log("VG copy completed - target device now contains complete LVM structure")
|
|
|
|
def backup_lv_to_borg(self, source_lv, repo_path):
|
|
"""Backup LV to Borg repository (block-level)"""
|
|
self.log("Mode: LV to Borg Repository (block-level backup)")
|
|
|
|
# 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}_borg_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', ''))
|
|
# 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"
|
|
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 Borg backup of the raw block device
|
|
archive_name = f"lv_{lv_name}_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
self.log(f"Creating Borg archive (block-level): {archive_name}")
|
|
self.log("Backing up raw snapshot block device to Borg...")
|
|
self.log("This preserves exact block-level state including filesystem metadata")
|
|
|
|
# Use stdin mode to pipe the block device into borg
|
|
borg_cmd = f"dd if={self.current_snapshot} bs=4M | borg create --stdin-name '{lv_name}.img' --progress --stats {repo_path}::{archive_name} -"
|
|
|
|
# Run command with Borg 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:
|
|
if line.strip():
|
|
self.log(line.strip())
|
|
|
|
process.wait()
|
|
if process.returncode != 0:
|
|
raise Exception("Borg block-level backup failed")
|
|
|
|
self.log("Block-level Borg backup completed successfully")
|
|
|
|
# Cleanup 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 backup_vg_to_borg(self, source_vg, repo_path):
|
|
"""Backup entire VG to Borg repository (block-level)"""
|
|
self.log("Mode: Entire VG to Borg Repository (block-level backup)")
|
|
self.log("This will store all LV snapshots as raw block devices in Borg")
|
|
|
|
# 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")
|
|
|
|
# Get all LVs in the VG
|
|
success, output = self.run_command(f"lvs --noheadings -o lv_name {source_vg}", show_output=False)
|
|
if not success:
|
|
raise Exception(f"Failed to get LVs for VG {source_vg}")
|
|
|
|
lv_names = [lv.strip() for lv in output.strip().split('\n') if lv.strip()]
|
|
if not lv_names:
|
|
raise Exception(f"No logical volumes found in VG {source_vg}")
|
|
|
|
self.log(f"Found {len(lv_names)} logical volumes: {', '.join(lv_names)}")
|
|
|
|
# Create one archive with all LVs
|
|
archive_name = f"vg_{source_vg}_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
self.log(f"Creating Borg archive (block-level): {archive_name}")
|
|
|
|
# Create temporary directory for organizing the LV images
|
|
import tempfile
|
|
temp_dir = tempfile.mkdtemp(prefix="borg_vg_backup_")
|
|
|
|
snapshots_created = []
|
|
|
|
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_path = f"/dev/{source_vg}/{snapshot_name}"
|
|
lv_path = f"/dev/{source_vg}/{lv_name}"
|
|
|
|
# Get LV size to determine appropriate snapshot size
|
|
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"
|
|
else:
|
|
snapshot_size = "2G" # Default fallback
|
|
|
|
self.log(f"Processing LV: {lv_name}")
|
|
self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})")
|
|
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}")
|
|
continue
|
|
|
|
snapshots_created.append(snapshot_path)
|
|
|
|
# Stream this LV directly to Borg using append mode (if supported)
|
|
# or create individual archives
|
|
archive_name_lv = f"vg_{source_vg}_lv_{lv_name}_{time.strftime('%Y%m%d_%H%M%S')}"
|
|
self.log(f"Backing up {lv_name} to archive: {archive_name_lv}")
|
|
|
|
# Use stdin mode to stream the block device
|
|
borg_cmd = f"dd if={snapshot_path} bs=4M | borg create --stdin-name '{lv_name}.img' --progress --stats {repo_path}::{archive_name_lv} -"
|
|
|
|
# Run command with Borg 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:
|
|
if line.strip():
|
|
self.log(line.strip())
|
|
|
|
process.wait()
|
|
if process.returncode != 0:
|
|
self.log(f"Warning: Backup of {lv_name} failed")
|
|
else:
|
|
self.log(f"Successfully backed up {lv_name}")
|
|
|
|
# Clean up this snapshot immediately to save space
|
|
self.log(f"Removing snapshot {snapshot_path}")
|
|
success, output = self.run_command(f"lvremove -f {snapshot_path}")
|
|
if success:
|
|
snapshots_created.remove(snapshot_path)
|
|
else:
|
|
self.log(f"Warning: Failed to remove snapshot {snapshot_path}: {output}")
|
|
|
|
self.log("Block-level VG Borg backup completed successfully")
|
|
self.log(f"Created individual archives for each LV in VG {source_vg}")
|
|
|
|
finally:
|
|
# Remove temporary directory
|
|
import shutil
|
|
try:
|
|
shutil.rmtree(temp_dir)
|
|
self.log("Temporary directory cleaned up")
|
|
except:
|
|
self.log(f"Warning: Could not remove temp directory {temp_dir}")
|
|
|
|
# Remove any remaining snapshots
|
|
for snapshot_path in snapshots_created:
|
|
self.log(f"Removing remaining snapshot {snapshot_path}")
|
|
success, output = self.run_command(f"lvremove -f {snapshot_path}")
|
|
if not success:
|
|
self.log(f"Warning: Failed to remove snapshot {snapshot_path}: {output}")
|
|
|
|
self.current_snapshot = None
|
|
|
|
def cleanup_on_error(self):
|
|
"""Clean up on error"""
|
|
if self.current_snapshot:
|
|
self.log("Attempting to clean up snapshot after error")
|
|
success, output = self.run_command(f"lvremove -f {self.current_snapshot}")
|
|
if success:
|
|
self.log("Snapshot cleaned up")
|
|
else:
|
|
self.log(f"Failed to clean up snapshot: {output}")
|
|
self.current_snapshot = None
|
|
|
|
def emergency_stop(self):
|
|
"""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()
|
|
self.backup_running = False
|
|
self.reset_ui_state()
|
|
|
|
messagebox.showwarning("Stopped", "Backup process stopped. Check log for any cleanup needed.")
|
|
|
|
def reset_ui_state(self):
|
|
"""Reset UI to normal state"""
|
|
self.backup_running = False
|
|
self.backup_btn.config(state="normal")
|
|
self.stop_btn.config(state="disabled")
|
|
self.progress.stop()
|
|
|
|
def main():
|
|
# Check if running as root
|
|
if os.geteuid() != 0:
|
|
print("This application needs to run as root for LVM operations.")
|
|
print("Please run: sudo python3 simple_backup_gui.py")
|
|
return
|
|
|
|
root = tk.Tk()
|
|
app = SimpleBackupGUI(root)
|
|
root.mainloop()
|
|
|
|
if __name__ == "__main__":
|
|
main() |