216 backups encrypted on 216: pgBackRest (AES-256) + age-encrypted payment CSVs, pushed write-only to 247 with rrsync; health watches pg_stat_archiver and OFFSITE_SYNC_STATUS_FILE; 216 scripts/configs in ops/server216; plaintext pg216 pipeline removed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
@@ -160,6 +161,8 @@ def _offsite_backup() -> dict:
|
||||
"""
|
||||
if connection.vendor != 'postgresql':
|
||||
return {'state': 'not_postgres'}
|
||||
if settings.OFFSITE_SYNC_STATUS_FILE:
|
||||
return _pushed_backup()
|
||||
slot = settings.OFFSITE_WAL_SLOT
|
||||
if not slot:
|
||||
return {'state': 'not_configured'}
|
||||
@@ -193,6 +196,45 @@ def _slot_state(row) -> dict:
|
||||
return {'state': state, 'lagBytes': lag}
|
||||
|
||||
|
||||
def _pushed_backup() -> dict:
|
||||
"""
|
||||
بکاپِ رمزشدهی ۲۱۶: pgBackRest هر WAL را با archive_command آرشیو میکند (pg_stat_archiver) و
|
||||
`tadrisino-offsite-sync` مخزن را هر دقیقه به ۲۴۷ میفرستد و زمانش را در OFFSITE_SYNC_STATUS_FILE مینویسد.
|
||||
کهنگیِ آخرین آرشیو هشدار نیست: دیتابیسِ بیتغییر WALِ تازهای ندارد که آرشیو شود.
|
||||
"""
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute('SELECT last_archived_time, last_failed_time FROM pg_stat_archiver')
|
||||
last_archived, last_failed = cursor.fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {'state': 'error', 'error': type(exc).__name__}
|
||||
try:
|
||||
with open(settings.OFFSITE_SYNC_STATUS_FILE) as f:
|
||||
synced_at = int(f.read().strip())
|
||||
except FileNotFoundError:
|
||||
synced_at = None
|
||||
except (OSError, ValueError) as exc:
|
||||
return {'state': 'error', 'error': type(exc).__name__}
|
||||
return _pushed_state(last_archived, last_failed, synced_at, time.time())
|
||||
|
||||
|
||||
def _pushed_state(last_archived, last_failed, synced_at, now) -> dict:
|
||||
age = None if synced_at is None else int(now - synced_at)
|
||||
if last_failed is not None and (last_archived is None or last_failed > last_archived):
|
||||
state = 'archive_failing'
|
||||
elif age is None:
|
||||
state = 'never_synced'
|
||||
elif age > settings.OFFSITE_SYNC_MAX_AGE_SECONDS:
|
||||
state = 'sync_stale'
|
||||
else:
|
||||
state = 'ok'
|
||||
return {
|
||||
'state': state,
|
||||
'syncAgeSeconds': age,
|
||||
'lastArchivedAt': last_archived.isoformat() if last_archived else None,
|
||||
}
|
||||
|
||||
|
||||
# اتصالهایی بیرون از poolِ workerها: pg_receivewal و pg_basebackupِ سرورِ بکاپ (۲)، pg_dump و
|
||||
# migrateِ deploy.sh (۲)، فرمانِ مدیریتی یا psqlِ دستی (۲)، و workerی که gunicorn با max_requests
|
||||
# عوض میکند و تا بسته شدنِ قبلی همزمان بالاست (یک pool).
|
||||
@@ -267,6 +309,9 @@ _OFFSITE_MESSAGES = {
|
||||
'lagging': 'سرورِ بکاپ از دیتابیس عقب است.',
|
||||
'lost': 'سرورِ بکاپ آنقدر قطع بود که زنجیرهی WAL برید؛ روی آن سرور اسلات و base backupِ تازه لازم است.',
|
||||
'error': 'وضعیتِ کپیِ زندهی دیتابیس خوانده نشد.',
|
||||
'archive_failing': 'آرشیوِ WAL (pgBackRest) شکست میخورد و تغییراتِ تازه بکاپ نمیشوند: sudo -u postgres pgbackrest --stanza=zareian check',
|
||||
'never_synced': 'بکاپِ رمزشده هنوز هیچوقت به سرورِ بیرونی (۲۴۷) نرسیده (فایلِ OFFSITE_SYNC_STATUS_FILE نیست).',
|
||||
'sync_stale': 'ارسالِ بکاپِ رمزشده به ۲۴۷ عقب افتاده: journalctl -u tadrisino-offsite-sync',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1078,6 +1078,12 @@ LOG_DIR = os.environ.get('LOG_DIR', '')
|
||||
OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '')
|
||||
# بیش از این عقبماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقبماندگی نزدیکِ صفر است.
|
||||
OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20
|
||||
# سرورِ اختصاصی (۲۱۶): بکاپِ رمزشدهی pgBackRest؛ `tadrisino-offsite-sync` هر دقیقه آن را به ۲۴۷ میفرستد و زمانِ
|
||||
# آخرین ارسالِ موفق (epoch) را در این فایل مینویسد (docs/operations.md «سرورِ اختصاصیِ جدید»). ست باشد، بر
|
||||
# OFFSITE_WAL_SLOT مقدم است. OFFSITE_WAL_SLOT را فقط ۲۴۹ دارد و با سوئیچ به ۲۱۶ همراهِ _slot_state حذف میشود.
|
||||
OFFSITE_SYNC_STATUS_FILE = os.environ.get('OFFSITE_SYNC_STATUS_FILE', '')
|
||||
# ارسال هر دقیقه است؛ ۱۰ دقیقه بیارسال یعنی چند اجرای پشتِ سرِ هم شکست خورده.
|
||||
OFFSITE_SYNC_MAX_AGE_SECONDS = 600
|
||||
|
||||
# چرخشِ فایلِ لاگ: 'size' (پیشفرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمیها پاک میشوند؛
|
||||
# 'external' (سرور) = برنامه هرگز پاک نمیکند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابهجا و
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
پرسشِ واقعی از pg_replication_slots اینجا اجرا نمیشود (تستها روی SQLiteاند)؛ روی سرور با اسلاتِ
|
||||
واقعی دیده شد (docs/operations.md «بکاپ»).
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
from config import health
|
||||
@@ -27,13 +29,45 @@ class SlotStateTests(SimpleTestCase):
|
||||
self.assertEqual(health._slot_state((True, 'reserved', 100))['state'], 'ok')
|
||||
|
||||
|
||||
@override_settings(OFFSITE_SYNC_MAX_AGE_SECONDS=600)
|
||||
class PushedStateTests(SimpleTestCase):
|
||||
"""بکاپِ رمزشدهی ۲۱۶ (_pushed_state). پرسشِ pg_stat_archiver و خواندنِ فایل روی سرور دیده شد، نه اینجا."""
|
||||
|
||||
T0 = datetime(2026, 10, 17, 16, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_recent_sync_is_ok(self):
|
||||
r = health._pushed_state(self.T0, None, 1000, now=1060)
|
||||
self.assertEqual((r['state'], r['syncAgeSeconds']), ('ok', 60))
|
||||
self.assertEqual(r['lastArchivedAt'], self.T0.isoformat())
|
||||
|
||||
def test_old_sync_is_stale(self):
|
||||
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1601)['state'], 'sync_stale')
|
||||
self.assertEqual(health._pushed_state(self.T0, None, 1000, now=1600)['state'], 'ok')
|
||||
|
||||
def test_no_status_file_is_never_synced(self):
|
||||
self.assertEqual(health._pushed_state(self.T0, None, None, now=1000)['state'], 'never_synced')
|
||||
|
||||
def test_failure_after_last_success_wins(self):
|
||||
later = self.T0 + timedelta(minutes=1)
|
||||
self.assertEqual(health._pushed_state(self.T0, later, 1000, now=1001)['state'], 'archive_failing')
|
||||
self.assertEqual(health._pushed_state(None, later, 1000, now=1001)['state'], 'archive_failing')
|
||||
|
||||
def test_old_failure_then_success_is_ok(self):
|
||||
earlier = self.T0 - timedelta(minutes=1)
|
||||
self.assertEqual(health._pushed_state(self.T0, earlier, 1000, now=1001)['state'], 'ok')
|
||||
|
||||
def test_idle_database_with_no_archive_yet_is_ok(self):
|
||||
self.assertEqual(health._pushed_state(None, None, 1000, now=1001)['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'):
|
||||
for state in ('not_configured', 'slot_missing', 'disconnected', 'lagging', 'lost', 'error',
|
||||
'archive_failing', 'never_synced', 'sync_stale'):
|
||||
self.assertEqual(len(health._offsite_warnings({'state': state})), 1, state)
|
||||
|
||||
def test_sqlite_reports_not_postgres(self):
|
||||
|
||||
Reference in New Issue
Block a user