Scheduled Postgres backup, restore check, backup warnings in health, payment result logging
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
224
statuslog/backup.py
Normal file
224
statuslog/backup.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
بکاپِ زمانبندیشدهی 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
|
||||
Reference in New Issue
Block a user