175 lines
7.8 KiB
Python
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)) |