Files
2026-06-23 23:08:16 +03:30

175 lines
7.8 KiB
Python

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))