225 lines
10 KiB
Python
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
|