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:
2026-10-10 16:53:10 +03:30
parent 19072ee534
commit a9cccc6550
22 changed files with 374 additions and 447 deletions

View File

@@ -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 غلط

View File

@@ -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)

View 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'})