diff --git a/README.md b/README.md index 65ba090..92cc8b1 100644 --- a/README.md +++ b/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:///api/health +``` + +بدونِ احرازِ هویت و بدونِ SSH می‌گوید دیتابیس سالم است یا نه، مهاجرتِ +اعمال‌نشده مانده یا نه، کش کار می‌کند یا نه، و چند پایه/درس/سوال روی آن سرور +هست. `200` یعنی سالم، `503` یعنی چیزی واقعاً خراب است. هیچ سکرتی برنمی‌گرداند. + +این همان چیزی‌ست که جلوی «کد رفته ولی مهاجرت اجرا نشده» را می‌گیرد — حالتی که +اپ ظاهراً بالا می‌آید و بعد زیرِ بارِ واقعی می‌شکند. + +### مدیریتِ محتوا بدونِ ترمینال + +سوال‌ها از پنلِ ادمین وارد و خارج می‌شوند، نه با دستور روی سرور: + +- `/admin/content/question/` → دکمه‌ی **«وارد کردنِ سوال‌ها از فایل»** +- برای گرفتنِ نمونه: چند سوال را انتخاب کنید → اکشنِ **«دریافتِ فایلِ سوال‌های + انتخاب‌شده»**. خروجی دقیقاً همان قالبی‌ست که ورودی می‌پذیرد. + +فایلِ نامعتبر **هیچ چیزی نمی‌نویسد** — یا همه‌ی سوال‌ها وارد می‌شوند یا هیچ‌کدام، +و خطا می‌گوید کدام ردیف ایراد دارد. + + ### متغیرهای محیطی موردنیاز | متغیر | لازم در پروداکشن | توضیح | |---|---|---| diff --git a/config/health.py b/config/health.py new file mode 100644 index 0000000..bbbaae0 --- /dev/null +++ b/config/health.py @@ -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) diff --git a/config/settings.py b/config/settings.py index 479d366..405f1ac 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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'), }, } diff --git a/config/urls.py b/config/urls.py index 4728de9..a9ee445 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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'), diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..9b674a3 --- /dev/null +++ b/deploy.sh @@ -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