✨ New Features: - Complete backup lifecycle management (create, list, download, delete, cleanup) - Web-based backup interface with real-time status updates - Individual backup deletion and bulk cleanup for old backups - Docker-aware backup operations with volume persistence - Automated backup scheduling and retention policies 📁 Added Files: - backup.py - Core backup script for creating timestamped archives - docker_backup.sh - Docker-compatible backup wrapper script - app/templates/backup.html - Web interface for backup management - BACKUP_SYSTEM.md - Comprehensive backup system documentation - BACKUP_GUIDE.md - Quick reference guide for backup operations 🔧 Enhanced Files: - Dockerfile - Added backup.py copy for container availability - docker-compose.yml - Added backup volume mount for persistence - app/routes/api.py - Added backup API endpoints (create, list, delete, cleanup) - app/routes/main.py - Added backup management route - app/templates/index.html - Added backup management navigation - README.md - Updated with backup system overview and quick start 🎯 Key Improvements: - Fixed backup creation errors in Docker environment - Added Docker-aware path detection for container operations - Implemented proper error handling and user confirmation dialogs - Added real-time backup status updates via JavaScript - Enhanced data persistence with volume mounting 💡 Use Cases: - Data protection and disaster recovery - Environment migration and cloning - Development data management - Automated maintenance workflows
378 lines
14 KiB
Python
Executable File
378 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
QR Code Manager - Backup Utility
|
|
|
|
This script creates comprehensive backups of the QR Code Manager application
|
|
including data files, configuration, and optionally the entire application.
|
|
|
|
Usage:
|
|
python backup.py [options]
|
|
|
|
Options:
|
|
--data-only : Backup only data files (default)
|
|
--full : Full backup including application files
|
|
--restore : Restore from backup
|
|
--list : List available backups
|
|
--config : Backup configuration files only
|
|
--auto : Automated backup (for cron jobs)
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import shutil
|
|
import tarfile
|
|
import argparse
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
class QRCodeManagerBackup:
|
|
def __init__(self):
|
|
self.app_root = Path(__file__).parent
|
|
self.backup_dir = self.app_root / "backups"
|
|
self.data_dir = self.app_root / "data"
|
|
self.config_files = [
|
|
".env",
|
|
".env.production",
|
|
"docker-compose.yml",
|
|
"Dockerfile",
|
|
"gunicorn.conf.py",
|
|
"requirements.txt"
|
|
]
|
|
self.data_files = [
|
|
"data/link_pages.json",
|
|
"data/qr_codes.json",
|
|
"data/short_urls.json"
|
|
]
|
|
|
|
# Ensure backup directory exists
|
|
self.backup_dir.mkdir(exist_ok=True)
|
|
|
|
def get_timestamp(self):
|
|
"""Get current timestamp for backup naming"""
|
|
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
def create_data_backup(self, backup_name=None):
|
|
"""Create backup of data files only"""
|
|
if not backup_name:
|
|
backup_name = f"qr_data_backup_{self.get_timestamp()}.tar.gz"
|
|
|
|
backup_path = self.backup_dir / backup_name
|
|
|
|
print(f"🗂️ Creating data backup: {backup_name}")
|
|
|
|
with tarfile.open(backup_path, "w:gz") as tar:
|
|
# Add data files
|
|
for data_file in self.data_files:
|
|
file_path = self.app_root / data_file
|
|
if file_path.exists():
|
|
tar.add(file_path, arcname=data_file)
|
|
print(f" ✅ Added: {data_file}")
|
|
else:
|
|
print(f" ⚠️ Missing: {data_file}")
|
|
|
|
# Add backup metadata
|
|
metadata = {
|
|
"backup_type": "data",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"app_version": self.get_app_version(),
|
|
"files_included": self.data_files
|
|
}
|
|
|
|
metadata_path = self.app_root / "backup_metadata.json"
|
|
with open(metadata_path, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
tar.add(metadata_path, arcname="backup_metadata.json")
|
|
metadata_path.unlink() # Remove temp file
|
|
|
|
print(f"✅ Data backup created: {backup_path}")
|
|
return backup_path
|
|
|
|
def create_config_backup(self, backup_name=None):
|
|
"""Create backup of configuration files only"""
|
|
if not backup_name:
|
|
backup_name = f"qr_config_backup_{self.get_timestamp()}.tar.gz"
|
|
|
|
backup_path = self.backup_dir / backup_name
|
|
|
|
print(f"⚙️ Creating config backup: {backup_name}")
|
|
|
|
with tarfile.open(backup_path, "w:gz") as tar:
|
|
# Add config files
|
|
for config_file in self.config_files:
|
|
file_path = self.app_root / config_file
|
|
if file_path.exists():
|
|
tar.add(file_path, arcname=config_file)
|
|
print(f" ✅ Added: {config_file}")
|
|
else:
|
|
print(f" ⚠️ Missing: {config_file}")
|
|
|
|
# Add backup metadata
|
|
metadata = {
|
|
"backup_type": "config",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"app_version": self.get_app_version(),
|
|
"files_included": self.config_files
|
|
}
|
|
|
|
metadata_path = self.app_root / "backup_metadata.json"
|
|
with open(metadata_path, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
tar.add(metadata_path, arcname="backup_metadata.json")
|
|
metadata_path.unlink() # Remove temp file
|
|
|
|
print(f"✅ Config backup created: {backup_path}")
|
|
return backup_path
|
|
|
|
def create_full_backup(self, backup_name=None):
|
|
"""Create full backup including application files"""
|
|
if not backup_name:
|
|
backup_name = f"qr_full_backup_{self.get_timestamp()}.tar.gz"
|
|
|
|
backup_path = self.backup_dir / backup_name
|
|
|
|
print(f"📦 Creating full backup: {backup_name}")
|
|
|
|
# Exclude patterns
|
|
exclude_patterns = [
|
|
"__pycache__",
|
|
"*.pyc",
|
|
".git",
|
|
"backups",
|
|
"*.log",
|
|
".dockerignore",
|
|
"backup.py"
|
|
]
|
|
|
|
def exclude_filter(tarinfo):
|
|
for pattern in exclude_patterns:
|
|
if pattern in tarinfo.name:
|
|
return None
|
|
return tarinfo
|
|
|
|
with tarfile.open(backup_path, "w:gz") as tar:
|
|
# Add entire application directory
|
|
tar.add(self.app_root, arcname="qr-code-manager", filter=exclude_filter)
|
|
|
|
# Add backup metadata
|
|
metadata = {
|
|
"backup_type": "full",
|
|
"timestamp": datetime.now().isoformat(),
|
|
"app_version": self.get_app_version(),
|
|
"excluded_patterns": exclude_patterns
|
|
}
|
|
|
|
metadata_path = self.app_root / "backup_metadata.json"
|
|
with open(metadata_path, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
tar.add(metadata_path, arcname="backup_metadata.json")
|
|
metadata_path.unlink() # Remove temp file
|
|
|
|
print(f"✅ Full backup created: {backup_path}")
|
|
return backup_path
|
|
|
|
def list_backups(self):
|
|
"""List all available backups"""
|
|
print("📋 Available backups:")
|
|
print("-" * 60)
|
|
|
|
backup_files = sorted(self.backup_dir.glob("*.tar.gz"), reverse=True)
|
|
|
|
if not backup_files:
|
|
print(" No backups found")
|
|
return
|
|
|
|
for backup_file in backup_files:
|
|
# Get file info
|
|
stat = backup_file.stat()
|
|
size_mb = stat.st_size / (1024 * 1024)
|
|
modified = datetime.fromtimestamp(stat.st_mtime)
|
|
|
|
# Try to get backup type from filename
|
|
if "data" in backup_file.name:
|
|
backup_type = "📄 Data"
|
|
elif "config" in backup_file.name:
|
|
backup_type = "⚙️ Config"
|
|
elif "full" in backup_file.name:
|
|
backup_type = "📦 Full"
|
|
else:
|
|
backup_type = "❓ Unknown"
|
|
|
|
print(f" {backup_type:12} | {backup_file.name:40} | {size_mb:6.1f}MB | {modified.strftime('%Y-%m-%d %H:%M')}")
|
|
|
|
def restore_backup(self, backup_file):
|
|
"""Restore from backup file"""
|
|
backup_path = Path(backup_file)
|
|
|
|
if not backup_path.exists():
|
|
# Try in backup directory
|
|
backup_path = self.backup_dir / backup_file
|
|
if not backup_path.exists():
|
|
print(f"❌ Backup file not found: {backup_file}")
|
|
return False
|
|
|
|
print(f"🔄 Restoring from backup: {backup_path.name}")
|
|
|
|
# Create restoration directory
|
|
restore_dir = self.app_root / f"restore_{self.get_timestamp()}"
|
|
restore_dir.mkdir(exist_ok=True)
|
|
|
|
try:
|
|
with tarfile.open(backup_path, "r:gz") as tar:
|
|
# Extract to restoration directory
|
|
tar.extractall(restore_dir)
|
|
|
|
# Check backup metadata
|
|
metadata_file = restore_dir / "backup_metadata.json"
|
|
if metadata_file.exists():
|
|
with open(metadata_file) as f:
|
|
metadata = json.load(f)
|
|
print(f" Backup type: {metadata.get('backup_type', 'unknown')}")
|
|
print(f" Created: {metadata.get('timestamp', 'unknown')}")
|
|
print(f" App version: {metadata.get('app_version', 'unknown')}")
|
|
|
|
# Ask for confirmation
|
|
confirm = input(" Proceed with restoration? (y/N): ").lower().strip()
|
|
if confirm != 'y':
|
|
shutil.rmtree(restore_dir)
|
|
print("❌ Restoration cancelled")
|
|
return False
|
|
|
|
# Restore files based on backup type
|
|
backup_type = metadata.get('backup_type', 'unknown') if metadata_file.exists() else 'unknown'
|
|
|
|
if backup_type == 'data':
|
|
# Restore data files
|
|
for data_file in self.data_files:
|
|
src = restore_dir / data_file
|
|
dst = self.app_root / data_file
|
|
if src.exists():
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(src, dst)
|
|
print(f" ✅ Restored: {data_file}")
|
|
|
|
elif backup_type == 'config':
|
|
# Restore config files
|
|
for config_file in self.config_files:
|
|
src = restore_dir / config_file
|
|
dst = self.app_root / config_file
|
|
if src.exists():
|
|
shutil.copy2(src, dst)
|
|
print(f" ✅ Restored: {config_file}")
|
|
|
|
elif backup_type == 'full':
|
|
# Full restoration - more complex
|
|
print(" ⚠️ Full restoration requires manual review")
|
|
print(f" Extracted to: {restore_dir}")
|
|
print(" Please manually copy files as needed")
|
|
return True
|
|
|
|
# Cleanup
|
|
shutil.rmtree(restore_dir)
|
|
print("✅ Restoration completed successfully")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"❌ Restoration failed: {e}")
|
|
if restore_dir.exists():
|
|
shutil.rmtree(restore_dir)
|
|
return False
|
|
|
|
def get_app_version(self):
|
|
"""Get application version from main.py or git"""
|
|
try:
|
|
# Try to get git commit hash
|
|
import subprocess
|
|
result = subprocess.run(['git', 'rev-parse', '--short', 'HEAD'],
|
|
cwd=self.app_root,
|
|
capture_output=True,
|
|
text=True)
|
|
if result.returncode == 0:
|
|
return f"git-{result.stdout.strip()}"
|
|
except:
|
|
pass
|
|
|
|
# Fallback to file modification time
|
|
main_py = self.app_root / "main.py"
|
|
if main_py.exists():
|
|
mtime = datetime.fromtimestamp(main_py.stat().st_mtime)
|
|
return f"modified-{mtime.strftime('%Y%m%d')}"
|
|
|
|
return "unknown"
|
|
|
|
def cleanup_old_backups(self, keep_count=10):
|
|
"""Remove old backups, keeping only the most recent ones"""
|
|
backup_files = sorted(self.backup_dir.glob("*.tar.gz"),
|
|
key=lambda x: x.stat().st_mtime,
|
|
reverse=True)
|
|
|
|
if len(backup_files) <= keep_count:
|
|
return
|
|
|
|
print(f"🧹 Cleaning up old backups (keeping {keep_count} most recent)")
|
|
|
|
for backup_file in backup_files[keep_count:]:
|
|
print(f" 🗑️ Removing: {backup_file.name}")
|
|
backup_file.unlink()
|
|
|
|
def automated_backup(self):
|
|
"""Perform automated backup suitable for cron jobs"""
|
|
print(f"🤖 Automated backup started at {datetime.now()}")
|
|
|
|
try:
|
|
# Create data backup
|
|
self.create_data_backup()
|
|
|
|
# Create config backup weekly (if it's Monday)
|
|
if datetime.now().weekday() == 0: # Monday
|
|
self.create_config_backup()
|
|
|
|
# Create full backup monthly (if it's the 1st)
|
|
if datetime.now().day == 1:
|
|
self.create_full_backup()
|
|
|
|
# Cleanup old backups
|
|
self.cleanup_old_backups(keep_count=15)
|
|
|
|
print("✅ Automated backup completed successfully")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Automated backup failed: {e}")
|
|
sys.exit(1)
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="QR Code Manager Backup Utility")
|
|
parser.add_argument("--data-only", action="store_true", help="Backup only data files")
|
|
parser.add_argument("--config", action="store_true", help="Backup only config files")
|
|
parser.add_argument("--full", action="store_true", help="Full backup including application files")
|
|
parser.add_argument("--restore", type=str, help="Restore from backup file")
|
|
parser.add_argument("--list", action="store_true", help="List available backups")
|
|
parser.add_argument("--auto", action="store_true", help="Automated backup (for cron jobs)")
|
|
parser.add_argument("--cleanup", type=int, help="Cleanup old backups, keeping N most recent")
|
|
|
|
args = parser.parse_args()
|
|
|
|
backup_util = QRCodeManagerBackup()
|
|
|
|
if args.list:
|
|
backup_util.list_backups()
|
|
elif args.restore:
|
|
backup_util.restore_backup(args.restore)
|
|
elif args.auto:
|
|
backup_util.automated_backup()
|
|
elif args.cleanup:
|
|
backup_util.cleanup_old_backups(args.cleanup)
|
|
elif args.config:
|
|
backup_util.create_config_backup()
|
|
elif args.full:
|
|
backup_util.create_full_backup()
|
|
else:
|
|
# Default: data backup
|
|
backup_util.create_data_backup()
|
|
|
|
if __name__ == "__main__":
|
|
main()
|