Live off-site Postgres copy on 247 replaces the nightly pg_dump; backup retention in deploy.sh
- ops/pgbackup: SSH tunnel + pg_receivewal (slot zareian_247) + daily verified pg_basebackup (keep 7) and a restore check, all run on 78.38.35.247 as systemd units - /api/health reports offsiteBackup (slot active/lag/lost) and warns; OFFSITE_WAL_SLOT setting - removed statuslog backup_db/backup_verify and their health warnings - deploy.sh keeps 3 pre-deploy dumps, 3 local patches, drops manual backups after 30 days - docs: operations backup section, security item 14 closed, CLAUDE.md status Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,9 @@ DB_USER=
|
||||
DB_PASSWORD=
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=5432
|
||||
# اسلاتِ تکثیری که سرورِ بکاپ از آن کپیِ زنده میگیرد (ops/pgbackup/). روی سرور: zareian_247.
|
||||
# خالی روی Postgres = کپیِ بیرونی نداریم و /api/health هشدار میدهد. روی لپتاپ خالی.
|
||||
OFFSITE_WAL_SLOT=
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ۲) دامنهها — دو آدرس، بقیه از آنها ساخته میشوند
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -328,7 +328,7 @@ reliably pull the final answer out of a solution and produced ~40% broken output
|
||||
|
||||
## Known gaps (per README pre-deploy checklist)
|
||||
|
||||
- Postgres is live; no off-machine backup yet (owner waiting for the destination from the senior).
|
||||
- Postgres is live and streamed live to the backup server (see «بکاپ» below); restoring onto 249 itself never rehearsed.
|
||||
- Throttle rates need periodic review against real traffic.
|
||||
- `DEV_MASTER_OTP` (`0000`) must stay gated behind `DEBUG` — never enabled in prod.
|
||||
- No video content exists yet, though `Lesson.kind`/`video_url` can express it.
|
||||
@@ -445,6 +445,29 @@ preferred setup, because there is then no key to leak. **Never let the server ru
|
||||
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
|
||||
anyone who can read this repo can forge a token for any user.**
|
||||
|
||||
## بکاپ: کپیِ زنده روی سرورِ ۲۴۷ — از ۲۰۲۶-۱۰-۱۰
|
||||
|
||||
**هر تغییرِ دیتابیس لحظهای به سرورِ دیگری میرود؛ بکاپِ شبانهی خودمان (`backup_db`/`backup_verify`/cron) حذف شد.**
|
||||
شرحِ کامل و رویهی بازیابی: `docs/operations.md` «بکاپ و بازیابی». فایلها: `ops/pgbackup/`.
|
||||
|
||||
- **سرورِ بکاپ:** `administrator@78.38.35.247`، همان رمزِ ۲۴۹ (همان فایلِ رمز). شبکهی داخلی: ۲۴۹ = `192.168.200.164`،
|
||||
۲۴۷ = `192.168.200.167`. **۲۴۷ هم ماشینِ مشترک است** (artina، judge0، metaverse، docker، و `postgresql@14-main`ِ
|
||||
دیگران روی ۵۴۳۲): همان قواعدِ «The server» اینجا هم هست. مالِ ما فقط `~/zareian-pgbackup/` و یونیتهای
|
||||
`zareian-pg-*` است. Postgresِ آنجا را به کار نمیگیریم؛ فقط ابزارهای Postgres 14 را اجرا میکنیم.
|
||||
- **چطور:** روی ۲۴۷ `zareian-pg-tunnel` (SSH با کلیدِ `~/.ssh/zareian_pgbackup` که در `authorized_keys`ِ ۲۴۹ به
|
||||
`permitopen="127.0.0.1:5440"` محدود است، شِل نمیدهد) → `zareian-pg-wal` (`pg_receivewal`، اسلاتِ `zareian_247`،
|
||||
نقشِ `zareian_replicator`، رمزش فقط در `~/.pgpass`ِ ۲۴۷) → `wal/`؛ و `zareian-pg-basebackup.timer` هر روز ۰۴:۳۰ →
|
||||
`base/` (۷ تا، با `pg_verifybackup`؛ WALِ قدیمیتر از قدیمیترین پاک میشود).
|
||||
- **۲۴۹ را نمیخواباند:** تکثیر ناهمزمان است (`sync_state: async`)؛ `max_slot_wal_keep_size=1GB` (با `ALTER SYSTEM`،
|
||||
نه `/etc`) سقفِ چیزی است که در قطعیِ ۲۴۷ روی دیسکِ ۲۴۹ جمع میشود. قطعیِ طولانیتر اسلات را `lost` میکند (رویه در docs).
|
||||
- **پایش:** `OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹؛ `/api/health` (با کلید) `offsiteBackup` و هشدار میدهد
|
||||
(`config/tests_offsite_backup.py`). شکستِ نسخهی روزانه فقط در `journalctl -u zareian-pg-basebackup`ِ ۲۴۷ دیده میشود.
|
||||
- **سنجیده (۲۰۲۶-۱۰-۱۰):** کلید شِل نمیگیرد و تونل به پورتِ دیگر رد میشود؛ `restore-check.sh` روی ۲۴۷ شمارش و آخرین
|
||||
زمانِ کاربر/ورود/سفارش/تراکنش/تغییرِ وضعیت را برابرِ ۲۴۹ داد؛ یک ردیفِ آزمایشی که بعد از نسخهی کامل روی ۲۴۹ نوشته شد
|
||||
۲ ثانیه بعد در بازیابیِ ۲۴۷ بود (جدولش پاک شد). **نسنجیده:** ریبوتِ ۲۴۷، قطعیِ طولانی تا `lost`، اجرای خودکارِ timer
|
||||
(اولین بار ۲۰۲۶-۱۰-۱۱ ۰۴:۳۰)، و برگرداندنِ بازیابی روی خودِ ۲۴۹.
|
||||
- ایمیجِ روزانهی کلِ ماشین کارِ ادمینِ سرور است؛ محتوایش را ما نسنجیدهایم. کپیِ ما به آن تکیه نمیکند.
|
||||
|
||||
## اشتراک و دیوارِ پرداخت — روی سرور از ۲۰۲۶-۱۰-۱۰ ۰۱:۱۹ (بکاند `64c4c09`، فرانت `a8a5351`)
|
||||
|
||||
- **فقط فصلِ اولِ هر پایه رایگان است** (`content.views.unit_is_free`). بیرون از آن، بیاشتراکِ فعال، سوالهای درس،
|
||||
@@ -903,9 +926,9 @@ migrationها اعمال شدند، سهیار و `nextLessonId` از بیر
|
||||
(`templates/tadrisino/`) تا متنِ فارسی راستبهچپ رندر شود. قالبِ drf-spectacular
|
||||
دستنخورده است و با `{{ block.super }}` گسترش داده شده.
|
||||
|
||||
**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ میگیرد (`db-before-deploy-*`، ۲ تای آخر،
|
||||
بیبکاپ migrate نمیشود)؛ روی سرور هم اجرا شد (۲۰۲۶-۱۰-۰۷، `integrity_check: ok`). بیرون از ماشین بکاپی
|
||||
نیست.
|
||||
**بکاپ:** `deploy.sh` پیش از `migrate` خودش بکاپ میگیرد (`db-before-deploy-*`، ۳ تای آخر، بیبکاپ
|
||||
migrate نمیشود) و مرحلهی «نگهداریِ بکاپها» بقیهی `.deploy-backups` را مرتب میکند (بکاپِ دستیِ پیش از
|
||||
تغییر بعد از ۳۰ روز پاک میشود). کپیِ واقعیِ داده بیرون از ماشین است؛ بخشِ پایین.
|
||||
|
||||
**⚠️ Gitea از روی سرور:** از ۲۰۲۶-۱۰-۰۷ `https://gitea.tadrisino2.ir` از خودِ سرور (حلشده به IPهای
|
||||
ArvanCloud) ۵۰۴ میدهد و از لپتاپ ۲۰۰؛ علتش پیدا نشد (مسیرِ آروان↔سرور). `deploy.sh` در `git fetch` میایستد.
|
||||
|
||||
@@ -134,11 +134,6 @@ curl https://mathapi.tadrisino2.ir/api/health
|
||||
|
||||
اینها عمداً نوشته شدهاند تا کسی دوباره کشفشان نکند:
|
||||
|
||||
- **دیتابیس هنوز SQLite است**، نه Postgres. برای حجمِ فعلی کافیست، ولی
|
||||
تکنویسنده بودنش اولین گلوگاهِ مقیاس است.
|
||||
- **بکاپ فقط روی همان ماشین است.** `deploy.sh` پیش از `migrate` از `db.sqlite3` بکاپ میگیرد
|
||||
(۲ تای آخر)، ولی نسخهای بیرون از سرور نیست. جزئیات در
|
||||
[`docs/operations.md`](docs/operations.md).
|
||||
- **`CACHE_URL` روی سرور خالی است، عمداً**، پس محدودیتِ نرخ بینِ workerها مشترک نیست. Redisِ روی آن ماشین
|
||||
مالِ تیمهای دیگر است و به آن وصل نمیشویم؛ Redisِ اختصاصیِ خودمان یک تصمیمِ باز پیش از لانچ است.
|
||||
`/api/health` این را هشدار میدهد.
|
||||
|
||||
@@ -144,41 +144,62 @@ def _ai_provider_state() -> dict:
|
||||
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
|
||||
def _offsite_backup() -> dict:
|
||||
"""
|
||||
کپیِ زندهی دیتابیس روی سرورِ دیگر (`ops/pgbackup/`، شرح در docs/operations.md «بکاپ»).
|
||||
|
||||
from statuslog.backup import read_status
|
||||
آن سرور با `pg_receivewal` از اسلاتِ تکثیرِ `OFFSITE_WAL_SLOT` هر تغییر را لحظهای میگیرد؛ اینجا از
|
||||
سمتِ همین دیتابیس دیده میشود که وصل است و چقدر عقب است. ستنبودنِ اسلات روی Postgres یعنی کپیِ
|
||||
بیرونی نداریم و هشدار میشود. base backupِ روزانهی آن سرور از اینجا دیده نمیشود (journalِ همانجا).
|
||||
"""
|
||||
if connection.vendor != 'postgresql':
|
||||
return {'state': 'not_postgres'}
|
||||
slot = settings.OFFSITE_WAL_SLOT
|
||||
if not slot:
|
||||
return {'state': 'not_configured'}
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'SELECT active, wal_status, pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) '
|
||||
'FROM pg_replication_slots WHERE slot_name = %s',
|
||||
[slot],
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {'state': 'error', 'error': type(exc).__name__}
|
||||
return _slot_state(row)
|
||||
|
||||
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
|
||||
def _slot_state(row) -> dict:
|
||||
if row is None:
|
||||
return {'state': 'slot_missing'}
|
||||
active, wal_status, lag = row
|
||||
lag = int(lag or 0)
|
||||
if wal_status == 'lost':
|
||||
# سقفِ max_slot_wal_keep_size خورده: دیسکِ این سرور امن ماند ولی زنجیرهی WAL آنجا بریده است.
|
||||
state = 'lost'
|
||||
elif not active:
|
||||
state = 'disconnected'
|
||||
elif lag > settings.OFFSITE_WAL_MAX_LAG_BYTES:
|
||||
state = 'lagging'
|
||||
else:
|
||||
state = 'ok'
|
||||
return {'state': state, 'lagBytes': lag}
|
||||
|
||||
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
|
||||
|
||||
_OFFSITE_MESSAGES = {
|
||||
'not_configured': 'کپیِ زندهی دیتابیس بیرون از این سرور تنظیم نشده (OFFSITE_WAL_SLOT).',
|
||||
'slot_missing': 'اسلاتِ تکثیرِ OFFSITE_WAL_SLOT در Postgres نیست؛ سرورِ بکاپ هیچوقت وصل نشده یا اسلات حذف شده.',
|
||||
'disconnected': 'سرورِ بکاپ الان وصل نیست؛ تغییرات روی همین سرور جمع میشوند تا سقفِ max_slot_wal_keep_size.',
|
||||
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
|
||||
'lost': 'سرورِ بکاپ آنقدر قطع بود که زنجیرهی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
|
||||
'error': 'وضعیتِ کپیِ زندهی دیتابیس خوانده نشد.',
|
||||
}
|
||||
|
||||
|
||||
def _offsite_warnings(offsite: dict) -> list[str]:
|
||||
msg = _OFFSITE_MESSAGES.get(offsite['state'])
|
||||
return [msg] if msg else []
|
||||
|
||||
|
||||
def _warnings() -> list[str]:
|
||||
@@ -219,7 +240,6 @@ def _warnings() -> list[str]:
|
||||
'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همهی کاربرها '
|
||||
'«در دسترس نیست» میدهد تا حساب شارژ شود.'
|
||||
)
|
||||
out.extend(_backup_warnings())
|
||||
backend = settings.CACHES['default']['BACKEND']
|
||||
if not settings.DEBUG and 'LocMem' in backend:
|
||||
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||||
@@ -261,7 +281,8 @@ class HealthView(APIView):
|
||||
if not HasInternalServiceKey().has_permission(request, self):
|
||||
return Response({'status': 'ok' if critical_ok else 'error'}, status=status)
|
||||
|
||||
warnings = _warnings()
|
||||
offsite = _offsite_backup()
|
||||
warnings = _warnings() + _offsite_warnings(offsite)
|
||||
payload = {
|
||||
'status': 'ok' if critical_ok else 'error',
|
||||
'debug': bool(settings.DEBUG),
|
||||
@@ -269,6 +290,7 @@ class HealthView(APIView):
|
||||
'checks': checks,
|
||||
'integrations': _check_integrations(),
|
||||
'traffic': _traffic(),
|
||||
'offsiteBackup': offsite,
|
||||
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
||||
# IPای که throttle برای همین درخواست میبیند. باید IPِ عمومیِ خودِ
|
||||
# صدازننده باشد؛ اگر IPِ ArvanCloud یا nginx بود، NUM_PROXIES غلط
|
||||
|
||||
@@ -970,11 +970,11 @@ LOGGING = {
|
||||
# Promtail بتونه tail کنه. بدونش (اجرای عادی خارج از Docker)، فقط stdout.
|
||||
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
|
||||
# کپیِ زندهی دیتابیس روی سرورِ دیگر (ops/pgbackup/): نامِ اسلاتِ تکثیری که آن سرور از آن میخواند.
|
||||
# خالی روی Postgres یعنی کپیِ بیرونی نداریم و /api/health هشدار میدهد.
|
||||
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
|
||||
# بیش از این عقبماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقبماندگی نزدیکِ صفر است.
|
||||
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
|
||||
|
||||
if LOG_DIR:
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
40
config/tests_offsite_backup.py
Normal file
40
config/tests_offsite_backup.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
وضعیتِ کپیِ زندهی دیتابیس در /api/health (config/health.py: _slot_state، _offsite_warnings).
|
||||
پرسشِ واقعی از pg_replication_slots اینجا اجرا نمیشود (تستها روی SQLiteاند)؛ روی سرور با اسلاتِ
|
||||
واقعی دیده شد (docs/operations.md «بکاپ»).
|
||||
"""
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
from config import health
|
||||
|
||||
|
||||
class SlotStateTests(SimpleTestCase):
|
||||
def test_missing_slot(self):
|
||||
self.assertEqual(health._slot_state(None)['state'], 'slot_missing')
|
||||
|
||||
def test_streaming_and_caught_up_is_ok(self):
|
||||
self.assertEqual(health._slot_state((True, 'reserved', 0)), {'state': 'ok', 'lagBytes': 0})
|
||||
|
||||
def test_inactive_slot_is_disconnected(self):
|
||||
self.assertEqual(health._slot_state((False, 'reserved', 0))['state'], 'disconnected')
|
||||
|
||||
def test_lost_slot_wins_over_everything(self):
|
||||
self.assertEqual(health._slot_state((False, 'lost', None))['state'], 'lost')
|
||||
|
||||
@override_settings(OFFSITE_WAL_MAX_LAG_BYTES=100)
|
||||
def test_lag_above_limit(self):
|
||||
self.assertEqual(health._slot_state((True, 'reserved', 101))['state'], 'lagging')
|
||||
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
|
||||
|
||||
|
||||
class OffsiteWarningTests(SimpleTestCase):
|
||||
def test_ok_and_dev_are_silent(self):
|
||||
self.assertEqual(health._offsite_warnings({'state': 'ok', 'lagBytes': 0}), [])
|
||||
self.assertEqual(health._offsite_warnings({'state': 'not_postgres'}), [])
|
||||
|
||||
def test_every_bad_state_warns(self):
|
||||
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error'):
|
||||
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
|
||||
|
||||
def test_sqlite_reports_not_postgres(self):
|
||||
self.assertEqual(health._offsite_backup(), {'state': 'not_postgres'})
|
||||
28
deploy.sh
28
deploy.sh
@@ -222,9 +222,10 @@ printf ' دیتابیس و بکاپها فقط برای %s خواندنی
|
||||
#
|
||||
# با sqlite3.Connection.backup() نه cp: کپیِ ساده وسطِ یک نوشتن فایلِ نیمهکاره میدهد (sqlite3
|
||||
# بهعنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمیشود: دیپلوی بیبکاپ را
|
||||
# عمداً نمیپذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبلتر) نگه داشته میشود تا دیسکِ
|
||||
# عمداً نمیپذیریم. فقط ۳ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و دو تای قبل) نگه داشته میشود تا دیسکِ
|
||||
# مشترک پر نشود؛ بکاپهای دستی با نامِ دیگر دست نمیخورند.
|
||||
# Postgres با pg_dump (آزمودهنشده روی سرور تا انتقال)؛ بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چکلیستِ لانچ).
|
||||
# Postgres با pg_dump. این بکاپ فقط برای برگشتن از migrationِ خراب است؛ کپیِ زندهی بیرونِ ماشین جداست
|
||||
# (ops/pgbackup/، docs/operations.md «بکاپ و بازیابی»).
|
||||
step "بکاپِ دیتابیس"
|
||||
python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد."
|
||||
import datetime
|
||||
@@ -252,7 +253,7 @@ if db['ENGINE'] == 'django.db.backends.postgresql':
|
||||
# فقط خوانا بودنِ فایل را میسنجد؛ بازیابیِ واقعی روی دیتابیسِ دیگر آزموده نشده.
|
||||
subprocess.run(['pg_restore', '--list', dst], check=True, stdout=subprocess.DEVNULL)
|
||||
print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، pg_restore --list: ok)')
|
||||
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-2]
|
||||
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-3]
|
||||
for path in old:
|
||||
os.remove(path)
|
||||
raise SystemExit(0)
|
||||
@@ -285,13 +286,30 @@ if result != 'ok':
|
||||
raise SystemExit(f' بکاپ ساخته شد ولی integrity_check «{result}» داد: {dst}')
|
||||
print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، integrity_check: ok)')
|
||||
|
||||
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-2]
|
||||
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-3]
|
||||
for path in old:
|
||||
os.remove(path)
|
||||
if old:
|
||||
print(f' {len(old)} بکاپِ خودکارِ قدیمیتر پاک شد (۲ تای آخر میماند).')
|
||||
print(f' {len(old)} بکاپِ خودکارِ قدیمیتر پاک شد (۳ تای آخر میماند).')
|
||||
PYBACKUP
|
||||
|
||||
# ─── ۳.۸ نگهداریِ بکاپها ─────────────────────────────────────────────────
|
||||
# .deploy-backups بیسقف بزرگ میشد و دیسکِ این ماشینِ مشترک ~۸۸٪ پُر است. قاعدهی هر انتشار:
|
||||
# db-before-deploy-* ۳ تای آخر (مرحلهی قبل)
|
||||
# local-changes/commits-* ۳ تای آخر از هر کدام
|
||||
# هر چیزِ دیگر بکاپهای دستیِ پیش از تغییر (env-before-*، db-before-*، unit-*…): پس از ۳۰ روز
|
||||
# پاک. کپیِ .env رمز دارد و یک ماه بعد کسی به آن برنمیگردد.
|
||||
# کپیِ واقعیِ داده اینجا نیست: کپیِ زنده روی سرورِ دیگر است (ops/pgbackup/).
|
||||
step "نگهداریِ بکاپها"
|
||||
if [ -d .deploy-backups ]; then
|
||||
for pattern in 'local-changes-*.patch' 'local-commits-*.patch'; do
|
||||
ls -1t .deploy-backups/$pattern 2>/dev/null | tail -n +4 | xargs -r rm -f --
|
||||
done
|
||||
find .deploy-backups -maxdepth 1 -type f ! -name 'db-before-deploy-*' ! -name 'local-changes-*' \
|
||||
! -name 'local-commits-*' -mtime +30 -print -delete | sed 's/^/ پاک شد: /'
|
||||
printf ' .deploy-backups: %s\n' "$(du -sh .deploy-backups | cut -f1)"
|
||||
fi
|
||||
|
||||
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
|
||||
step "اعمالِ مهاجرتهای دیتابیس"
|
||||
python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ریاستارت نشد."
|
||||
|
||||
@@ -72,25 +72,73 @@ curl -i https://mathapi.tadrisino2.ir/api/health
|
||||
|
||||
## بکاپ و بازیابی
|
||||
|
||||
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`). سه لایه:
|
||||
دیتابیسِ سرور Postgres است (cluster جدای `zareian`، پورت ۵۴۴۰، فقط localhost؛ راهنمای کامل: `گزارش-۲۹/راهنمای-Postgres-و-Redis.html`).
|
||||
|
||||
| لایه | چه زمانی | نگهداری | کجا |
|
||||
| لایه | چه چیزی | از دست رفتنِ داده در بدترین حالت | کجا |
|
||||
|---|---|---|---|
|
||||
| پیش از 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` هشدار میدهد |
|
||||
| کپیِ زنده (اصلی) | هر تغییرِ دیتابیس لحظهای به سرورِ دیگر میرود + یک نسخهی کامل در روز؛ بازیابی تا هر لحظه از ۷ روزِ اخیر | چند ثانیهی آخر | `administrator@78.38.35.247:~/zareian-pgbackup/` |
|
||||
| پیش از migrate | `./deploy.sh` (`pg_dump -Fc`؛ اگر نشد migrate اجرا نمیشود)؛ برای برگشتن از migrationِ خراب | — | `~/mathapp-v2/.deploy-backups/db-before-deploy-*.pgdump` (۲ تای آخر) |
|
||||
| ایمیجِ کلِ ماشین | کارِ ادمینِ سرور، روزانه؛ ما محتوایش را نسنجیدهایم | — | دستِ ادمین |
|
||||
|
||||
نصبِ بکاپِ شبانه (یک بار، با `crontab -e` حسابِ `administrator`؛ بکاپِ crontab را پیش از ویرایش بگیرید):
|
||||
بکاپِ شبانهی جداگانه (`backup_db` با cron) حذف شد: کپیِ زنده همان کار را بهتر میکند و ایمیجِ روزانه را ادمین دارد.
|
||||
|
||||
### کپیِ زنده چطور کار میکند
|
||||
|
||||
Postgres هر تغییر را اول در یک دفترِ پیوسته (WAL) مینویسد. سرورِ ۲۴۷ این دفتر را با ابزارِ خودِ Postgres
|
||||
(`pg_receivewal`) لحظهای میگیرد، نه کپیِ کلِ دیتابیس: فقط تغییرات میروند. روزی یک بار هم یک نسخهی کامل
|
||||
(`pg_basebackup`) گرفته میشود؛ ۷ نسخهی آخر میمانند و WALِ قدیمیتر از قدیمیترینشان پاک میشود، پس حجم
|
||||
محدود است. بازیابی = آخرین نسخهی کاملِ پیش از لحظهی دلخواه + بازپخشِ WAL تا آن لحظه.
|
||||
|
||||
```
|
||||
15 2 * * * cd /home/administrator/mathapp-v2 && venv/bin/python manage.py backup_db >> .deploy-backups/scheduled/cron.log 2>&1
|
||||
۲۴۹ (اپ) ۲۴۷ (بکاپ)
|
||||
Postgres 5440 (localhost) ◄── تونلِ SSH ─── zareian-pg-tunnel (127.0.0.1:15440)
|
||||
اسلاتِ zareian_247 zareian-pg-wal → wal/ (لحظهای)
|
||||
نقشِ zareian_replicator zareian-pg-basebackup.timer → base/ (هر روز ۰۴:۳۰، ۷ تا)
|
||||
```
|
||||
|
||||
**آزمونِ بازیابی (ماهی یک بار، و پس از هر تغییرِ بکاپ):** `venv/bin/python manage.py backup_verify` آخرین بکاپ را روی دیتابیسِ آزمایشیِ `<DB_NAME>_restorecheck` برمیگرداند، شمارشِ کاربر/ورود/سفارش/تراکنش/لاگِ وضعیت را با لحظهی بکاپ میسنجد و آن دیتابیس را میاندازد؛ به دیتابیسِ اصلی دست نمیزند. نقشِ برنامه باید `CREATEDB` داشته باشد (`ALTER ROLE zareian_app CREATEDB;` با ادمینِ Postgres). فایلِ دستخورده را با هش رد میکند.
|
||||
- **۲۴۹ را نمیخواباند:** اپ منتظرِ ۲۴۷ نمیماند (تکثیرِ ناهمزمان). اگر ۲۴۷ قطع شود، اسلات تغییرات را نگه میدارد تا
|
||||
وصل شود، ولی حداکثر ۱ گیگ (`max_slot_wal_keep_size`)؛ بیش از آن اسلات «lost» میشود و دیسکِ ۲۴۹ امن میماند.
|
||||
نسخهی کامل با `--checkpoint=spread --max-rate=20M` گرفته میشود.
|
||||
- **امنیت:** کلیدِ SSHِ ۲۴۷ در `authorized_keys`ِ ۲۴۹ با `restrict,port-forwarding,permitopen="127.0.0.1:5440"`
|
||||
محدود است (نه شِل، نه پورتِ دیگر). رمزِ نقشِ `zareian_replicator` فقط در `~/.pgpass`ِ ۲۴۷ است. این نقش
|
||||
همهی داده را میخواند (طبیعتِ بکاپ)، پس `~/zareian-pgbackup` با مجوز ۷۰۰ است. ترافیک در شبکهی داخلی است
|
||||
(۲۴۹ = `192.168.200.164`، ۲۴۷ = `192.168.200.167`).
|
||||
- **Postgresِ ۲۴۷ را به کار نمیگیریم:** سرویسِ `postgresql@14-main` آنجا مالِ دیگران است. ما فقط ابزارهای
|
||||
Postgres 14 را اجرا میکنیم؛ آزمونِ بازیابی یک Postgresِ موقت روی سوکتِ داخلِ پوشهی خودمان بالا میآورد.
|
||||
- **پایش:** `/api/health` (با `X-Internal-Key`) زیرِ `offsiteBackup` وضعیت و عقبماندگی را میدهد و اگر ۲۴۷
|
||||
وصل نباشد، عقب باشد یا اسلات lost شده باشد هشدار میدهد (`OFFSITE_WAL_SLOT=zareian_247` در `.env`ِ ۲۴۹).
|
||||
شکستِ نسخهی کاملِ روزانه از ۲۴۹ دیده نمیشود: روی ۲۴۷ `systemctl status zareian-pg-basebackup` و
|
||||
`journalctl -u zareian-pg-basebackup`. شکستش داده را از دست نمیدهد (WAL از آخرین نسخهی سالم نگه داشته
|
||||
میشود)، فقط بازیابی را کندتر میکند.
|
||||
|
||||
**بازیابیِ واقعی** (دیتابیسِ اصلی خراب شد): `sudo systemctl stop zareian-mathapp` ← از دیتابیسِ فعلی یک `pg_dump` دیگر بگیرید ← `dropdb`/`createdb` ← `pg_restore --no-owner -d zareian_mathapp <فایل>` ← `start` ← `/api/health`. سفارشهای بعد از زمانِ بکاپ را با پنلِ زرینپال/بازار تطبیق دهید.
|
||||
فایلها در `ops/pgbackup/` ریپو. روی ۲۴۷: یونیتها در `/etc/systemd/system/zareian-pg-*`، اسکریپتها در
|
||||
`~/zareian-pgbackup/bin/`. هر دو با دست کپی میشوند (`deploy.sh` فقط ۲۴۹ را میشناسد)؛ بعد از تغییرِ
|
||||
یکی از آنها: کپی، `sudo systemctl daemon-reload`، و `restart` همان یونیت.
|
||||
|
||||
آزمودهشده: بکاپ، نگهداری، بازیابی و ردشدنِ فایلِ خراب روی Postgresِ لپتاپ. آزمودهنشده: زیر cron روی سرور، دیسکِ نزدیک به پُر، `BACKUP_OFFSITE_CMD` واقعی.
|
||||
### آزمونِ بازیابی (ماهی یک بار)
|
||||
|
||||
روی ۲۴۷: `~/zareian-pgbackup/bin/restore-check.sh` (یا با یک لحظه: `… '2026-10-10 16:30:00+03:30'`).
|
||||
آخرین نسخهی کامل + همهی WAL را در پوشهی موقت بالا میآورد، شمارش و آخرین زمانِ کاربر/ورود/سفارش/تراکنش/
|
||||
تغییرِ وضعیت را چاپ میکند و پاک میکند. همان پرسش را روی ۲۴۹ بزنید؛ باید یکی باشند (جز تغییرِ همان چند ثانیه).
|
||||
|
||||
### اگر ۲۴۷ مدتی قطع بود
|
||||
|
||||
`/api/health` میگوید `disconnected`. با برگشتنِ ۲۴۷ سرویسها خودشان وصل میشوند و عقبماندگی را میگیرند. اگر
|
||||
`lost` شد (بیش از ۱ گیگ تغییر در قطعی): روی ۲۴۷
|
||||
`systemctl stop zareian-pg-wal`، روی ۲۴۹ اسلات را بیندازید
|
||||
(`SELECT pg_drop_replication_slot('zareian_247')`)، روی ۲۴۷ بسازید
|
||||
(`pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator --create-slot --slot=zareian_247`)،
|
||||
`systemctl start zareian-pg-wal` و `systemctl start zareian-pg-basebackup` (نسخهی کاملِ تازه؛ WALِ پیش از آن
|
||||
بریده است).
|
||||
|
||||
### بازیابیِ واقعی
|
||||
|
||||
- **دیتابیسِ ۲۴۹ خراب شد یا دادهای اشتباهی پاک شد:** لحظهی درست را پیدا کنید، روی ۲۴۷ با `restore-check.sh`
|
||||
تا آن لحظه آزمایش کنید؛ وقتی درست بود، با `KEEP=1` اجرا کنید تا Postgresِ موقت بماند، از آن با
|
||||
`pg_dump -Fc -h <پوشه> -U postgres zareian_mathapp` خروجی بگیرید و روی ۲۴۹ با `pg_restore --clean --no-owner -d zareian_mathapp` برگردد (اپ را پیش از آن stop کنید و از
|
||||
وضعیتِ فعلی هم یک `pg_dump` بگیرید). این مسیر روی سرور آزموده نشده؛ با کمک بروید.
|
||||
- **کلِ ۲۴۹ رفت:** همان بازیابی تا انتهای WAL روی ماشینِ تازه، و سفارشهای چند ثانیهی آخر را با پنلِ زرینپال/بازار
|
||||
تطبیق دهید.
|
||||
|
||||
بکاپِ دستیِ SQLite (توسعهی محلی):
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
| ۹ | کشِ مشترک بینِ workerها نیست (Redisِ اختصاصی)؛ سقفهای مبتنی بر کش ≈ ۳ برابرند | متوسط | تصمیم + ساختِ Redis |
|
||||
| ۱۱ | کدِ OTP بیهش در دیتابیس و در فهرستِ پنل | کم | کد |
|
||||
| ۱۲ | هزینهی دستیارِ AI با ساختِ شمارهی تازه (هر شماره ۲ سوالِ رایگان) قابلِ بالا بردن است | کم (مالی) | کد |
|
||||
| ۱۴ | SQLite روی دیسکِ مشترکِ پُر؛ بکاپِ پیش از migrate خودکار است ولی بیرون از ماشین نیست | کم (داده) | زیرساخت |
|
||||
| ۱۵ | دسترسیِ سرور: حسابِ مشترک، رمز، sudoِ کامل | بالا | ادمینِ سرور |
|
||||
| ۱۶ | فرانت توکن را در `localStorage` نگه میدارد | کم | فرانت |
|
||||
| ۲۲ | سایتِ فرانت هیچ هدرِ امنیتی ندارد (CSP، frame-ancestors، HSTS) | کم | nginx/فرانت |
|
||||
@@ -116,19 +115,6 @@
|
||||
با سقفِ بسیار کمتر برای مهمان؛ هشدار وقتی `aiProvider.state` در `/api/health` برابرِ
|
||||
`quota_exhausted` شد.
|
||||
|
||||
## ۱۴. دیتابیس و بکاپ
|
||||
|
||||
- **چیست:** SQLite در `~/mathapp-v2/db.sqlite3`، روی دیسکی که بینِ چند سرویس مشترک است و
|
||||
~۸۳٪ پر است. `transaction_mode: IMMEDIATE` همزمانی را درست ولی سریالی میکند. `deploy.sh`
|
||||
پیش از `migrate` بکاپ میگیرد (`.deploy-backups/db-before-deploy-*`، ۲ تای آخر؛ اگر بکاپ
|
||||
نشد، migrate اجرا نمیشود)، ولی همه در همان پوشه و روی همان دیسکاند. روی لپتاپ و در دیپلوی ۲۰۲۶-۱۰-۰۷ روی سرور آزموده شد
|
||||
(ساخت و `integrity_check`؛ حذفِ قدیمیها فقط روی لپتاپ). دیتابیسِ لپتاپ ۱۲۶ مگابایت
|
||||
است و دو بکاپ ~۲۵۰ مگابایت میگیرند؛ پیش از اولین دیپلو `df -h /` را ببینید.
|
||||
- **خطر:** از دست رفتنِ کلِ داده با یک خطای دیسک یا یک دستورِ اشتباه؛ پُر شدنِ دیسک
|
||||
سرویسهای دیگر (Gitea، ایمیل) را هم میخواباند.
|
||||
- **رفع:** مهاجرت به Postgres (روی همان ماشین نصب است؛ بعدش بکاپِ `deploy.sh` باید `pg_dump` شود،
|
||||
الان فقط هشدار میدهد) و نگهداریِ یک نسخه بیرون از ماشین.
|
||||
|
||||
## ۱۵. دسترسیِ سرور
|
||||
|
||||
- **چیست:** ورود با رمز به حسابِ مشترکِ `administrator` با `sudo (ALL : ALL) ALL` و عضوِ
|
||||
@@ -235,6 +221,7 @@
|
||||
| قلبِ صفر جوابِ سوال را میبندد؛ چکش داخلِ تراکنش است (بیجوابماندن و جزیرهی قفل جداگانه در ردیفِ بعدی بسته شدهاند) | `content/tests_hearts_gate.py` |
|
||||
| `select_for_update` مؤثر با `transaction_mode: IMMEDIATE`؛ مقایسهی زمانثابتِ `X-Internal-Key` | `payment/tests_audit_fixes.py` |
|
||||
| `deploy.sh` پیش از `migrate` بکاپِ `db-before-deploy-*` میگیرد، `integrity_check` میکند و اگر نشد migrate را اجرا نمیکند | مرحلهی «بکاپِ دیتابیس»: ۷ بار روی لپتاپ و یک بار روی سرور (۶ مگابایت، `integrity_check: ok`) |
|
||||
| دیتابیس Postgresِ اختصاصی است و هر تغییرش لحظهای روی سرورِ دیگری (۲۴۷) کپی میشود، با نسخهی کاملِ روزانه و بازیابی تا هر لحظه از ۷ روزِ اخیر؛ قطعیِ ۲۴۷ اپ را نمیخواباند و بیش از ۱ گیگ روی دیسکِ ۲۴۹ جمع نمیشود (`docs/operations.md` «بکاپ و بازیابی») | `config/tests_offsite_backup.py` برای هشدارِ `/api/health`؛ روی سرور: بازیابی روی ۲۴۷ با شمارشهای برابرِ ۲۴۹ و یک ردیفِ آزمایشی که ۲ ثانیه بعد آنجا بود |
|
||||
| دیتابیس و بکاپها فقط برای صاحبِ سرویس خواندنیاند (مرحلهی «مجوزِ فایلهای داده» در `deploy.sh`) | با `sudo -u nobody head -c 15 ~/mathapp-v2/db.sqlite3` روی سرور باید خطای دسترسی بدهد |
|
||||
| پانزده مسیرِ خواندنی و نوشتنیِ کمخطر سقفِ نرخ دارند (بانکِ سوال و intro بیورود، `progress/nodes`، `user/hearts`، اعلان، تیکت، پلنها، وضعیتِ اشتراک، سفارش، پیشنهادها، affiliate، تاریخچه و فیدبکِ دستیار، خروج). با نرخِ ۳ در دقیقه درخواستِ چهارم ۴۲۹ میگیرد | `content/tests_read_throttles.py` |
|
||||
| ساختنِ دستیِ `OTPCode` و `Session` در پنل فقط برای ابرکاربر (کارمندِ عادی ۴۰۳) | `accounts/tests_security_review.py` (`StaffCannotMintLoginsTests`) |
|
||||
|
||||
28
ops/pgbackup/basebackup.sh
Executable file
28
ops/pgbackup/basebackup.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# نسخهی کاملِ روزانه از Postgresِ ۲۴۹، روی ۲۴۷. هفت نسخهی آخر میماند و WALِ قدیمیتر از قدیمیترینِ
|
||||
# آنها پاک میشود؛ پس بازیابی تا هر لحظه از هفت روزِ اخیر ممکن است و حجم محدود میماند.
|
||||
# --checkpoint=spread و --max-rate بار روی ۲۴۹ را کم نگه میدارند (آنجا سرورِ اصلی است).
|
||||
# نسخه تا pg_verifybackup قبولش نکند جای قبلیها را نمیگیرد و چیزی پاک نمیشود.
|
||||
set -euo pipefail
|
||||
ROOT=/home/administrator/zareian-pgbackup
|
||||
BIN=/usr/lib/postgresql/14/bin
|
||||
KEEP=7
|
||||
|
||||
cd "$ROOT"
|
||||
umask 077
|
||||
ts=$(date +%Y%m%d-%H%M%S)
|
||||
rm -rf base/.inprogress-*
|
||||
tmp="base/.inprogress-$ts"
|
||||
|
||||
"$BIN/pg_basebackup" -h 127.0.0.1 -p 15440 -U zareian_replicator --no-password \
|
||||
-D "$tmp" -Fp -X stream --checkpoint=spread --max-rate=20M
|
||||
"$BIN/pg_verifybackup" "$tmp"
|
||||
mv "$tmp" "base/$ts"
|
||||
|
||||
ls -1d base/2* | head -n -"$KEEP" | xargs -r rm -rf
|
||||
oldest=$(ls -1d base/2* | head -1)
|
||||
start_wal=$(sed -n 's/^START WAL LOCATION: .* (file \([0-9A-F]*\))$/\1/p' "$oldest/backup_label")
|
||||
[ -n "$start_wal" ] || { echo "START WAL در $oldest/backup_label پیدا نشد" >&2; exit 1; }
|
||||
"$BIN/pg_archivecleanup" wal "$start_wal"
|
||||
|
||||
echo "ok base/$ts ($(du -sh "base/$ts" | cut -f1)), kept $(ls -1d base/2* | wc -l), wal since $start_wal ($(du -sh wal | cut -f1))"
|
||||
51
ops/pgbackup/restore-check.sh
Executable file
51
ops/pgbackup/restore-check.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# آزمونِ بازیابی روی خودِ ۲۴۷: آخرین نسخهی کامل + همهی WALِ رسیده را در پوشهی موقت بالا میآورد
|
||||
# (Postgresِ موقت روی سوکتِ داخلِ همان پوشه، بیپورتِ شبکه؛ به Postgresِ دیگرانِ این ماشین دست نمیزند)،
|
||||
# شمارشِ جدولهای کاربر/ورود/پرداخت و آخرین زمانها را چاپ میکند و همهچیز را پاک میکند.
|
||||
# همان پرسش را روی ۲۴۹ بزنید و مقایسه کنید. با آرگومان، تا یک لحظه برمیگردد:
|
||||
# restore-check.sh '2026-10-10 16:30:00+03:30'
|
||||
# با KEEP=1 پوشه و Postgresِ موقت میمانند (برای بازیابیِ واقعی: از آن pg_dump بگیرید، بعد pg_ctl stop و rm).
|
||||
set -euo pipefail
|
||||
ROOT=/home/administrator/zareian-pgbackup
|
||||
BIN=/usr/lib/postgresql/14/bin
|
||||
TARGET="${1:-}"
|
||||
|
||||
latest=$(ls -1d "$ROOT"/base/2* | tail -1)
|
||||
work=$(mktemp -d "$ROOT/restore-check.XXXXXX")
|
||||
if [ "${KEEP:-0}" != 1 ]; then
|
||||
trap '"$BIN/pg_ctl" -D "$work/data" -m immediate stop >/dev/null 2>&1 || true; rm -rf "$work"' EXIT
|
||||
fi
|
||||
|
||||
cp -a "$latest" "$work/data"
|
||||
mkdir "$work/wal"
|
||||
cp "$ROOT"/wal/* "$work/wal/"
|
||||
# فایلِ در حالِ نوشتن (.partial) هم دادهی واقعی است؛ برای بازیابی نامِ کامل میگیرد.
|
||||
for f in "$work"/wal/*.partial; do [ -e "$f" ] && mv "$f" "${f%.partial}"; done
|
||||
|
||||
# postgresql.conf و pg_hba.conf روی اوبونتو در /etc اند، نه در پوشهی داده؛ برای نمونهی موقت ساخته میشوند.
|
||||
cat > "$work/data/postgresql.conf" <<CONF
|
||||
listen_addresses = ''
|
||||
unix_socket_directories = '$work'
|
||||
restore_command = 'cp $work/wal/%f %p'
|
||||
recovery_target_action = 'promote'
|
||||
${TARGET:+recovery_target_time = '$TARGET'}
|
||||
CONF
|
||||
echo 'local all all trust' > "$work/data/pg_hba.conf"
|
||||
: > "$work/data/postgresql.auto.conf"
|
||||
touch "$work/data/recovery.signal"
|
||||
|
||||
"$BIN/pg_ctl" -D "$work/data" -l "$work/log" -w -t 600 start >/dev/null
|
||||
for _ in $(seq 120); do
|
||||
[ "$("$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -Atc 'select pg_is_in_recovery()')" = f ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "base: $(basename "$latest") target: ${TARGET:-end of WAL}"
|
||||
"$BIN/psql" -h "$work" -U postgres -d zareian_mathapp -c "
|
||||
select 'users' what, count(*)::text n, max(created_at)::text latest from accounts_user
|
||||
union all select 'login_events', count(*)::text, max(created_at)::text from accounts_loginevent
|
||||
union all select 'orders', count(*)::text, max(created_at)::text from payment_order
|
||||
union all select 'transactions', count(*)::text, max(created_at)::text from payment_transaction
|
||||
union all select 'status_changes', count(*)::text, max(created_at)::text from statuslog_statuschangelog;"
|
||||
[ "${KEEP:-0}" = 1 ] && echo "kept: $work (psql -h $work -U postgres -d zareian_mathapp)"
|
||||
exit 0
|
||||
10
ops/pgbackup/zareian-pg-basebackup.service
Normal file
10
ops/pgbackup/zareian-pg-basebackup.service
Normal file
@@ -0,0 +1,10 @@
|
||||
# روی سرورِ بکاپ (۲۴۷). یک نسخهی کاملِ روزانه؛ بازیابی = آخرین نسخه + WALِ بعدش تا هر لحظه.
|
||||
[Unit]
|
||||
Description=Zareian MathApp: daily base backup from 249
|
||||
Requires=zareian-pg-tunnel.service
|
||||
After=zareian-pg-tunnel.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=administrator
|
||||
ExecStart=/home/administrator/zareian-pgbackup/bin/basebackup.sh
|
||||
9
ops/pgbackup/zareian-pg-basebackup.timer
Normal file
9
ops/pgbackup/zareian-pg-basebackup.timer
Normal file
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Zareian MathApp: daily base backup from 249
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 04:30:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
19
ops/pgbackup/zareian-pg-tunnel.service
Normal file
19
ops/pgbackup/zareian-pg-tunnel.service
Normal file
@@ -0,0 +1,19 @@
|
||||
# روی سرورِ بکاپ (۲۴۷). تونلِ SSH از 127.0.0.1:15440ِ اینجا به Postgresِ ما روی ۲۴۹ (127.0.0.1:5440).
|
||||
# کلید در authorized_keysِ ۲۴۹ با restrict,port-forwarding,permitopen="127.0.0.1:5440" محدود است:
|
||||
# با آن نه شِل باز میشود نه پورتِ دیگری. ترافیک در شبکهی داخلی (192.168.200.x) میماند.
|
||||
[Unit]
|
||||
Description=Zareian MathApp: SSH tunnel to the app Postgres on 249
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
User=administrator
|
||||
ExecStart=/usr/bin/ssh -N -i /home/administrator/.ssh/zareian_pgbackup \
|
||||
-o BatchMode=yes -o ExitOnForwardFailure=yes \
|
||||
-o ServerAliveInterval=15 -o ServerAliveCountMax=3 \
|
||||
-L 127.0.0.1:15440:127.0.0.1:5440 administrator@192.168.200.164
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
19
ops/pgbackup/zareian-pg-wal.service
Normal file
19
ops/pgbackup/zareian-pg-wal.service
Normal file
@@ -0,0 +1,19 @@
|
||||
# روی سرورِ بکاپ (۲۴۷). هر تغییرِ دیتابیسِ ۲۴۹ را لحظهای در wal/ مینویسد (pg_receivewal، ابزارِ خودِ Postgres).
|
||||
# --synchronous: هر تکه همان لحظه روی دیسکِ اینجا fsync میشود. اپ روی ۲۴۹ منتظرِ اینجا نمیماند
|
||||
# (synchronous_standby_names خالی است)، پس قطعیِ ۲۴۷ هیچ پرداختی را کند یا متوقف نمیکند.
|
||||
# اسلاتِ zareian_247 روی ۲۴۹ نگه میدارد که در قطعی چیزی جا نیفتد؛ سقفش max_slot_wal_keep_size=1GB است.
|
||||
[Unit]
|
||||
Description=Zareian MathApp: live WAL stream from 249
|
||||
After=zareian-pg-tunnel.service
|
||||
BindsTo=zareian-pg-tunnel.service
|
||||
|
||||
[Service]
|
||||
User=administrator
|
||||
ExecStart=/usr/lib/postgresql/14/bin/pg_receivewal -h 127.0.0.1 -p 15440 -U zareian_replicator \
|
||||
--no-password --slot=zareian_247 --synchronous --no-loop \
|
||||
-D /home/administrator/zareian-pgbackup/wal
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
26
payment/tests_audit_log.py
Normal file
26
payment/tests_audit_log.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
ردِ نتیجهی درگاه در لاگِ فنی: کالبکِ بیسفارش و نتیجهی تکراری فقط همینجا رد دارند (پولی جابهجا
|
||||
نمیکنند، پس ردیفِ دیتابیس ندارند). نتیجههای پذیرفتهشده در Transaction/StatusChangeLogاند.
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
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])
|
||||
@@ -1,224 +0,0 @@
|
||||
"""
|
||||
بکاپِ زمانبندیشدهی 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
|
||||
@@ -1,16 +0,0 @@
|
||||
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')}")
|
||||
@@ -1,17 +0,0 @@
|
||||
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']}")
|
||||
@@ -1,114 +0,0 @@
|
||||
"""
|
||||
بکاپِ زمانبندیشده: نگهداری، هوکِ بیرونی، هشدارِ 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