import os import sqlite3 import logging import shutil import fcntl import signal from datetime import datetime from pathlib import Path import boto3 from botocore.config import Config from botocore.exceptions import ClientError from django.core.management.base import BaseCommand from django.conf import settings logger = logging.getLogger('django.production.backup') class Command(BaseCommand): help = 'Enterprise-grade, Chunked, URI Read-Only SQLite WAL backup for Docker/Swarm.' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) # اتصال سیگنال‌های توقف داکر به متد خروج امن signal.signal(signal.SIGTERM, self._signal_handler) signal.signal(signal.SIGINT, self._signal_handler) self.lock_file = None self.local_backup_path = None self._shutdown_requested = False def _signal_handler(self, signum, frame): """ فقط فلگ را تغییر می‌دهیم. خروج ناگهانی (sys.exit) باعث خرابی می‌شود. انجین در چرخه بعدی متوجه این فلگ شده و با احترام خارج می‌شود. """ logger.warning(f"OS Signal {signum} received from Coolify/Docker. Initiating graceful abort...") self._shutdown_requested = True def handle(self, *args, **options): # ۱. قفل انحصاری (محل قفل می‌تواند در /tmp باشد چون حجمش صفر است) lock_file_path = "/tmp/django_sqlite_backup.lock" self.lock_file = open(lock_file_path, "w") try: fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) except IOError: self.stderr.write(self.style.WARNING("ABORTED: Another backup cronjob is currently running.")) return required_envs = ['S3_ACCESS_KEY', 'S3_SECRET_KEY', 'S3_ENDPOINT', 'S3_BUCKET'] missing_envs = [env for env in required_envs if not os.getenv(env)] if missing_envs: self.handle_critical_failure(f"Missing S3 Configuration ENVs: {', '.join(missing_envs)}") return db_path = Path(settings.DATABASES['default']['NAME']).absolute() timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') backup_filename = f"db_backup_{timestamp}.sqlite3" # مهم: ذخیره فایل موقت در کنار دیتابیس اصلی برای جلوگیری از افتادن در تله tmpfs (RAM) self.local_backup_path = db_path.parent / backup_filename src_conn = None dst_conn = None try: if not db_path.exists(): raise FileNotFoundError(f"Source database not found at {db_path}") db_size = db_path.stat().st_size _, _, free_space = shutil.disk_usage(db_path.parent) if free_space < (db_size * 1.5): raise IOError(f"Insufficient volume space. Req: {db_size*1.5}B, Free: {free_space}B") logger.info("Initializing Zero-Impact Read-Only connection to production DB...") db_uri = f"file:{db_path}?mode=ro" src_conn = sqlite3.connect(db_uri, uri=True, timeout=30) dst_conn = sqlite3.connect(self.local_backup_path) logger.info("Executing native SQLite C-Level chunked backup (Preventing WAL Starvation)...") # کالبک هوشمند برای مانیتورینگ وضعیت کپی و بررسی سیگنال توقف def progress_callback(status, remaining, total): if self._shutdown_requested: # با پرتاب این اکسپشن، انجین C کپی را متوقف کرده و کنترل را به پایتون پس می‌دهد raise InterruptedError("Graceful abort: Container shutdown requested.") return None # ادامه عملیات with dst_conn: # استفاده از قابلیت‌های نیتیو: کپی ۲۵۰ صفحه، ۵۰ میلی‌ثانیه تنفس به دیتابیس زنده # این کار حلقه While را حذف کرده و مدیریت را به سطح کرنل/انجین C می‌سپارد src_conn.backup( dst_conn, pages=250, sleep=0.05, progress=progress_callback ) dst_conn.close() src_conn.close() src_conn = dst_conn = None # اگر توقف به دلیل ری‌استارت سرور بوده، فایل ناقص را آپلود نمی‌کنیم if self._shutdown_requested: logger.info("Backup safely aborted before S3 upload.") return if not self.local_backup_path.exists() or self.local_backup_path.stat().st_size == 0: raise ValueError("Generated snapshot is missing or 0 bytes.") # ۲. انتقال امن به S3 s3_config = Config( retries={'max_attempts': 5, 'mode': 'standard'}, connect_timeout=15, read_timeout=60 # افزایش زمان برای فایل‌های بزرگ ) s3_client = boto3.client( 's3', aws_access_key_id=os.getenv('S3_ACCESS_KEY'), aws_secret_access_key=os.getenv('S3_SECRET_KEY'), endpoint_url=os.getenv('S3_ENDPOINT'), config=s3_config ) bucket_name = os.getenv('S3_BUCKET') s3_key = f"backups/{backup_filename}" logger.info("Transferring encrypted snapshot to S3 Object Storage...") # Boto3 به صورت خودکار برای فایل‌های بزرگ از Multipart Upload استفاده می‌کند s3_client.upload_file(Filename=str(self.local_backup_path), Bucket=bucket_name, Key=s3_key) # ۳. اعتبارسنجی نهایی s3_metadata = s3_client.head_object(Bucket=bucket_name, Key=s3_key) if s3_metadata['ContentLength'] != self.local_backup_path.stat().st_size: raise ValueError("Integrity breach: Size mismatch inside S3 bucket!") success_msg = f"SUCCESS: Production backup {backup_filename} verified and secured." logger.info(success_msg) self.stdout.write(self.style.SUCCESS(success_msg)) except InterruptedError as e: # هندل کردن لاگ توقف امن (این یک فاجعه نیست، رفتار سیستم است) logger.warning(str(e)) except Exception as e: self.handle_critical_failure(f"CRITICAL BACKUP FAILURE: {str(e)}") finally: # تضمین آزادسازی منابع تحت هر شرایطی if src_conn: try: src_conn.close() except: pass if dst_conn: try: dst_conn.close() except: pass self.cleanup_resources() def cleanup_resources(self): """تضمین پاکسازی Volume های داکر در هر شرایطی""" if self.local_backup_path and self.local_backup_path.exists(): try: os.remove(self.local_backup_path) logger.info("Ephemeral local snapshot purged from volume.") except Exception as e: logger.error(f"Leak Alert: Failed to purge temp file: {str(e)}") if self.lock_file: try: fcntl.flock(self.lock_file, fcntl.LOCK_UN) self.lock_file.close() except: pass def handle_critical_failure(self, message): logger.critical(message, exc_info=True) self.stderr.write(self.style.ERROR(message))