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

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

View File

@@ -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')}")

View File

@@ -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']}")

View File

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