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:
@@ -144,6 +144,43 @@ def _ai_provider_state() -> dict:
|
|||||||
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
|
return {'state': row['new_status'], 'since': row['created_at'].isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_warnings() -> list[str]:
|
||||||
|
"""بکاپِ روزانه، کپیِ بیرونی و آزمونِ بازیابی؛ فقط وقتی Postgres است (SQLite یعنی توسعهی محلی)."""
|
||||||
|
from datetime import datetime, timezone as dt_tz
|
||||||
|
|
||||||
|
from statuslog.backup import read_status
|
||||||
|
|
||||||
|
if settings.DEBUG or connection.vendor != 'postgresql':
|
||||||
|
return []
|
||||||
|
st = read_status()
|
||||||
|
now = datetime.now(dt_tz.utc)
|
||||||
|
|
||||||
|
def age_hours(iso: str):
|
||||||
|
return (now - datetime.fromisoformat(iso)).total_seconds() / 3600 if iso else None
|
||||||
|
|
||||||
|
out = []
|
||||||
|
if st.get('corrupt'):
|
||||||
|
return ['status.json بکاپ خوانده نشد؛ لاگ را ببینید.']
|
||||||
|
age = age_hours(st.get('last_ok_at'))
|
||||||
|
if age is None:
|
||||||
|
out.append('هیچ بکاپِ زمانبندیشدهای ثبت نشده (manage.py backup_db با cron اجرا نمیشود).')
|
||||||
|
elif age > settings.BACKUP_MAX_AGE_HOURS:
|
||||||
|
out.append(f'آخرین بکاپِ موفق {age:.0f} ساعت پیش بود (سقف {settings.BACKUP_MAX_AGE_HOURS}).')
|
||||||
|
if st.get('last_error'):
|
||||||
|
out.append(f"آخرین تلاشِ بکاپ شکست خورد: {st['last_error'][:200]}")
|
||||||
|
if (st.get('offsite') or {}).get('state') != 'ok':
|
||||||
|
out.append('کپیِ بکاپ بیرون از این ماشین نیست یا شکست خورده (BACKUP_OFFSITE_CMD).')
|
||||||
|
rc = st.get('restore_check') or {}
|
||||||
|
rc_age = age_hours(rc.get('at'))
|
||||||
|
if rc_age is None:
|
||||||
|
out.append('بازیابیِ بکاپ هنوز آزموده نشده (manage.py backup_verify).')
|
||||||
|
elif not rc.get('ok'):
|
||||||
|
out.append('آخرین آزمونِ بازیابی ناهمخوان بود.')
|
||||||
|
elif rc_age > settings.RESTORE_CHECK_MAX_AGE_DAYS * 24:
|
||||||
|
out.append(f'آخرین آزمونِ بازیابی {rc_age / 24:.0f} روز پیش بود.')
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _warnings() -> list[str]:
|
def _warnings() -> list[str]:
|
||||||
"""
|
"""
|
||||||
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناکاند.
|
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناکاند.
|
||||||
@@ -182,6 +219,7 @@ def _warnings() -> list[str]:
|
|||||||
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
||||||
'«در دسترس نیست» میدهد تا حساب شارژ شود.'
|
'«در دسترس نیست» میدهد تا حساب شارژ شود.'
|
||||||
)
|
)
|
||||||
|
out.extend(_backup_warnings())
|
||||||
backend = settings.CACHES['default']['BACKEND']
|
backend = settings.CACHES['default']['BACKEND']
|
||||||
if not settings.DEBUG and 'LocMem' in backend:
|
if not settings.DEBUG and 'LocMem' in backend:
|
||||||
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||||||
|
|||||||
@@ -969,6 +969,12 @@ LOGGING = {
|
|||||||
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
|
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
|
||||||
LOG_DIR = os.environ.get('LOG_DIR', '')
|
LOG_DIR = os.environ.get('LOG_DIR', '')
|
||||||
|
|
||||||
|
# بکاپِ زمانبندیشده (statuslog/backup.py). پیشفرض داخلِ همان پوشهی gitignoreشدهی deploy.sh است؛
|
||||||
|
# مقصدِ بیرونِ ماشین جداست: BACKUP_OFFSITE_CMD در .env (ستنبودنش را /api/health هشدار میدهد).
|
||||||
|
BACKUP_DIR = os.environ.get('BACKUP_DIR', str(BASE_DIR / '.deploy-backups' / 'scheduled'))
|
||||||
|
BACKUP_MAX_AGE_HOURS = 26 # روزانه + دو ساعت جا؛ دیرتر از این یعنی cron نخورده
|
||||||
|
RESTORE_CHECK_MAX_AGE_DAYS = 35
|
||||||
|
|
||||||
if LOG_DIR:
|
if LOG_DIR:
|
||||||
os.makedirs(LOG_DIR, exist_ok=True)
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
LOGGING['handlers']['file'] = {
|
LOGGING['handlers']['file'] = {
|
||||||
|
|||||||
@@ -72,9 +72,27 @@ curl -i https://mathapi.tadrisino2.ir/api/health
|
|||||||
|
|
||||||
## بکاپ و بازیابی
|
## بکاپ و بازیابی
|
||||||
|
|
||||||
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). `deploy.sh` پیش از `migrate`
|
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). سه لایه:
|
||||||
با `pg_dump -Fc` بکاپ میگیرد (`.deploy-backups/db-before-deploy-<زمان>.pgdump`، ۲ تای آخر) و اگر بکاپ نشد migrate را اجرا نمیکند. بازیابی از آن روی دیتابیسِ جدا آزموده نشده.
|
|
||||||
بکاپ روی همان ماشین است؛ نسخهی بیرون از ماشین هنوز نیست. بدون `DB_NAME` برنامه روی `db.sqlite3` (فقط توسعهی محلی) میماند. بکاپِ دستیِ SQLite (توسعهی محلی):
|
| لایه | چه زمانی | نگهداری | کجا |
|
||||||
|
|---|---|---|---|
|
||||||
|
| پیش از migrate | هر `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمیشود) | ۲ تای آخر | `.deploy-backups/db-before-deploy-*.pgdump` |
|
||||||
|
| روزانه | cron: `python manage.py backup_db` (`statuslog/backup.py`) | ۷ روزِ آخر + ۴ هفتهی آخر | `.deploy-backups/scheduled/` + `status.json` |
|
||||||
|
| بیرونِ ماشین | همان فرمان، اگر `BACKUP_OFFSITE_CMD` در `.env` باشد (فرمانی با `{file}`، مثلاً rsync/scp/rclone) | دستِ مقصد | مقصد را ادمین تعیین میکند؛ تا تعیین نشده `/api/health` هشدار میدهد |
|
||||||
|
|
||||||
|
نصبِ بکاپِ شبانه (یک بار، با `crontab -e` حسابِ `administrator`؛ بکاپِ crontab را پیش از ویرایش بگیرید):
|
||||||
|
|
||||||
|
```
|
||||||
|
15 2 * * * cd /home/administrator/mathapp-v2 && venv/bin/python manage.py backup_db >> .deploy-backups/scheduled/cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
**آزمونِ بازیابی (ماهی یک بار، و پس از هر تغییرِ بکاپ):** `venv/bin/python manage.py backup_verify` آخرین بکاپ را روی دیتابیسِ آزمایشیِ `<DB_NAME>_restorecheck` برمیگرداند، شمارشِ کاربر/ورود/سفارش/تراکنش/لاگِ وضعیت را با لحظهی بکاپ میسنجد و آن دیتابیس را میاندازد؛ به دیتابیسِ اصلی دست نمیزند. نقشِ برنامه باید `CREATEDB` داشته باشد (`ALTER ROLE zareian_app CREATEDB;` با ادمینِ Postgres). فایلِ دستخورده را با هش رد میکند.
|
||||||
|
|
||||||
|
**بازیابیِ واقعی** (دیتابیسِ اصلی خراب شد): `sudo systemctl stop zareian-mathapp` ← از دیتابیسِ فعلی یک `pg_dump` دیگر بگیرید ← `dropdb`/`createdb` ← `pg_restore --no-owner -d zareian_mathapp <فایل>` ← `start` ← `/api/health`. سفارشهای بعد از زمانِ بکاپ را با پنلِ زرینپال/بازار تطبیق دهید.
|
||||||
|
|
||||||
|
آزمودهشده: بکاپ، نگهداری، بازیابی و ردشدنِ فایلِ خراب روی Postgresِ لپتاپ. آزمودهنشده: زیر cron روی سرور، دیسکِ نزدیک به پُر، `BACKUP_OFFSITE_CMD` واقعی.
|
||||||
|
|
||||||
|
بکاپِ دستیِ SQLite (توسعهی محلی):
|
||||||
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -212,6 +212,14 @@ def apply_order_payment_result(*, order: Order, gateway: str, gateway_ref_id: st
|
|||||||
event=event, user_id=order.user_id,
|
event=event, user_id=order.user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# یک خطِ لاگِ فنی برای هر نتیجه، تکراریها هم: در دیتابیس فقط نتیجهی پذیرفتهشده ردیف دارد
|
||||||
|
# (Transaction/StatusChangeLog)، پس کالبکِ تکراری یا جعلی بیاین خط هیچ ردی نداشت.
|
||||||
|
logger.log(
|
||||||
|
logging.WARNING if is_duplicate and new_status == 'paid' and not already_paid else logging.INFO,
|
||||||
|
'payment result: order=%s user=%s gateway=%s event=%s status=%s->%s ref=%s ignored=%s',
|
||||||
|
order.id, order.user_id, gateway, event, old_status, new_status,
|
||||||
|
gateway_ref_id or '-', is_duplicate,
|
||||||
|
)
|
||||||
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
|
newly_paid = new_status == 'paid' and not already_paid and not is_duplicate
|
||||||
if newly_paid:
|
if newly_paid:
|
||||||
if order.subscription_id:
|
if order.subscription_id:
|
||||||
|
|||||||
@@ -223,8 +223,11 @@ class ZarinpalCallbackView(APIView):
|
|||||||
# یک NOKِ ناشناس آن را «ناموفق» میکرد و با دو سفارش ۵۰۰ میداد.
|
# یک NOKِ ناشناس آن را «ناموفق» میکرد و با دو سفارش ۵۰۰ میداد.
|
||||||
if not authority:
|
if not authority:
|
||||||
raise ValidationError({'Authority': 'لازم است.'})
|
raise ValidationError({'Authority': 'لازم است.'})
|
||||||
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
|
order = Order.objects.filter(gateway_authority=authority, provider='gateway').first()
|
||||||
|
if order is None:
|
||||||
|
# کالبکِ بیسفارش: یا Authorityِ جعلی یا سفارشی که از دیتابیس رفته؛ در دیتابیس ردی ندارد.
|
||||||
|
logger.warning('zarinpal callback: no order for authority=%s status=%s', authority[:64], status_param[:10])
|
||||||
|
raise Http404
|
||||||
if status_param != 'OK':
|
if status_param != 'OK':
|
||||||
new_status, gateway_ref_id = 'failed', ''
|
new_status, gateway_ref_id = 'failed', ''
|
||||||
else:
|
else:
|
||||||
|
|||||||
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
|
||||||
0
statuslog/management/__init__.py
Normal file
0
statuslog/management/__init__.py
Normal file
0
statuslog/management/commands/__init__.py
Normal file
0
statuslog/management/commands/__init__.py
Normal file
16
statuslog/management/commands/backup_db.py
Normal file
16
statuslog/management/commands/backup_db.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from statuslog.backup import BackupError, run_backup
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'بکاپِ زمانبندیشدهی Postgres (pg_dump)، نگهداری ۷ روزه + ۴ هفتهای، هوکِ کپیِ بیرونی.'
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
try:
|
||||||
|
s = run_backup()
|
||||||
|
except BackupError as exc:
|
||||||
|
raise CommandError(str(exc))
|
||||||
|
self.stdout.write(f"{s['file']} {s['size_bytes'] // 1024} KB offsite={s['offsite']['state']}")
|
||||||
|
if s['offsite']['state'] == 'failed':
|
||||||
|
raise CommandError(f"بکاپِ محلی ساخته شد ولی کپیِ بیرونی شکست خورد: {s['offsite'].get('error')}")
|
||||||
17
statuslog/management/commands/backup_verify.py
Normal file
17
statuslog/management/commands/backup_verify.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
|
||||||
|
from statuslog.backup import BackupError, run_verify
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'آخرین بکاپ را روی دیتابیسِ آزمایشیِ جدا بازیابی و شمارشِ جدولهای پرداخت/ورود را میسنجد.'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--file', default='', help='نامِ یک فایلِ مشخص در پوشهی بکاپ (پیشفرض: آخرین).')
|
||||||
|
|
||||||
|
def handle(self, *args, **opts):
|
||||||
|
try:
|
||||||
|
r = run_verify(opts['file'])
|
||||||
|
except BackupError as exc:
|
||||||
|
raise CommandError(str(exc))
|
||||||
|
self.stdout.write(f"بازیابی از {r['file']} سالم بود: {r['counts']}")
|
||||||
114
statuslog/tests_backup.py
Normal file
114
statuslog/tests_backup.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
بکاپِ زمانبندیشده: نگهداری، هوکِ بیرونی، هشدارِ health، و لاگِ پرداخت.
|
||||||
|
خودِ pg_dump/pg_restore اینجا اجرا نمیشود (تستها روی SQLiteاند)؛ آن را یک اجرای دستی روی Postgresِ
|
||||||
|
لپتاپ سنجید (شرحش در docs/operations.md). آزمودهنشده: زیر cron روی سرور.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test import SimpleTestCase, TestCase, override_settings
|
||||||
|
|
||||||
|
from statuslog import backup
|
||||||
|
|
||||||
|
|
||||||
|
def _touch(d: Path, stamp: str):
|
||||||
|
f = d / f'db-{stamp}.pgdump'
|
||||||
|
f.write_bytes(b'x')
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
class PruneTests(SimpleTestCase):
|
||||||
|
def test_keeps_last_7_days_and_4_weeks_only(self):
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
d = Path(t)
|
||||||
|
start = datetime.datetime(2026, 8, 1, 2, 0, 0)
|
||||||
|
for i in range(60): # شصت روز، هر روز یک بکاپ
|
||||||
|
_touch(d, (start + datetime.timedelta(days=i)).strftime('%Y%m%d-%H%M%S'))
|
||||||
|
removed = backup._prune(d)
|
||||||
|
left = sorted(p.name for p in d.glob('db-*.pgdump'))
|
||||||
|
self.assertEqual(len(left) + len(removed), 60)
|
||||||
|
self.assertIn('db-20260929-020000.pgdump', left) # آخرین
|
||||||
|
self.assertLessEqual(len(left), 7 + 4)
|
||||||
|
self.assertGreaterEqual(len(left), 7)
|
||||||
|
|
||||||
|
def test_two_backups_same_day_keep_latest(self):
|
||||||
|
with tempfile.TemporaryDirectory() as t:
|
||||||
|
d = Path(t)
|
||||||
|
_touch(d, '20261001-020000')
|
||||||
|
_touch(d, '20261001-140000')
|
||||||
|
backup._prune(d)
|
||||||
|
self.assertEqual([p.name for p in d.glob('db-*')], ['db-20261001-140000.pgdump'])
|
||||||
|
|
||||||
|
|
||||||
|
class OffsiteTests(SimpleTestCase):
|
||||||
|
def test_not_configured_is_explicit(self):
|
||||||
|
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': ''}):
|
||||||
|
self.assertEqual(backup.run_offsite(Path('/x')), {'state': 'not_configured'})
|
||||||
|
|
||||||
|
def test_placeholder_required(self):
|
||||||
|
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'rsync a b'}):
|
||||||
|
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed')
|
||||||
|
|
||||||
|
def test_failing_command_is_reported_not_swallowed(self):
|
||||||
|
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'false {file}'}):
|
||||||
|
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'failed')
|
||||||
|
|
||||||
|
def test_ok(self):
|
||||||
|
with mock.patch.dict('os.environ', {'BACKUP_OFFSITE_CMD': 'true {file}'}):
|
||||||
|
self.assertEqual(backup.run_offsite(Path('/x'))['state'], 'ok')
|
||||||
|
|
||||||
|
|
||||||
|
class HealthWarningTests(SimpleTestCase):
|
||||||
|
def _warn(self, status, **settings_kw):
|
||||||
|
from config import health
|
||||||
|
with tempfile.TemporaryDirectory() as t, override_settings(BACKUP_DIR=t, DEBUG=False, **settings_kw):
|
||||||
|
if status is not None:
|
||||||
|
(Path(t) / 'status.json').write_text(json.dumps(status))
|
||||||
|
with mock.patch.object(health.connection, 'vendor', 'postgresql'):
|
||||||
|
return health._backup_warnings()
|
||||||
|
|
||||||
|
def test_no_status_warns(self):
|
||||||
|
self.assertTrue(any('هیچ بکاپ' in w for w in self._warn(None)))
|
||||||
|
|
||||||
|
def test_stale_backup_warns(self):
|
||||||
|
old = (datetime.datetime.now().astimezone() - datetime.timedelta(hours=40)).isoformat()
|
||||||
|
self.assertTrue(any('ساعت پیش' in w for w in self._warn({'last_ok_at': old})))
|
||||||
|
|
||||||
|
def test_fully_healthy_has_no_warning(self):
|
||||||
|
now = datetime.datetime.now().astimezone().isoformat()
|
||||||
|
st = {'last_ok_at': now, 'offsite': {'state': 'ok'}, 'restore_check': {'ok': True, 'at': now}}
|
||||||
|
self.assertEqual(self._warn(st), [])
|
||||||
|
|
||||||
|
def test_offsite_missing_and_restore_untested_warn(self):
|
||||||
|
now = datetime.datetime.now().astimezone().isoformat()
|
||||||
|
w = self._warn({'last_ok_at': now, 'offsite': {'state': 'not_configured'}})
|
||||||
|
self.assertTrue(any('بیرون از این ماشین' in x for x in w))
|
||||||
|
self.assertTrue(any('آزموده نشده' in x for x in w))
|
||||||
|
|
||||||
|
def test_sqlite_dev_is_silent(self):
|
||||||
|
from config import health
|
||||||
|
self.assertEqual(health._backup_warnings(), [])
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentAuditLogTests(TestCase):
|
||||||
|
def test_unknown_authority_callback_is_404_and_logged(self):
|
||||||
|
with self.assertLogs('payment.views', level='WARNING') as cm:
|
||||||
|
r = self.client.get('/api/payment/zarinpal/callback?Authority=A000NOPE&Status=OK')
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
self.assertIn('no order', cm.output[0])
|
||||||
|
|
||||||
|
def test_ignored_duplicate_paid_result_is_logged_as_warning(self):
|
||||||
|
from payment.models import Order
|
||||||
|
from payment.services import apply_order_payment_result
|
||||||
|
o = Order.objects.create(user_id=1, amount=1000, idempotency_key='k1', gateway_authority='A1')
|
||||||
|
with self.assertLogs('payment.services', level='INFO') as cm:
|
||||||
|
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
|
||||||
|
new_status='paid', event='t')
|
||||||
|
self.assertIn('ignored=False', cm.output[0])
|
||||||
|
with self.assertLogs('payment.services', level='INFO') as cm:
|
||||||
|
apply_order_payment_result(order=o, gateway='zarinpal', gateway_ref_id='R1',
|
||||||
|
new_status='paid', event='t')
|
||||||
|
self.assertIn('ignored=True', cm.output[0])
|
||||||
Reference in New Issue
Block a user