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