Files
MathApp/statuslog/backup.py

225 lines
10 KiB
Python

"""
بکاپِ زمان‌بندی‌شده‌ی 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 <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