بازبینیِ کارِ امروز، سه مورد پیدا کرد. /api/health وقتی مهاجرتِ اعمالنشده وجود داشت میشکست: کدِ فهرستکردنِ مهاجرتها به migration.key تکیه میکرد که اصلاً وجود ندارد (فیلدها app_label و name هستند). یعنی دقیقاً در همان حالتی که این بررسی برایش ساخته شده، بهجای نامِ مهاجرتها فقط AttributeError برمیگرداند. حالا تستی دارد که مهاجرتِ معلق را شبیهسازی میکند — چیزی که نبودش باعث شد اولین بار غلط بماند. وارد کردنِ سوال با replace عددِ غلط گزارش میکرد: delete() در جنگو مجموعِ همهی ردیفهای آبشاری را میدهد، پس «۵ سوال پاک شد» در واقع گزینهها و پاسخها را هم میشمرد. deploy.sh بررسیِ متغیرهای محیطیاش با تنظیماتِ جدید ناسازگار شده بود: متغیرهایی را اجباری میدانست که حالا پیشفرض دارند، و فقط شل را میخواند نه .env — یعنی روی سرورِ درست هم دیپلوی را رد میکرد. حالا بهجای تکرارِ منطقِ تنظیمات، از خودِ جنگو میپرسد چه چیزی مؤثر است و گزارش میدهد. کوچکتر: بررسیِ سلامت یک درخواست میزند نه دو تا، و شکستِ ریاستارت دیگر اسکریپت را بیصدا تمام نمیکند. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
6.6 KiB
Python
148 lines
6.6 KiB
Python
"""
|
||
GET /api/health — «آیا این سرور واقعاً سالم است؟»
|
||
|
||
چرا لازم بود: تا امروز تنها راهِ فهمیدنِ اینکه دیپلوی درست نشسته یا نه، این
|
||
بود که کسی از بیرون اپ را دستی امتحان کند و اگر چیزی غلط بود به آدمی که به
|
||
سرور دسترسی دارد پیام بدهد. یعنی خرابی را همیشه آدم کشف میکرد، نه ابزار.
|
||
این اندپوینت همان بررسیها را از بیرون و بدونِ دسترسیِ SSH ممکن میکند.
|
||
|
||
مهمترین بررسی «مهاجرتهای اعمالنشده» است: شایعترین حالتِ خرابِ دیپلوی این
|
||
است که کد pull شده ولی migrate اجرا نشده — اپ بالا میآید و بعد زیرِ بارِ
|
||
واقعی با خطای ستونِ ناموجود میشکند.
|
||
|
||
عمداً هیچ سکرتی برنمیگرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
|
||
"""
|
||
from django.conf import settings
|
||
from django.core.cache import cache
|
||
from django.db import connection
|
||
from django.db.migrations.executor import MigrationExecutor
|
||
from drf_spectacular.utils import extend_schema
|
||
from rest_framework.permissions import AllowAny
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
|
||
def _check_database() -> dict:
|
||
try:
|
||
with connection.cursor() as cursor:
|
||
cursor.execute('SELECT 1')
|
||
cursor.fetchone()
|
||
return {'ok': True, 'engine': connection.vendor}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _check_migrations() -> dict:
|
||
"""مهاجرتِ اعمالنشده یعنی دیپلوی نیمهکاره است."""
|
||
try:
|
||
executor = MigrationExecutor(connection)
|
||
targets = executor.loader.graph.leaf_nodes()
|
||
pending = executor.migration_plan(targets)
|
||
# migration_plan یک فهرست از (migration, backwards) میدهد؛ خودِ
|
||
# Migration فیلدهای app_label و name دارد (نه key).
|
||
return {
|
||
'ok': not pending,
|
||
'pending': [f'{migration.app_label}.{migration.name}' for migration, _ in pending],
|
||
}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _check_cache() -> dict:
|
||
"""با DEBUG=False کشِ مشترک اجباری است؛ throttle بدونش بیاثر میشود."""
|
||
try:
|
||
cache.set('health:probe', 'ok', 10)
|
||
return {'ok': cache.get('health:probe') == 'ok', 'backend': settings.CACHES['default']['BACKEND'].rsplit('.', 1)[-1]}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _check_content() -> dict:
|
||
"""
|
||
محتوا روی این سرور هست یا نه — بدونِ این، اپ سالم به نظر میرسد ولی
|
||
کاربر صفحهی خالی میبیند.
|
||
"""
|
||
try:
|
||
from content.models import Grade, Lesson, Question
|
||
|
||
grades = Grade.objects.count()
|
||
lessons = Lesson.objects.count()
|
||
questions = Question.objects.count()
|
||
return {
|
||
'ok': grades > 0 and lessons > 0,
|
||
'grades': grades,
|
||
'lessons': lessons,
|
||
'questions': questions,
|
||
}
|
||
except Exception as exc: # noqa: BLE001
|
||
return {'ok': False, 'error': type(exc).__name__}
|
||
|
||
|
||
def _check_integrations() -> dict:
|
||
"""فقط «تنظیم شده یا نه» — هیچ کلیدی برنمیگردد."""
|
||
return {
|
||
'qdrant': bool(getattr(settings, 'QDRANT_API_KEY', '')),
|
||
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
|
||
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
|
||
}
|
||
|
||
|
||
def _warnings() -> list[str]:
|
||
"""
|
||
چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناکاند.
|
||
|
||
اینجا هستند چون تنها راهِ دیدنشان تا امروز این بود که کسی تنظیماتِ سرور را
|
||
از نزدیک بخواند — و ما به سرور دسترسی نداریم.
|
||
"""
|
||
out = []
|
||
if settings.DEBUG:
|
||
out.append(
|
||
'DEBUG روشن است: صفحههای خطا کلِ تنظیمات و traceback را نشان میدهند، '
|
||
'و کدِ ورودِ آزمایشیِ 0000 برای هر شمارهای پذیرفته میشود. '
|
||
'روی سرورِ عمومی باید خاموش باشد.'
|
||
)
|
||
if getattr(settings, 'DEV_MASTER_OTP', None) is not None:
|
||
out.append('کدِ یکسانِ ورود (DEV_MASTER_OTP) فعال است — هر کسی میتواند بهجای هر کاربری وارد شود.')
|
||
backend = settings.CACHES['default']['BACKEND']
|
||
if not settings.DEBUG and 'LocMem' in backend:
|
||
out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.')
|
||
if not getattr(settings, 'CORS_ALLOWED_ORIGINS', None):
|
||
out.append('هیچ origin ای برای CORS مجاز نیست — مرورگر همهی درخواستهای فرانت را بلاک میکند.')
|
||
return out
|
||
|
||
|
||
class HealthView(APIView):
|
||
"""
|
||
GET /api/health — وضعیتِ این نسخهی مستقرشده.
|
||
|
||
۲۰۰ یعنی سالم، ۵۰۳ یعنی چیزی واقعاً خراب است (دیتابیس، کش، یا مهاجرتِ
|
||
اعمالنشده). نبودِ محتوا خرابی حساب نمیشود ولی گزارش میشود.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
authentication_classes = []
|
||
throttle_classes = [ScopedRateThrottle]
|
||
throttle_scope = 'health'
|
||
|
||
@extend_schema(tags=['System'], responses={200: dict, 503: dict})
|
||
def get(self, request):
|
||
checks = {
|
||
'database': _check_database(),
|
||
'migrations': _check_migrations(),
|
||
'cache': _check_cache(),
|
||
'content': _check_content(),
|
||
}
|
||
critical_ok = all(checks[k]['ok'] for k in ('database', 'migrations', 'cache'))
|
||
|
||
warnings = _warnings()
|
||
payload = {
|
||
'status': 'ok' if critical_ok else 'error',
|
||
'debug': bool(settings.DEBUG),
|
||
'warnings': warnings,
|
||
'checks': checks,
|
||
'integrations': _check_integrations(),
|
||
'allowedOrigins': list(getattr(settings, 'CORS_ALLOWED_ORIGINS', [])),
|
||
}
|
||
return Response(payload, status=200 if critical_ok else 503)
|