make a deploy observable and one command instead of a checklist

بالا آوردنِ نسخه‌ی جدید تا الان چند کارِ دستی به ترتیبِ درست بود (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>
This commit is contained in:
2026-09-13 20:28:59 +03:30
parent 5f3aae0756
commit cbe9b63dc4
5 changed files with 311 additions and 0 deletions

119
config/health.py Normal file
View File

@@ -0,0 +1,119 @@
"""
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)

View File

@@ -333,6 +333,9 @@ REST_FRAMEWORK = {
'affiliate_track': '10/hour',
'payment_webhook': '60/hour',
'payment_pay': '30/hour',
# /api/health برای پایشِ بیرونی است؛ باید مکرر قابلِ صدا زدن باشد
# ولی نه آن‌قدر که خودش به ابزارِ فشار تبدیل شود.
'health': os.environ.get('HEALTH_THROTTLE_RATE', '60/min'),
},
}

View File

@@ -10,6 +10,7 @@ from django.contrib import admin
from django.http import JsonResponse
from django.urls import include, path
from django.views import defaults as django_default_views
from config.health import HealthView
from drf_spectacular.views import (
SpectacularAPIView,
SpectacularRedocView,
@@ -41,6 +42,9 @@ def handler500(request):
urlpatterns = [
path('admin/', admin.site.urls),
# بررسیِ سلامتِ نسخه‌ی مستقرشده — بدونِ احراز، برای پایشِ بیرونی.
path('api/health', HealthView.as_view(), name='health'),
# OpenAPI schema + Swagger UI + Redoc
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),