Safety and reliability: timestamped snapshot names, safer LUKS open via stdin, read-only filesystem-aware mounts, VG free-space preflight in GUI; CLI strict mode and file-level snapshot sizing; README updates
This commit is contained in:
@@ -94,6 +94,10 @@ sudo ./enhanced_simple_backup.sh vg-to-borg internal-vg /path/to/borg/repo --pas
|
|||||||
- Automatic snapshot cleanup on errors
|
- Automatic snapshot cleanup on errors
|
||||||
- Emergency stop functionality (GUI)
|
- Emergency stop functionality (GUI)
|
||||||
- Input validation and error handling
|
- 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
|
## What This System Does NOT Do
|
||||||
|
|
||||||
@@ -115,6 +119,11 @@ Previous versions of this project included complex migration logic that occasion
|
|||||||
✅ Predictable behavior
|
✅ Predictable behavior
|
||||||
✅ No hidden "smart" features
|
✅ 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
|
## Recovery
|
||||||
|
|
||||||
### From LV Backup
|
### From LV Backup
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
# 2. LV → Raw: Create fresh backup on raw device
|
# 2. LV → Raw: Create fresh backup on raw device
|
||||||
# 3. VG → Raw: Clone entire volume group to raw device
|
# 3. VG → Raw: Clone entire volume group to raw device
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
|
||||||
# Colors
|
# Colors
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
@@ -170,7 +171,7 @@ case "$MODE" in
|
|||||||
# Extract VG and LV names
|
# Extract VG and LV names
|
||||||
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
||||||
LV_NAME=$(lvs --noheadings -o lv_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"
|
SNAPSHOT_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME"
|
||||||
|
|
||||||
info "This will update the existing backup LV"
|
info "This will update the existing backup LV"
|
||||||
@@ -216,7 +217,7 @@ case "$MODE" in
|
|||||||
# Extract VG and LV names
|
# Extract VG and LV names
|
||||||
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
||||||
LV_NAME=$(lvs --noheadings -o lv_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"
|
SNAPSHOT_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME"
|
||||||
|
|
||||||
info "This will create a fresh backup on raw device"
|
info "This will create a fresh backup on raw device"
|
||||||
@@ -303,7 +304,7 @@ case "$MODE" in
|
|||||||
# Extract VG and LV names
|
# Extract VG and LV names
|
||||||
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
||||||
LV_NAME=$(lvs --noheadings -o lv_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"
|
SNAPSHOT_PATH="/dev/$VG_NAME/$SNAPSHOT_NAME"
|
||||||
|
|
||||||
# Get LV size to determine appropriate snapshot size
|
# 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
|
# Process each LV one by one to avoid space issues
|
||||||
for LV_NAME in $LV_LIST; do
|
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"
|
SNAPSHOT_PATH="/dev/$SOURCE/$SNAPSHOT_NAME"
|
||||||
LV_PATH="/dev/$SOURCE/$LV_NAME"
|
LV_PATH="/dev/$SOURCE/$LV_NAME"
|
||||||
|
|
||||||
@@ -487,27 +488,21 @@ case "$MODE" in
|
|||||||
# Extract VG and LV names
|
# Extract VG and LV names
|
||||||
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
VG_NAME=$(lvs --noheadings -o vg_name "$SOURCE" | tr -d ' ')
|
||||||
LV_NAME=$(lvs --noheadings -o lv_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"
|
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$//')
|
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
|
if [ "$GENEROUS_SNAPSHOTS" = true ]; then
|
||||||
SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES / 4)) # 25%
|
# File-level generous: 5% with 1G min, 20G max
|
||||||
MIN_SIZE=$((2 * 1024 * 1024 * 1024)) # 2GB minimum
|
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
|
else
|
||||||
# Auto mode: 10% normally, 15% for large LVs
|
# Auto: 3% with 1G min, 15G max
|
||||||
if [ $LV_SIZE_BYTES -gt $((50 * 1024 * 1024 * 1024)) ]; then
|
SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES * 3 / 100))
|
||||||
SNAPSHOT_SIZE_BYTES=$((LV_SIZE_BYTES * 15 / 100)) # 15% for >50GB
|
[ $SNAPSHOT_SIZE_BYTES -lt $((1 * 1024 * 1024 * 1024)) ] && SNAPSHOT_SIZE_BYTES=$((1 * 1024 * 1024 * 1024))
|
||||||
else
|
[ $SNAPSHOT_SIZE_BYTES -gt $((15 * 1024 * 1024 * 1024)) ] && SNAPSHOT_SIZE_BYTES=$((15 * 1024 * 1024 * 1024))
|
||||||
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
|
fi
|
||||||
SNAPSHOT_SIZE_GB=$((SNAPSHOT_SIZE_BYTES / 1073741824))
|
SNAPSHOT_SIZE_GB=$((SNAPSHOT_SIZE_BYTES / 1073741824))
|
||||||
SNAPSHOT_SIZE="${SNAPSHOT_SIZE_GB}G"
|
SNAPSHOT_SIZE="${SNAPSHOT_SIZE_GB}G"
|
||||||
@@ -549,9 +544,17 @@ case "$MODE" in
|
|||||||
# Create temporary mount point
|
# Create temporary mount point
|
||||||
TEMP_MOUNT=$(mktemp -d -t borg_files_backup_XXXXXX)
|
TEMP_MOUNT=$(mktemp -d -t borg_files_backup_XXXXXX)
|
||||||
|
|
||||||
# Mount snapshot
|
# Mount snapshot read-only with safe FS options
|
||||||
log "Mounting snapshot to $TEMP_MOUNT"
|
FS_TYPE=$(blkid -o value -s TYPE "$SNAPSHOT_PATH" 2>/dev/null || echo "")
|
||||||
mount "$SNAPSHOT_PATH" "$TEMP_MOUNT" || error "Failed to mount snapshot"
|
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
|
# Create Borg archive
|
||||||
ARCHIVE_NAME="files_${LV_NAME}_$(date +%Y%m%d_%H%M%S)"
|
ARCHIVE_NAME="files_${LV_NAME}_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
|||||||
@@ -11,18 +11,60 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
class SimpleBackupGUI:
|
class SimpleBackupGUI:
|
||||||
def __init__(self, root):
|
def __init__(self, root):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.root.title("Simple LVM Backup")
|
self.root.title("Simple LVM Backup")
|
||||||
self.root.geometry("600x400")
|
self.root.geometry("1200x800") # Made much bigger: was 900x600
|
||||||
|
|
||||||
# State tracking
|
# State tracking
|
||||||
self.backup_running = False
|
self.backup_running = False
|
||||||
self.current_snapshot = None
|
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()
|
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
|
self.refresh_drives() # Initialize with correct widgets for default mode
|
||||||
|
|
||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
@@ -100,6 +142,14 @@ class SimpleBackupGUI:
|
|||||||
# Repo path
|
# Repo path
|
||||||
ttk.Label(self.borg_frame, text="Repository Path:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
ttk.Label(self.borg_frame, text="Repository Path:").grid(row=0, column=0, sticky=tk.W, pady=2)
|
||||||
self.repo_path_var = tk.StringVar()
|
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 = 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)
|
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)
|
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")
|
command=self.emergency_stop, state="disabled")
|
||||||
self.stop_btn.grid(row=4, column=2, pady=10)
|
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
|
# 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 = 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
|
# 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 = 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)
|
self.log_text = tk.Text(log_frame, height=15, width=70)
|
||||||
scrollbar = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview)
|
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.columnconfigure(0, weight=1)
|
||||||
self.root.rowconfigure(0, weight=1)
|
self.root.rowconfigure(0, weight=1)
|
||||||
main_frame.columnconfigure(1, 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.columnconfigure(0, weight=1)
|
||||||
log_frame.rowconfigure(0, weight=1)
|
log_frame.rowconfigure(0, weight=1)
|
||||||
|
|
||||||
@@ -206,29 +265,81 @@ class SimpleBackupGUI:
|
|||||||
def log(self, message):
|
def log(self, message):
|
||||||
"""Add message to log"""
|
"""Add message to log"""
|
||||||
timestamp = time.strftime("%H:%M:%S")
|
timestamp = time.strftime("%H:%M:%S")
|
||||||
self.log_text.insert(tk.END, f"[{timestamp}] {message}\n")
|
# Check if log_text widget exists yet
|
||||||
self.log_text.see(tk.END)
|
if hasattr(self, 'log_text'):
|
||||||
self.root.update_idletasks()
|
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):
|
def calculate_snapshot_size(self, lv_size_bytes, backup_mode="block"):
|
||||||
"""Calculate appropriate snapshot size based on LV size and user preference"""
|
"""Calculate appropriate snapshot size based on LV size, user preference, and backup mode"""
|
||||||
mode = self.snapshot_size_var.get()
|
mode = self.snapshot_size_var.get()
|
||||||
|
|
||||||
if mode == "conservative":
|
# For file-level backups, use much smaller snapshots
|
||||||
# 5% of LV size, minimum 1GB
|
if backup_mode == "file":
|
||||||
snapshot_size_bytes = max(lv_size_bytes // 20, 1024**3)
|
if mode == "conservative":
|
||||||
elif mode == "generous":
|
# 2% of LV size, minimum 512MB, maximum 10GB
|
||||||
# 25% of LV size, minimum 2GB
|
snapshot_size_bytes = max(min(lv_size_bytes // 50, 10 * 1024**3), 512 * 1024**2)
|
||||||
snapshot_size_bytes = max(lv_size_bytes // 4, 2 * 1024**3)
|
elif mode == "generous":
|
||||||
else: # auto
|
# 5% of LV size, minimum 1GB, maximum 20GB
|
||||||
# 10% of LV size, minimum 1GB, but increase for very large LVs
|
snapshot_size_bytes = max(min(lv_size_bytes // 20, 20 * 1024**3), 1024**3)
|
||||||
base_percentage = 10
|
else: # auto
|
||||||
if lv_size_bytes > 50 * 1024**3: # >50GB
|
# 3% of LV size, minimum 1GB, maximum 15GB
|
||||||
base_percentage = 15 # Use 15% for large, potentially active LVs
|
snapshot_size_bytes = max(min(lv_size_bytes * 3 // 100, 15 * 1024**3), 1024**3)
|
||||||
snapshot_size_bytes = max(lv_size_bytes * base_percentage // 100, 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)
|
snapshot_size_gb = snapshot_size_bytes // (1024**3)
|
||||||
return f"{snapshot_size_gb}G"
|
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):
|
def run_command(self, cmd, show_output=True):
|
||||||
"""Run a command and return result"""
|
"""Run a command and return result"""
|
||||||
@@ -252,6 +363,81 @@ class SimpleBackupGUI:
|
|||||||
self.log(f"ERROR: {str(e)}")
|
self.log(f"ERROR: {str(e)}")
|
||||||
return False, 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):
|
def refresh_drives(self):
|
||||||
"""Refresh available drives based on selected mode"""
|
"""Refresh available drives based on selected mode"""
|
||||||
mode = self.mode_var.get()
|
mode = self.mode_var.get()
|
||||||
@@ -432,6 +618,9 @@ class SimpleBackupGUI:
|
|||||||
self.stop_btn.config(state="normal")
|
self.stop_btn.config(state="normal")
|
||||||
self.progress.start()
|
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 = threading.Thread(target=self.multi_lv_backup_worker, args=(mode, selected_sources, repo_path))
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -493,6 +682,9 @@ class SimpleBackupGUI:
|
|||||||
self.stop_btn.config(state="normal")
|
self.stop_btn.config(state="normal")
|
||||||
self.progress.start()
|
self.progress.start()
|
||||||
|
|
||||||
|
# Save settings before starting backup
|
||||||
|
self.update_borg_settings()
|
||||||
|
|
||||||
thread = threading.Thread(target=self.backup_worker, args=(mode, source, target))
|
thread = threading.Thread(target=self.backup_worker, args=(mode, source, target))
|
||||||
thread.daemon = True
|
thread.daemon = True
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -500,6 +692,11 @@ class SimpleBackupGUI:
|
|||||||
def backup_worker(self, mode, source, target):
|
def backup_worker(self, mode, source, target):
|
||||||
"""The actual backup work"""
|
"""The actual backup work"""
|
||||||
try:
|
try:
|
||||||
|
# Check if stop was requested
|
||||||
|
if not self.backup_running:
|
||||||
|
self.log("Backup stopped before starting")
|
||||||
|
return
|
||||||
|
|
||||||
self.log(f"=== Starting {mode} backup ===")
|
self.log(f"=== Starting {mode} backup ===")
|
||||||
|
|
||||||
if mode == "lv_to_lv":
|
if mode == "lv_to_lv":
|
||||||
@@ -530,11 +727,21 @@ class SimpleBackupGUI:
|
|||||||
def multi_lv_backup_worker(self, mode, selected_sources, repo_path):
|
def multi_lv_backup_worker(self, mode, selected_sources, repo_path):
|
||||||
"""Backup multiple LVs sequentially"""
|
"""Backup multiple LVs sequentially"""
|
||||||
try:
|
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"=== Starting multi-LV {mode} backup ===")
|
||||||
self.log(f"Selected {len(selected_sources)} logical volumes")
|
self.log(f"Selected {len(selected_sources)} logical volumes")
|
||||||
|
|
||||||
# Initialize repository once if needed
|
# Initialize repository once if needed
|
||||||
if self.create_new_repo.get():
|
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()
|
borg_env = os.environ.copy()
|
||||||
if self.passphrase_var.get():
|
if self.passphrase_var.get():
|
||||||
borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get()
|
borg_env['BORG_PASSPHRASE'] = self.passphrase_var.get()
|
||||||
@@ -601,10 +808,15 @@ class SimpleBackupGUI:
|
|||||||
# Create snapshot of source
|
# Create snapshot of source
|
||||||
vg_name = source_lv.split('/')[2]
|
vg_name = source_lv.split('/')[2]
|
||||||
lv_name = source_lv.split('/')[3]
|
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.current_snapshot = f"/dev/{vg_name}/{snapshot_name}"
|
||||||
|
|
||||||
self.log(f"Creating snapshot: {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}")
|
success, output = self.run_command(f"lvcreate -L1G -s -n {snapshot_name} {source_lv}")
|
||||||
if not success:
|
if not success:
|
||||||
raise Exception(f"Failed to create snapshot: {output}")
|
raise Exception(f"Failed to create snapshot: {output}")
|
||||||
@@ -639,10 +851,15 @@ class SimpleBackupGUI:
|
|||||||
# Create snapshot of source
|
# Create snapshot of source
|
||||||
vg_name = source_lv.split('/')[2]
|
vg_name = source_lv.split('/')[2]
|
||||||
lv_name = source_lv.split('/')[3]
|
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.current_snapshot = f"/dev/{vg_name}/{snapshot_name}"
|
||||||
|
|
||||||
self.log(f"Creating snapshot: {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}")
|
success, output = self.run_command(f"lvcreate -L1G -s -n {snapshot_name} {source_lv}")
|
||||||
if not success:
|
if not success:
|
||||||
raise Exception(f"Failed to create snapshot: {output}")
|
raise Exception(f"Failed to create snapshot: {output}")
|
||||||
@@ -732,7 +949,7 @@ class SimpleBackupGUI:
|
|||||||
# Create snapshot
|
# Create snapshot
|
||||||
vg_name = source_lv.split('/')[2]
|
vg_name = source_lv.split('/')[2]
|
||||||
lv_name = source_lv.split('/')[3]
|
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}"
|
self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}"
|
||||||
|
|
||||||
# Get LV size to determine appropriate snapshot size
|
# Get LV size to determine appropriate snapshot size
|
||||||
@@ -744,6 +961,11 @@ class SimpleBackupGUI:
|
|||||||
snapshot_size = "2G" # Default fallback
|
snapshot_size = "2G" # Default fallback
|
||||||
|
|
||||||
self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})")
|
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}")
|
success, output = self.run_command(f"lvcreate -L{snapshot_size} -s -n {snapshot_name} {source_lv}")
|
||||||
if not success:
|
if not success:
|
||||||
raise Exception(f"Failed to create snapshot: {output}")
|
raise Exception(f"Failed to create snapshot: {output}")
|
||||||
@@ -830,7 +1052,7 @@ class SimpleBackupGUI:
|
|||||||
try:
|
try:
|
||||||
# Process each LV one by one to avoid space issues
|
# Process each LV one by one to avoid space issues
|
||||||
for lv_name in lv_names:
|
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}"
|
snapshot_path = f"/dev/{source_vg}/{snapshot_name}"
|
||||||
lv_path = f"/dev/{source_vg}/{lv_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"Processing LV: {lv_name}")
|
||||||
self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})")
|
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}")
|
success, output = self.run_command(f"lvcreate -L{snapshot_size} -s -n {snapshot_name} {lv_path}")
|
||||||
if not success:
|
if not success:
|
||||||
self.log(f"Warning: Failed to create snapshot for {lv_name}: {output}")
|
self.log(f"Warning: Failed to create snapshot for {lv_name}: {output}")
|
||||||
@@ -930,18 +1158,25 @@ class SimpleBackupGUI:
|
|||||||
# Create snapshot
|
# Create snapshot
|
||||||
vg_name = source_lv.split('/')[2]
|
vg_name = source_lv.split('/')[2]
|
||||||
lv_name = source_lv.split('/')[3]
|
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}"
|
self.current_snapshot = f"/dev/{vg_name}/{snapshot_name}"
|
||||||
|
|
||||||
# Get LV size to determine appropriate snapshot size
|
# 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)
|
success, lv_size_output = self.run_command(f"lvs --noheadings -o lv_size --units b {source_lv}", show_output=False)
|
||||||
if success:
|
if success:
|
||||||
lv_size_bytes = int(lv_size_output.strip().replace('B', ''))
|
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:
|
else:
|
||||||
snapshot_size = "2G" # Default fallback
|
snapshot_size = "2G" # Default fallback
|
||||||
|
|
||||||
self.log(f"Creating snapshot: {snapshot_name} (size: {snapshot_size})")
|
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}")
|
success, output = self.run_command(f"lvcreate -L{snapshot_size} -s -n {snapshot_name} {source_lv}")
|
||||||
if not success:
|
if not success:
|
||||||
raise Exception(f"Failed to create snapshot: {output}")
|
raise Exception(f"Failed to create snapshot: {output}")
|
||||||
@@ -949,13 +1184,60 @@ class SimpleBackupGUI:
|
|||||||
# Create temporary mount point
|
# Create temporary mount point
|
||||||
import tempfile
|
import tempfile
|
||||||
temp_mount = tempfile.mkdtemp(prefix="borg_files_backup_")
|
temp_mount = tempfile.mkdtemp(prefix="borg_files_backup_")
|
||||||
|
luks_device = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Mount the snapshot
|
# Check if the snapshot is LUKS encrypted
|
||||||
self.log(f"Mounting snapshot to {temp_mount}")
|
self.log("Checking if volume is LUKS encrypted...")
|
||||||
success, output = self.run_command(f"mount {self.current_snapshot} {temp_mount}")
|
|
||||||
if not success:
|
# Resolve the real device path in case of symbolic links
|
||||||
raise Exception(f"Failed to mount snapshot: {output}")
|
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
|
# Create Borg backup of files
|
||||||
archive_name = f"files_{lv_name}_{time.strftime('%Y%m%d_%H%M%S')}"
|
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
|
# Cleanup: unmount and remove temp directory
|
||||||
self.log("Cleaning up mount point")
|
self.log("Cleaning up mount point")
|
||||||
self.run_command(f"umount {temp_mount}", show_output=False)
|
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)
|
os.rmdir(temp_mount)
|
||||||
|
|
||||||
# Remove snapshot
|
# Remove snapshot
|
||||||
@@ -1010,15 +1299,210 @@ class SimpleBackupGUI:
|
|||||||
"""Emergency stop - kill any running processes"""
|
"""Emergency stop - kill any running processes"""
|
||||||
self.log("EMERGENCY STOP requested")
|
self.log("EMERGENCY STOP requested")
|
||||||
|
|
||||||
# Kill any dd or pv processes
|
# Set stop flag for backup workers to check
|
||||||
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.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()
|
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):
|
def reset_ui_state(self):
|
||||||
"""Reset UI to normal state"""
|
"""Reset UI to normal state"""
|
||||||
@@ -1026,6 +1510,137 @@ class SimpleBackupGUI:
|
|||||||
self.backup_btn.config(state="normal")
|
self.backup_btn.config(state="normal")
|
||||||
self.stop_btn.config(state="disabled")
|
self.stop_btn.config(state="disabled")
|
||||||
self.progress.stop()
|
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():
|
def main():
|
||||||
# Check if running as root
|
# Check if running as root
|
||||||
|
|||||||
Reference in New Issue
Block a user