""" بکاپِ زمان‌بندی‌شده‌ی Postgres و آزمونِ بازیابی. چرا فایلِ جدا: تا امروز تنها بکاپِ خودکار همان بود که `deploy.sh` پیش از migrate می‌گیرد (۲ تای آخر)؛ اگر چند روز دیپلویی نبود، بکاپِ تازه‌ای هم نبود. این‌جا یک بکاپِ روزانه، نگه‌داری، هوک برای کپیِ بیرونِ ماشین، و بازیابی روی دیتابیسِ آزمایشی هست. وضعیتش در `status.json` کنارِ بکاپ‌ها می‌ماند و `/api/health` از روی آن هشدار می‌دهد. آزموده‌شده: pg_dump/pg_restore روی Postgres ۱۴ی لپ‌تاپ (statuslog/tests_backup.py، تست‌های واقعی فقط وقتی Postgres در دسترس باشد). آزموده‌نشده: روی سرور، زیر cron، با دیسکِ نزدیک به پُر، با `BACKUP_OFFSITE_CMD` واقعی. """ import datetime import hashlib import json import logging import os import shlex import shutil import subprocess from pathlib import Path from django.apps import apps from django.conf import settings from django.db import connection logger = logging.getLogger(__name__) STATUS_NAME = 'status.json' KEEP_DAILY = 7 # هفت روزِ آخر، از هر روز آخرین بکاپ KEEP_WEEKLY = 4 # چهار هفته‌ی آخر، از هر هفته آخرین بکاپ # جدول‌هایی که پرسشِ «پرداخت و ورود» به آن‌ها بسته است؛ شمارششان با بکاپ و بازیابی مقایسه می‌شود. TRACKED = ['accounts.User', 'accounts.LoginEvent', 'payment.Order', 'payment.Transaction', 'statuslog.StatusChangeLog'] class BackupError(Exception): pass def backup_dir() -> Path: return Path(settings.BACKUP_DIR) def status_path() -> Path: return backup_dir() / STATUS_NAME def read_status() -> dict: try: return json.loads(status_path().read_text()) except FileNotFoundError: return {} except (OSError, ValueError): # فایلِ خراب همان «بکاپ نیست» نیست؛ صدا کن تا /api/health آن را نشان دهد. logger.exception('status.json خوانده نشد: %s', status_path()) return {'corrupt': True} def write_status(update: dict) -> dict: status = read_status() status.pop('corrupt', None) status.update(update) backup_dir().mkdir(parents=True, exist_ok=True, mode=0o700) tmp = status_path().with_suffix('.tmp') tmp.write_text(json.dumps(status, ensure_ascii=False, indent=1)) os.chmod(tmp, 0o600) os.replace(tmp, status_path()) return status def require_postgres() -> dict: db = settings.DATABASES['default'] if db['ENGINE'] != 'django.db.backends.postgresql': raise BackupError('بکاپِ زمان‌بندی‌شده فقط برای Postgres است (DB_NAME در .env ست نیست).') return db def _pg_env(db: dict) -> dict: # رمز از env می‌رود نه argv: ps روی ماشینِ مشترک argv را به همه نشان می‌دهد. return dict(os.environ, PGPASSWORD=db['PASSWORD']) def _pg_args(db: dict) -> list: return ['-h', db['HOST'], '-p', str(db['PORT']), '-U', db['USER']] def table_counts(conn=None) -> dict: conn = conn or connection out = {} with conn.cursor() as cur: for label in TRACKED: table = apps.get_model(label)._meta.db_table cur.execute(f'SELECT count(*) FROM "{table}"') out[label] = cur.fetchone()[0] return out def _run(cmd: list, env: dict, what: str) -> subprocess.CompletedProcess: try: return subprocess.run(cmd, env=env, check=True, capture_output=True, text=True) except FileNotFoundError as exc: raise BackupError(f'{what}: فرمانِ {cmd[0]} روی این ماشین نیست.') from exc except subprocess.CalledProcessError as exc: raise BackupError(f'{what} شکست خورد: {(exc.stderr or "").strip()[-500:]}') from exc def _prune(directory: Path) -> list: files = sorted(directory.glob('db-*.pgdump')) by_day, by_week = {}, {} for f in files: # مرتب‌شده؛ آخری برای هر کلید می‌ماند stamp = datetime.datetime.strptime(f.name[3:18], '%Y%m%d-%H%M%S') by_day[stamp.date()] = f iso = stamp.isocalendar() by_week[(iso[0], iso[1])] = f keep = {v for _, v in sorted(by_day.items())[-KEEP_DAILY:]} keep |= {v for _, v in sorted(by_week.items())[-KEEP_WEEKLY:]} removed = [f for f in files if f not in keep] for f in removed: f.unlink() return removed def run_offsite(path: Path) -> dict: """ `BACKUP_OFFSITE_CMD` یک فرمان با جاینگهدار `{file}` است (مثلاً rsync/scp/rclone به مقصدی که ادمین تعیین می‌کند). ست‌نبودنش خطا نیست ولی صریح «تنظیم نشده» ثبت می‌شود و health هشدار می‌دهد. """ template = os.environ.get('BACKUP_OFFSITE_CMD', '').strip() if not template: return {'state': 'not_configured'} if '{file}' not in template: return {'state': 'failed', 'error': 'BACKUP_OFFSITE_CMD باید {file} داشته باشد.'} cmd = [part.replace('{file}', str(path)) for part in shlex.split(template)] try: subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=1800) except (subprocess.SubprocessError, OSError) as exc: detail = getattr(exc, 'stderr', '') or str(exc) logger.error('کپیِ بیرونیِ بکاپ شکست خورد: %s', detail.strip()[-300:]) return {'state': 'failed', 'error': detail.strip()[-300:]} return {'state': 'ok', 'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')} def run_backup() -> dict: db = require_postgres() directory = backup_dir() directory.mkdir(parents=True, exist_ok=True, mode=0o700) try: previous = sorted(directory.glob('db-*.pgdump')) need = (previous[-1].stat().st_size * 2) if previous else 100 * 2**20 free = shutil.disk_usage(directory).free if free < need: raise BackupError(f'فضای خالی ({free // 2**20} MB) کمتر از لازم ({need // 2**20} MB) است.') before = table_counts() now = datetime.datetime.now() dst = directory / f'db-{now:%Y%m%d-%H%M%S}.pgdump' _run(['pg_dump', '-Fc', *_pg_args(db), '-f', str(dst), db['NAME']], _pg_env(db), 'pg_dump') os.chmod(dst, 0o600) after = table_counts() # فقط خواناییِ فایل؛ بازیابیِ واقعی را backup_verify می‌سنجد. _run(['pg_restore', '--list', str(dst)], _pg_env(db), 'pg_restore --list') digest = hashlib.sha256(dst.read_bytes()).hexdigest() except BackupError as exc: logger.error('بکاپِ زمان‌بندی‌شده شکست خورد: %s', exc) write_status({'last_error': str(exc), 'last_error_at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')}) raise offsite = run_offsite(dst) removed = _prune(directory) status = write_status({ 'last_ok_at': now.astimezone().isoformat(timespec='seconds'), 'file': dst.name, 'size_bytes': dst.stat().st_size, 'sha256': digest, 'counts_before': before, 'counts_after': after, 'offsite': offsite, 'pruned': [f.name for f in removed], 'last_error': '', 'last_error_at': '', }) return status def run_verify(file: str = '') -> dict: """ فایلِ بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی می‌کند، شمارشِ جدول‌های پرداخت/ورود را با لحظه‌ی بکاپ می‌سنجد و دیتابیسِ آزمایشی را می‌اندازد. به دیتابیسِ اصلی دست نمی‌زند. نقشِ برنامه باید CREATEDB داشته باشد؛ نداشتنش با پیامِ روشن خطا می‌دهد، نه موفقیتِ ساختگی. """ db = require_postgres() status = read_status() name = file or status.get('file') if not name: raise BackupError('هنوز بکاپی ثبت نشده؛ اول backup_db.') path = backup_dir() / name if not path.exists(): raise BackupError(f'فایلِ بکاپ نیست: {path}') if hashlib.sha256(path.read_bytes()).hexdigest() != status.get('sha256') and not file: raise BackupError('هشِ فایل با آنچه هنگامِ بکاپ ثبت شد نمی‌خواند (فایل عوض یا خراب شده).') scratch = f"{db['NAME']}_restorecheck" env, args = _pg_env(db), _pg_args(db) _run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb') _run(['createdb', *args, scratch], env, 'createdb (نقشِ برنامه به CREATEDB نیاز دارد: ALTER ROLE CREATEDB)') try: _run(['pg_restore', '--no-owner', '--no-privileges', *args, '-d', scratch, str(path)], env, 'pg_restore') restored = {} for label in TRACKED: table = apps.get_model(label)._meta.db_table out = _run(['psql', '-tA', *args, '-d', scratch, '-c', f'SELECT count(*) FROM "{table}"'], env, 'psql') restored[label] = int(out.stdout.strip()) finally: _run(['dropdb', '--if-exists', *args, scratch], env, 'dropdb') lo, hi = status.get('counts_before', {}), status.get('counts_after', {}) bad = {k: {'restored': v, 'expected_between': [min(lo.get(k, v), hi.get(k, v)), max(lo.get(k, v), hi.get(k, v))]} for k, v in restored.items() if file == '' and not min(lo.get(k, v), hi.get(k, v)) <= v <= max(lo.get(k, v), hi.get(k, v))} result = {'file': name, 'counts': restored, 'ok': not bad, 'mismatch': bad, 'at': datetime.datetime.now().astimezone().isoformat(timespec='seconds')} write_status({'restore_check': result}) if bad: logger.error('آزمونِ بازیابی ناهمخوان: %s', bad) raise BackupError(f'شمارش‌ها بعد از بازیابی نمی‌خوانند: {bad}') return result