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:
119
config/health.py
Normal file
119
config/health.py
Normal 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)
|
||||
@@ -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'),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user