feat: Enhanced simple LVM backup system

- Add 3 backup modes: LV→LV, LV→Raw, VG→Raw
- Simple GUI with mode selection and dynamic target lists
- Command-line version with clear mode support
- Enhanced drive listing with mode-specific options
- Minimal logic: just snapshot → copy → cleanup
- No complex migration features that cause system issues
- Supports refreshing existing backups and creating fresh ones
This commit is contained in:
root
2025-10-09 00:27:34 +02:00
parent 56c07dbe49
commit 871a57947d
5 changed files with 1049 additions and 0 deletions

436
simple_backup_gui.py Executable file
View File

@@ -0,0 +1,436 @@
#!/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)
# 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)
# Refresh button
ttk.Button(main_frame, text="Refresh", command=self.refresh_drives).grid(row=3, 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=3, 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=3, column=2, pady=10)
# Progress area
ttk.Label(main_frame, text="Progress:").grid(row=4, column=0, sticky=tk.W, pady=(20, 5))
self.progress = ttk.Progressbar(main_frame, mode='indeterminate')
self.progress.grid(row=5, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=5)
# Log area
ttk.Label(main_frame, text="Log:").grid(row=6, column=0, sticky=tk.W, pady=(10, 5))
log_frame = ttk.Frame(main_frame)
log_frame.grid(row=7, 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(7, weight=1)
log_frame.columnconfigure(0, weight=1)
log_frame.rowconfigure(0, weight=1)
def on_mode_change(self):
"""Handle backup mode change"""
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'] = []
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"""
if not self.source_var.get() or not self.target_var.get():
messagebox.showerror("Error", "Please select both source and target")
return
mode = self.mode_var.get()
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)
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 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()