From 58f8a49e245f7fdb20551b0e4f57ae0b2ea0c8be Mon Sep 17 00:00:00 2001 From: javad Date: Tue, 23 Jun 2026 23:08:16 +0330 Subject: [PATCH] auto backup sqlite --- admin_panel/management/__init__.py | 0 admin_panel/management/commands/__init__.py | 0 .../management/commands/sqlite_db_backup.py | 175 ++++++++++++++++++ 3 files changed, 175 insertions(+) create mode 100644 admin_panel/management/__init__.py create mode 100644 admin_panel/management/commands/__init__.py create mode 100644 admin_panel/management/commands/sqlite_db_backup.py diff --git a/admin_panel/management/__init__.py b/admin_panel/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin_panel/management/commands/__init__.py b/admin_panel/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/admin_panel/management/commands/sqlite_db_backup.py b/admin_panel/management/commands/sqlite_db_backup.py new file mode 100644 index 0000000..794c2da --- /dev/null +++ b/admin_panel/management/commands/sqlite_db_backup.py @@ -0,0 +1,175 @@ +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)) \ No newline at end of file