#!/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; } # اگر TADRISINO_HEALTH_URL ست نباشد چند آدرس امتحان می‌شود: پورتِ محلی ممکن # است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ # خطا می‌گرفت. آدرسِ عمومی نشان می‌دهد سرویس واقعاً از بیرون جواب می‌دهد. if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then HEALTH_URLS=("$TADRISINO_HEALTH_URL") else HEALTH_URLS=( "http://127.0.0.1:8000/api/health" "https://api.math.tadrisino2.ir/api/health" ) fi # ─── ۱. کدِ تازه ──────────────────────────────────────────────────────────── 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 # set -u موقتاً خاموش: اسکریپت‌های activate قدیمی به PS1 بدونِ محافظ دست # می‌زنند و زیرِ set -u با «unbound variable» کلِ دیپلوی را می‌خوابانند. set +u # shellcheck disable=SC1091 source venv/bin/activate set -u 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" # ─── ۳.۵ تنظیماتِ مؤثر ───────────────────────────────────────────────────── # اینجا عمداً چیزی را اجباری نمی‌کنیم: تنظیماتِ پروژه خودش پیش‌فرضِ سالم دارد و # با فایلِ خالی هم بالا می‌آید. تکرارِ آن منطق در این اسکریپت یعنی دو جا که # باید هم‌زمان به‌روز بمانند — و همان باعث می‌شد دیپلویِ درست هم رد شود. # پس فقط از خودِ جنگو می‌پرسیم چه چیزی واقعاً فعال است (شاملِ مقادیرِ .env). step "تنظیماتِ مؤثر" python - <<'PYCHECK' || fail "تنظیمات اصلاً بار نشد؛ هیچ تغییری اعمال نشد." import os import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') django.setup() from django.conf import settings print(f' DEBUG : {settings.DEBUG}') print(f' ALLOWED_HOSTS : {", ".join(settings.ALLOWED_HOSTS) or "—"}') print(f' CORS مجاز : {", ".join(settings.CORS_ALLOWED_ORIGINS) or "—"}') print(f' کش : {settings.CACHES["default"]["BACKEND"].rsplit(".", 1)[-1]}') if os.environ.get('DJANGO_SECRET_KEY'): key_source = 'از متغیرِ محیطی' elif settings.DEBUG: key_source = 'کلیدِ ثابتِ حالتِ توسعه (در پروداکشن استفاده نمی‌شود)' else: key_source = 'تولیدشده و ذخیره‌شده در .django_secret_key' print(f' کلیدِ امضا : {key_source}') if settings.DEBUG: print() print(' ⚠ DEBUG روشن است. روی سرورِ عمومی یعنی:') print(' • صفحه‌های خطا کلِ تنظیمات و traceback را نشان می‌دهند') print(' • کدِ ورودِ 0000 برای هر شماره‌ای پذیرفته می‌شود') if 'LocMem' in settings.CACHES['default']['BACKEND'] and not settings.DEBUG: print(' ⚠ بدونِ Redis محدودیتِ نرخ بینِ workerها مشترک نیست (CACHE_URL).') PYCHECK # ─── ۴. دیتابیس ──────────────────────────────────────────────────────────── step "اعمالِ مهاجرت‌های دیتابیس" python manage.py migrate --noinput || fail "migrate شکست خورد؛ سرویس ری‌استارت نشد." # محتوا هم مثلِ کد از ریپو می‌آید (content/seed/). بدونِ این، یک سرورِ تازه # دیتابیسِ خالی دارد و `/api/grades` جوابِ `{"grades": []}` می‌دهد — یعنی هر # صفحه‌ی اپ خالی بالا می‌آید و به‌نظر خراب می‌رسد. # # ایدمپوتنت و بدونِ حذف است، پس اجرای هر باره بی‌خطر است. شکستش دیپلوی را # نمی‌خواباند: کدِ تازه بهتر از کدِ قدیمی است حتی اگر سید نخورده باشد. step "محتوای پایه" python manage.py seed_content 2>&1 | sed 's/^/ /' \ || warn "seed_content شکست خورد؛ کد به‌روز شد ولی محتوا دست‌نخورده ماند." # ─── ۵. فایل‌های استاتیک ─────────────────────────────────────────────────── # بی‌قید و شرط اجرا می‌شود، حتی وقتی DEBUG روشن است. # # قبلاً این مرحله وقتی DEBUG=1 بود رد می‌شد، چون در آن حالت whitenoise از # finders می‌خواند و جمع‌آوری لازم نبود. ولی این یعنی درستیِ دیپلوی به # ترتیبِ کارهای یک آدم گره می‌خورد: اگر کسی با DEBUG روشن دیپلوی کند و بعد # DEBUG را خاموش کند، جنگو دنبالِ فایل‌هایی می‌گردد که هیچ‌وقت جمع نشدند و # پنلِ ادمین بدونِ CSS بالا می‌آید — بدونِ هیچ خطایی در لاگ. دقیقاً همین # روی سرور اتفاق افتاد. # # هزینه‌اش چند ثانیه است و idempotent هم هست، پس همیشه اجرا می‌شود. step "جمع‌آوریِ فایل‌های استاتیک" python manage.py collectstatic --noinput >/dev/null \ || warn "collectstatic شکست خورد؛ پنلِ ادمین ممکن است بدونِ CSS بالا بیاید." printf ' انجام شد.\n' # ─── ۶. بررسی‌های پیش از راه‌اندازی ──────────────────────────────────────── 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 # `systemctl cat` تنها بررسیِ قابلِ اتکاست: list-unit-files حتی وقتی چیزی # پیدا نکند هم کدِ ۰ برمی‌گرداند. for candidate in tadrisino tadrisino-api mathapp gunicorn; do if systemctl cat "${candidate}.service" >/dev/null 2>&1; then SERVICE="$candidate"; break fi done fi if [ -n "$SERVICE" ]; then # شکستِ ری‌استارت نباید اسکریپت را بی‌صدا تمام کند؛ بررسیِ سلامتِ پایین # همچنان باید اجرا شود تا معلوم شود سرویسِ قبلی هنوز بالاست یا نه. if sudo systemctl restart "$SERVICE"; then printf ' سرویسِ %s ری‌استارت شد\n' "$SERVICE" else warn "ری‌استارتِ $SERVICE شکست خورد (شاید sudo رمز می‌خواهد). خودتان اجرا کنید: sudo systemctl restart $SERVICE" fi else warn "سرویسِ systemd پیدا نشد؛ خودتان ری‌استارت کنید، مثلاً: sudo systemctl restart <نامِ سرویس> و برای اینکه دفعه‌ی بعد خودکار شود: TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh" fi # ─── ۸. آیا واقعاً بالا آمد؟ ─────────────────────────────────────────────── step "بررسیِ سلامت" CODE=000; BODY="" for attempt in 1 2 3 4 5 6 7 8 9 10; do for url in "${HEALTH_URLS[@]}"; do # یک درخواست، نه دو تا: دو فراخوانیِ جدا می‌توانست دو وضعیتِ متفاوت ببیند. RESPONSE="$(curl -s -m 10 -w '\n%{http_code}' "$url" 2>/dev/null || printf '\n000')" CODE="${RESPONSE##*$'\n'}" BODY="${RESPONSE%$'\n'*}" if [ "$CODE" = "200" ]; then printf '%s✓ سرویس سالم بالا آمد%s (%s)\n' "$C_OK" "$C_OFF" "$url" printf '%s\n' "$BODY" | python -m json.tool 2>/dev/null | sed 's/^/ /' || printf ' %s\n' "$BODY" exit 0 fi done sleep 3 done printf ' آدرس‌های امتحان‌شده: %s\n' "${HEALTH_URLS[*]}" >&2 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