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:
@@ -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'})
|
||||
Reference in New Issue
Block a user