432 lines
15 KiB
Python
432 lines
15 KiB
Python
"""
|
|
Database Backup Management Module
|
|
Quality Recticel Application
|
|
|
|
This module provides functionality for backing up and restoring the MariaDB database,
|
|
including scheduled backups, manual backups, and backup file management.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import json
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
import configparser
|
|
from flask import current_app
|
|
import mariadb
|
|
|
|
class DatabaseBackupManager:
|
|
"""Manages database backup operations"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the backup manager with configuration from external_server.conf"""
|
|
self.config = self._load_database_config()
|
|
self.backup_path = self._get_backup_path()
|
|
self._ensure_backup_directory()
|
|
|
|
def _load_database_config(self):
|
|
"""Load database configuration from external_server.conf"""
|
|
try:
|
|
settings_file = os.path.join(current_app.instance_path, 'external_server.conf')
|
|
config = {}
|
|
|
|
if os.path.exists(settings_file):
|
|
with open(settings_file, 'r') as f:
|
|
for line in f:
|
|
if '=' in line:
|
|
key, value = line.strip().split('=', 1)
|
|
config[key] = value
|
|
|
|
return {
|
|
'host': config.get('server_domain', 'localhost'),
|
|
'port': config.get('port', '3306'),
|
|
'database': config.get('database_name', 'trasabilitate'),
|
|
'user': config.get('username', 'trasabilitate'),
|
|
'password': config.get('password', '')
|
|
}
|
|
except Exception as e:
|
|
print(f"Error loading database config: {e}")
|
|
return None
|
|
|
|
def _get_backup_path(self):
|
|
"""Get backup path from environment or use default"""
|
|
# Check environment variable (set in docker-compose)
|
|
backup_path = os.environ.get('BACKUP_PATH', '/srv/quality_app/backups')
|
|
|
|
# Check if custom path is set in config
|
|
try:
|
|
settings_file = os.path.join(current_app.instance_path, 'external_server.conf')
|
|
if os.path.exists(settings_file):
|
|
with open(settings_file, 'r') as f:
|
|
for line in f:
|
|
if line.startswith('backup_path='):
|
|
backup_path = line.strip().split('=', 1)[1]
|
|
break
|
|
except Exception as e:
|
|
print(f"Error reading backup path from config: {e}")
|
|
|
|
return backup_path
|
|
|
|
def _ensure_backup_directory(self):
|
|
"""Ensure backup directory exists"""
|
|
try:
|
|
Path(self.backup_path).mkdir(parents=True, exist_ok=True)
|
|
print(f"Backup directory ensured: {self.backup_path}")
|
|
except Exception as e:
|
|
print(f"Error creating backup directory: {e}")
|
|
|
|
def create_backup(self, backup_name=None):
|
|
"""
|
|
Create a complete backup of the database
|
|
|
|
Args:
|
|
backup_name (str, optional): Custom name for the backup file
|
|
|
|
Returns:
|
|
dict: Result with success status, message, and backup file path
|
|
"""
|
|
try:
|
|
if not self.config:
|
|
return {
|
|
'success': False,
|
|
'message': 'Database configuration not loaded'
|
|
}
|
|
|
|
# Generate backup filename
|
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
if backup_name:
|
|
filename = f"{backup_name}_{timestamp}.sql"
|
|
else:
|
|
filename = f"backup_{self.config['database']}_{timestamp}.sql"
|
|
|
|
backup_file = os.path.join(self.backup_path, filename)
|
|
|
|
# Build mysqldump command
|
|
cmd = [
|
|
'mysqldump',
|
|
f"--host={self.config['host']}",
|
|
f"--port={self.config['port']}",
|
|
f"--user={self.config['user']}",
|
|
f"--password={self.config['password']}",
|
|
'--single-transaction',
|
|
'--routines',
|
|
'--triggers',
|
|
'--events',
|
|
'--add-drop-database',
|
|
'--databases',
|
|
self.config['database']
|
|
]
|
|
|
|
# Execute mysqldump and save to file
|
|
with open(backup_file, 'w') as f:
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdout=f,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
# Get file size
|
|
file_size = os.path.getsize(backup_file)
|
|
file_size_mb = file_size / (1024 * 1024)
|
|
|
|
# Save backup metadata
|
|
self._save_backup_metadata(filename, file_size)
|
|
|
|
return {
|
|
'success': True,
|
|
'message': f'Backup created successfully',
|
|
'filename': filename,
|
|
'file_path': backup_file,
|
|
'size': f"{file_size_mb:.2f} MB",
|
|
'timestamp': timestamp
|
|
}
|
|
else:
|
|
error_msg = result.stderr
|
|
print(f"Backup error: {error_msg}")
|
|
return {
|
|
'success': False,
|
|
'message': f'Backup failed: {error_msg}'
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Exception during backup: {e}")
|
|
return {
|
|
'success': False,
|
|
'message': f'Backup failed: {str(e)}'
|
|
}
|
|
|
|
def _save_backup_metadata(self, filename, file_size):
|
|
"""Save metadata about the backup"""
|
|
try:
|
|
metadata_file = os.path.join(self.backup_path, 'backups_metadata.json')
|
|
|
|
# Load existing metadata
|
|
metadata = []
|
|
if os.path.exists(metadata_file):
|
|
with open(metadata_file, 'r') as f:
|
|
metadata = json.load(f)
|
|
|
|
# Add new backup metadata
|
|
metadata.append({
|
|
'filename': filename,
|
|
'size': file_size,
|
|
'timestamp': datetime.now().isoformat(),
|
|
'database': self.config['database']
|
|
})
|
|
|
|
# Save updated metadata
|
|
with open(metadata_file, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
except Exception as e:
|
|
print(f"Error saving backup metadata: {e}")
|
|
|
|
def list_backups(self):
|
|
"""
|
|
List all available backups
|
|
|
|
Returns:
|
|
list: List of backup information dictionaries
|
|
"""
|
|
try:
|
|
backups = []
|
|
|
|
# Get all .sql files in backup directory
|
|
if os.path.exists(self.backup_path):
|
|
for filename in os.listdir(self.backup_path):
|
|
if filename.endswith('.sql'):
|
|
file_path = os.path.join(self.backup_path, filename)
|
|
file_stat = os.stat(file_path)
|
|
|
|
backups.append({
|
|
'filename': filename,
|
|
'size': file_stat.st_size,
|
|
'size_mb': f"{file_stat.st_size / (1024 * 1024):.2f}",
|
|
'created': datetime.fromtimestamp(file_stat.st_ctime).strftime('%Y-%m-%d %H:%M:%S'),
|
|
'timestamp': file_stat.st_ctime
|
|
})
|
|
|
|
# Sort by timestamp (newest first)
|
|
backups.sort(key=lambda x: x['timestamp'], reverse=True)
|
|
|
|
return backups
|
|
|
|
except Exception as e:
|
|
print(f"Error listing backups: {e}")
|
|
return []
|
|
|
|
def delete_backup(self, filename):
|
|
"""
|
|
Delete a backup file
|
|
|
|
Args:
|
|
filename (str): Name of the backup file to delete
|
|
|
|
Returns:
|
|
dict: Result with success status and message
|
|
"""
|
|
try:
|
|
# Security: ensure filename doesn't contain path traversal
|
|
if '..' in filename or '/' in filename:
|
|
return {
|
|
'success': False,
|
|
'message': 'Invalid filename'
|
|
}
|
|
|
|
file_path = os.path.join(self.backup_path, filename)
|
|
|
|
if os.path.exists(file_path):
|
|
os.remove(file_path)
|
|
|
|
# Update metadata
|
|
self._remove_backup_metadata(filename)
|
|
|
|
return {
|
|
'success': True,
|
|
'message': f'Backup {filename} deleted successfully'
|
|
}
|
|
else:
|
|
return {
|
|
'success': False,
|
|
'message': 'Backup file not found'
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error deleting backup: {e}")
|
|
return {
|
|
'success': False,
|
|
'message': f'Failed to delete backup: {str(e)}'
|
|
}
|
|
|
|
def _remove_backup_metadata(self, filename):
|
|
"""Remove metadata entry for deleted backup"""
|
|
try:
|
|
metadata_file = os.path.join(self.backup_path, 'backups_metadata.json')
|
|
|
|
if os.path.exists(metadata_file):
|
|
with open(metadata_file, 'r') as f:
|
|
metadata = json.load(f)
|
|
|
|
# Filter out the deleted backup
|
|
metadata = [m for m in metadata if m['filename'] != filename]
|
|
|
|
with open(metadata_file, 'w') as f:
|
|
json.dump(metadata, f, indent=2)
|
|
|
|
except Exception as e:
|
|
print(f"Error removing backup metadata: {e}")
|
|
|
|
def restore_backup(self, filename):
|
|
"""
|
|
Restore database from a backup file
|
|
|
|
Args:
|
|
filename (str): Name of the backup file to restore
|
|
|
|
Returns:
|
|
dict: Result with success status and message
|
|
"""
|
|
try:
|
|
# Security: ensure filename doesn't contain path traversal
|
|
if '..' in filename or '/' in filename:
|
|
return {
|
|
'success': False,
|
|
'message': 'Invalid filename'
|
|
}
|
|
|
|
file_path = os.path.join(self.backup_path, filename)
|
|
|
|
if not os.path.exists(file_path):
|
|
return {
|
|
'success': False,
|
|
'message': 'Backup file not found'
|
|
}
|
|
|
|
# Build mysql restore command
|
|
cmd = [
|
|
'mysql',
|
|
f"--host={self.config['host']}",
|
|
f"--port={self.config['port']}",
|
|
f"--user={self.config['user']}",
|
|
f"--password={self.config['password']}"
|
|
]
|
|
|
|
# Execute mysql restore
|
|
with open(file_path, 'r') as f:
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdin=f,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode == 0:
|
|
return {
|
|
'success': True,
|
|
'message': f'Database restored successfully from {filename}'
|
|
}
|
|
else:
|
|
error_msg = result.stderr
|
|
print(f"Restore error: {error_msg}")
|
|
return {
|
|
'success': False,
|
|
'message': f'Restore failed: {error_msg}'
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Exception during restore: {e}")
|
|
return {
|
|
'success': False,
|
|
'message': f'Restore failed: {str(e)}'
|
|
}
|
|
|
|
def get_backup_schedule(self):
|
|
"""Get current backup schedule configuration"""
|
|
try:
|
|
schedule_file = os.path.join(self.backup_path, 'backup_schedule.json')
|
|
|
|
if os.path.exists(schedule_file):
|
|
with open(schedule_file, 'r') as f:
|
|
return json.load(f)
|
|
|
|
# Default schedule
|
|
return {
|
|
'enabled': False,
|
|
'time': '02:00', # 2 AM
|
|
'frequency': 'daily', # daily, weekly, monthly
|
|
'retention_days': 30 # Keep backups for 30 days
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error loading backup schedule: {e}")
|
|
return None
|
|
|
|
def save_backup_schedule(self, schedule):
|
|
"""
|
|
Save backup schedule configuration
|
|
|
|
Args:
|
|
schedule (dict): Schedule configuration
|
|
|
|
Returns:
|
|
dict: Result with success status and message
|
|
"""
|
|
try:
|
|
schedule_file = os.path.join(self.backup_path, 'backup_schedule.json')
|
|
|
|
with open(schedule_file, 'w') as f:
|
|
json.dump(schedule, f, indent=2)
|
|
|
|
return {
|
|
'success': True,
|
|
'message': 'Backup schedule saved successfully'
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error saving backup schedule: {e}")
|
|
return {
|
|
'success': False,
|
|
'message': f'Failed to save schedule: {str(e)}'
|
|
}
|
|
|
|
def cleanup_old_backups(self, retention_days=30):
|
|
"""
|
|
Delete backups older than retention_days
|
|
|
|
Args:
|
|
retention_days (int): Number of days to keep backups
|
|
|
|
Returns:
|
|
dict: Result with count of deleted backups
|
|
"""
|
|
try:
|
|
deleted_count = 0
|
|
cutoff_time = datetime.now() - timedelta(days=retention_days)
|
|
|
|
if os.path.exists(self.backup_path):
|
|
for filename in os.listdir(self.backup_path):
|
|
if filename.endswith('.sql'):
|
|
file_path = os.path.join(self.backup_path, filename)
|
|
file_time = datetime.fromtimestamp(os.path.getctime(file_path))
|
|
|
|
if file_time < cutoff_time:
|
|
os.remove(file_path)
|
|
self._remove_backup_metadata(filename)
|
|
deleted_count += 1
|
|
print(f"Deleted old backup: {filename}")
|
|
|
|
return {
|
|
'success': True,
|
|
'deleted_count': deleted_count,
|
|
'message': f'Cleaned up {deleted_count} old backup(s)'
|
|
}
|
|
|
|
except Exception as e:
|
|
print(f"Error cleaning up old backups: {e}")
|
|
return {
|
|
'success': False,
|
|
'message': f'Cleanup failed: {str(e)}'
|
|
}
|