بالا آوردنِ نسخهی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (pull، نصبِ
وابستگیها، مهاجرت، استاتیک، ریاستارت) و فراموش شدنِ هر کدام اپ را میخواباند
با خطایی که از بیرون فقط «502 Bad Gateway» دیده میشد. همین الان هم سرور
دقیقاً به همین دلیل خوابیده: کامیتِ ac1b1af پکیجِ django-cors-headers را اضافه
کرد، و بدونِ pip install جنگو با ModuleNotFoundError بالا نمیآید.
deploy.sh همه را به ترتیب و idempotent انجام میدهد. pip install همیشه اجرا
میشود، نه فقط وقتی requirements عوض شده — تشخیصِ «عوض شده یا نه» خودش یک جای
فراموششدنیِ دیگر است. آخرِ کار خودش سلامت را بررسی میکند و اگر بالا نیامده
باشد علتهای رایج را چاپ میکند، بهجای اینکه کسی بعداً از بیرون کشفش کند.
GET /api/health وضعیت را بدونِ احراز و بدونِ SSH بیرون میدهد: دیتابیس،
مهاجرتهای اعمالنشده، کش، و شمارشِ پایه/درس/سوال روی همان سرور. مهمترینش
مهاجرتِ اعمالنشده است — حالتی که اپ ظاهراً بالا میآید و بعد زیرِ بارِ واقعی
با خطای ستونِ ناموجود میشکند. عمداً هیچ سکرتی برنمیگرداند، فقط بله/خیرِ
«تنظیم شده».
۵۰۳ برمیگرداند وقتی دیتابیس/کش/مهاجرت خراب است تا پایشِ خودکار بتواند رویش
حساب کند؛ نبودِ محتوا خرابی حساب نمیشود ولی گزارش میشود.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
4.9 KiB
Python
120 lines
4.9 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)
|
|
return {
|
|
'ok': not pending,
|
|
'pending': [f'{app}.{name}' for (app, name), _ in
|
|
((m.key, m) for m, _ 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', '')),
|
|
}
|
|
|
|
|
|
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'))
|
|
|
|
payload = {
|
|
'status': 'ok' if critical_ok else 'error',
|
|
'debug': bool(settings.DEBUG),
|
|
'checks': checks,
|
|
'integrations': _check_integrations(),
|
|
}
|
|
return Response(payload, status=200 if critical_ok else 503)
|