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:
51
README.md
51
README.md
@@ -110,6 +110,57 @@ python manage.py test # اجرای کل تستسوییت
|
||||
|
||||
## ۲. راهنمای دیپلوی پروداکشن
|
||||
|
||||
### بالا آوردنِ نسخهی جدید — یک دستور
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
همین. اسکریپت به ترتیب کدِ تازه را میگیرد، وابستگیها را نصب میکند، مهاجرتها
|
||||
را اعمال میکند، فایلهای استاتیک را جمع میکند، سرویس را ریاستارت میکند، و در
|
||||
پایان **خودش بررسی میکند که واقعاً بالا آمده یا نه**. اگر بالا نیامد، علتهای
|
||||
رایج را چاپ میکند.
|
||||
|
||||
چرا این مهم است: قبلاً اینها چند کارِ دستی به ترتیبِ درست بودند و فراموش شدنِ
|
||||
هر کدام اپ را میخواباند، با خطایی که گویا نبود. مثلاً اضافه شدنِ یک پکیجِ جدید
|
||||
به `requirements.txt` بدونِ `pip install` باعثِ `ModuleNotFoundError` موقعِ بالا
|
||||
آمدن میشود و کاربر فقط `502 Bad Gateway` میبیند. اسکریپت همیشه `pip install`
|
||||
را اجرا میکند تا این حالت اصلاً پیش نیاید.
|
||||
|
||||
بارِ اول، نامِ سرویس را بدهید تا دفعههای بعد خودکار ریاستارت شود:
|
||||
|
||||
```bash
|
||||
TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh
|
||||
```
|
||||
|
||||
متغیرهای اختیاری: `SKIP_PULL=1` (بدونِ git pull)، `TADRISINO_HEALTH_URL`،
|
||||
`PIP_INDEX_URL` (اگر pypi.org تایماوت میدهد).
|
||||
|
||||
### بررسیِ سلامت از بیرون
|
||||
|
||||
```bash
|
||||
curl https://<host>/api/health
|
||||
```
|
||||
|
||||
بدونِ احرازِ هویت و بدونِ SSH میگوید دیتابیس سالم است یا نه، مهاجرتِ
|
||||
اعمالنشده مانده یا نه، کش کار میکند یا نه، و چند پایه/درس/سوال روی آن سرور
|
||||
هست. `200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمیگرداند.
|
||||
|
||||
این همان چیزیست که جلوی «کد رفته ولی مهاجرت اجرا نشده» را میگیرد — حالتی که
|
||||
اپ ظاهراً بالا میآید و بعد زیرِ بارِ واقعی میشکند.
|
||||
|
||||
### مدیریتِ محتوا بدونِ ترمینال
|
||||
|
||||
سوالها از پنلِ ادمین وارد و خارج میشوند، نه با دستور روی سرور:
|
||||
|
||||
- `/admin/content/question/` → دکمهی **«وارد کردنِ سوالها از فایل»**
|
||||
- برای گرفتنِ نمونه: چند سوال را انتخاب کنید → اکشنِ **«دریافتِ فایلِ سوالهای
|
||||
انتخابشده»**. خروجی دقیقاً همان قالبیست که ورودی میپذیرد.
|
||||
|
||||
فایلِ نامعتبر **هیچ چیزی نمینویسد** — یا همهی سوالها وارد میشوند یا هیچکدام،
|
||||
و خطا میگوید کدام ردیف ایراد دارد.
|
||||
|
||||
|
||||
### متغیرهای محیطی موردنیاز
|
||||
| متغیر | لازم در پروداکشن | توضیح |
|
||||
|---|---|---|
|
||||
|
||||
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'),
|
||||
|
||||
134
deploy.sh
Executable file
134
deploy.sh
Executable file
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# دیپلویِ تدریسینو — یک دستور، نه یک فهرستِ کار.
|
||||
#
|
||||
# ./deploy.sh
|
||||
#
|
||||
# چرا این فایل وجود دارد: تا امروز بالا آوردنِ نسخهی جدید یعنی چند کارِ دستی
|
||||
# به ترتیبِ درست (pull، نصبِ وابستگیها، مهاجرت، فایلهای استاتیک، ریاستارت).
|
||||
# فراموش شدنِ هر کدام، اپ را میخواباند و خطایش هم گویا نیست — مثلاً اضافه شدنِ
|
||||
# یک پکیجِ جدید بدونِ `pip install` باعثِ ModuleNotFoundError موقعِ بالا آمدن
|
||||
# میشود و کاربر فقط «502 Bad Gateway» میبیند. این اسکریپت همه را به ترتیب و
|
||||
# idempotent انجام میدهد و آخرش خودش بررسی میکند که واقعاً بالا آمده یا نه.
|
||||
#
|
||||
# متغیرهای اختیاری:
|
||||
# TADRISINO_SERVICE نامِ سرویسِ systemd برای ریاستارت (مثلاً tadrisino)
|
||||
# TADRISINO_HEALTH_URL آدرسی که در پایان بررسی میشود
|
||||
# SKIP_PULL=1 بدونِ git pull (وقتی کد را دستی گذاشتهاید)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
C_OK=$'\033[0;32m'; C_ERR=$'\033[0;31m'; C_WARN=$'\033[0;33m'; C_OFF=$'\033[0m'
|
||||
step() { printf '\n%s▸ %s%s\n' "$C_OK" "$1" "$C_OFF"; }
|
||||
warn() { printf '%s⚠ %s%s\n' "$C_WARN" "$1" "$C_OFF"; }
|
||||
fail() { printf '%s✖ %s%s\n' "$C_ERR" "$1" "$C_OFF" >&2; exit 1; }
|
||||
|
||||
HEALTH_URL="${TADRISINO_HEALTH_URL:-http://127.0.0.1:8000/api/health}"
|
||||
|
||||
# ─── ۱. کدِ تازه ────────────────────────────────────────────────────────────
|
||||
if [ "${SKIP_PULL:-0}" != "1" ]; then
|
||||
step "گرفتنِ کدِ تازه"
|
||||
git pull --ff-only || fail "git pull نشد. اگر تغییرِ محلی دارید اول تعیین تکلیفش کنید."
|
||||
else
|
||||
step "گرفتنِ کد رد شد (SKIP_PULL=1)"
|
||||
fi
|
||||
printf ' کامیتِ فعلی: %s\n' "$(git rev-parse --short HEAD) $(git log -1 --format=%s)"
|
||||
|
||||
# ─── ۲. محیطِ پایتون ────────────────────────────────────────────────────────
|
||||
step "فعالسازیِ محیطِ مجازی"
|
||||
if [ -d venv ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source venv/bin/activate
|
||||
printf ' venv فعال شد\n'
|
||||
elif [ -n "${VIRTUAL_ENV:-}" ]; then
|
||||
printf ' از venv فعالِ فعلی استفاده میشود: %s\n' "$VIRTUAL_ENV"
|
||||
else
|
||||
warn "هیچ venv ای پیدا نشد؛ از پایتونِ سیستم استفاده میشود."
|
||||
fi
|
||||
|
||||
# ─── ۳. وابستگیها ──────────────────────────────────────────────────────────
|
||||
# همیشه اجرا میشود، نه فقط وقتی requirements عوض شده: تشخیصِ «عوض شده یا نه»
|
||||
# خودش یک جای فراموششدنیِ دیگر است، و pip وقتی همهچیز نصب است سریع تمام میکند.
|
||||
step "نصبِ وابستگیها"
|
||||
PIP_ARGS=(-r requirements.txt --disable-pip-version-check)
|
||||
if [ -n "${PIP_INDEX_URL:-}" ]; then
|
||||
printf ' از ایندکسِ %s استفاده میشود\n' "$PIP_INDEX_URL"
|
||||
fi
|
||||
python -m pip install "${PIP_ARGS[@]}" \
|
||||
|| fail "نصبِ وابستگیها شکست خورد. اگر pypi.org تایماوت میدهد، این را امتحان کنید:
|
||||
PIP_INDEX_URL=https://mirror-pypi.runflare.com/simple ./deploy.sh"
|
||||
|
||||
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
|
||||
step "اعمالِ مهاجرتهای دیتابیس"
|
||||
python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ریاستارت نشد."
|
||||
|
||||
# ─── ۵. فایلهای استاتیک ───────────────────────────────────────────────────
|
||||
# فقط در حالتِ پروداکشن لازم است: با DEBUG=1 خودِ whitenoise از finders میخواند.
|
||||
step "جمعآوریِ فایلهای استاتیک"
|
||||
if python -c "
|
||||
import django, os
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE','config.settings')
|
||||
django.setup()
|
||||
from django.conf import settings
|
||||
raise SystemExit(0 if settings.DEBUG else 1)
|
||||
" 2>/dev/null; then
|
||||
printf ' DEBUG روشن است — لازم نیست.\n'
|
||||
else
|
||||
python manage.py collectstatic --noinput >/dev/null \
|
||||
|| warn "collectstatic شکست خورد؛ پنلِ ادمین ممکن است بدونِ CSS بالا بیاید."
|
||||
printf ' انجام شد.\n'
|
||||
fi
|
||||
|
||||
# ─── ۶. بررسیهای پیش از راهاندازی ────────────────────────────────────────
|
||||
step "بررسیِ تنظیماتِ استقرار"
|
||||
python manage.py check --deploy 2>&1 | sed 's/^/ /' || true
|
||||
|
||||
# ─── ۷. ریاستارت ──────────────────────────────────────────────────────────
|
||||
step "ریاستارتِ سرویس"
|
||||
SERVICE="${TADRISINO_SERVICE:-}"
|
||||
if [ -z "$SERVICE" ] && command -v systemctl >/dev/null 2>&1; then
|
||||
for candidate in tadrisino tadrisino-api mathapp gunicorn; do
|
||||
if systemctl list-unit-files "${candidate}.service" >/dev/null 2>&1 \
|
||||
&& systemctl cat "${candidate}.service" >/dev/null 2>&1; then
|
||||
SERVICE="$candidate"; break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$SERVICE" ]; then
|
||||
sudo systemctl restart "$SERVICE" && printf ' سرویسِ %s ریاستارت شد\n' "$SERVICE"
|
||||
else
|
||||
warn "سرویسِ systemd پیدا نشد؛ خودتان ریاستارت کنید، مثلاً:
|
||||
sudo systemctl restart <نامِ سرویس>
|
||||
و برای اینکه دفعهی بعد خودکار شود:
|
||||
TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh"
|
||||
fi
|
||||
|
||||
# ─── ۸. آیا واقعاً بالا آمد؟ ───────────────────────────────────────────────
|
||||
step "بررسیِ سلامت"
|
||||
printf ' %s\n' "$HEALTH_URL"
|
||||
for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
||||
BODY="$(curl -s -m 10 "$HEALTH_URL" 2>/dev/null || true)"
|
||||
CODE="$(curl -s -m 10 -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo 000)"
|
||||
if [ "$CODE" = "200" ]; then
|
||||
printf '%s✓ سرویس سالم بالا آمد.%s\n' "$C_OK" "$C_OFF"
|
||||
printf '%s\n' "$BODY" | python -m json.tool 2>/dev/null | sed 's/^/ /' || printf ' %s\n' "$BODY"
|
||||
exit 0
|
||||
fi
|
||||
sleep 3
|
||||
done
|
||||
|
||||
printf '%s✖ سرویس بعد از ۳۰ ثانیه هنوز سالم نیست (آخرین کد: %s)%s\n' "$C_ERR" "$CODE" "$C_OFF" >&2
|
||||
printf '%s\n' "$BODY" | head -c 600 >&2; echo >&2
|
||||
cat >&2 <<'HINT'
|
||||
|
||||
لاگ را ببینید:
|
||||
sudo journalctl -u <نامِ سرویس> -n 60 --no-pager
|
||||
رایجترین علتها:
|
||||
ModuleNotFoundError → وابستگیِ جدید نصب نشده (این اسکریپت باید حلش میکرد)
|
||||
ImproperlyConfigured → متغیرِ محیطیِ لازم ست نیست (SECRET_KEY / CACHE_URL)
|
||||
502 از nginx → پروسهی جنگو اصلاً بالا نیامده
|
||||
HINT
|
||||
exit 1
|
||||
Reference in New Issue
Block a user