#!/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 ست نباشد چند آدرس امتحان می‌شود: پورتِ محلی ممکن # است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ # خطا می‌گرفت. آدرسِ عمومی نشان می‌دهد سرویس واقعاً از بیرون جواب می‌دهد؛ آن # را پایین‌تر، بعد از فعال شدنِ venv، از خودِ تنظیماتِ جنگو (PUBLIC_BASE_URL) # می‌خوانیم تا با عوض شدنِ دامنه این فایل دست نخورد. if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then HEALTH_URLS=("$TADRISINO_HEALTH_URL") else HEALTH_URLS=( "http://127.0.0.1:8000/api/health" "http://127.0.0.1:8000/api/health/" ) fi # ─── ۱. کدِ تازه ──────────────────────────────────────────────────────────── if [ "${SKIP_PULL:-0}" != "1" ]; then step "گرفتنِ کدِ تازه" # امضای خودِ این فایل قبل از به‌روزرسانی. توضیحش پایین‌تر. SELF_BEFORE="$(cksum "$0" 2>/dev/null || true)" # شاخه‌ی فعلی؛ اگر HEAD جدا افتاده باشد main مبنا می‌شود. BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)" [ "$BRANCH" = "HEAD" ] && BRANCH=main git fetch origin "$BRANCH" \ || fail "به gitea وصل نشد. شبکه یا دسترسیِ مخزن را بررسی کنید." # اگر کسی روی سرور فایلی را دستی عوض کرده، اول نسخه‌ی پشتیبان، بعد کنار # گذاشتنش. # # چرا خودکار و نه توقف: قبلاً اینجا `git pull --ff-only` بود و با هر تغییرِ # دستی کلِ دیپلوی همان‌جا می‌ایستاد — و چون بقیه‌ی مراحل اجرا نمی‌شد، سرور # با کدِ قدیمی می‌ماند بدونِ اینکه کسی بفهمد. دقیقاً همین یک بار دو روز طول # کشید. حالا تغییرِ دستی مانعِ استقرار نیست؛ ذخیره می‌شود و رد می‌شویم. if ! git diff --quiet HEAD 2>/dev/null; then mkdir -p .deploy-backups PATCH=".deploy-backups/local-changes-$(date +%Y%m%d-%H%M%S).patch" git diff HEAD > "$PATCH" 2>/dev/null || true warn "روی سرور تغییرِ دستی پیدا شد. در $PATCH ذخیره شد و کنار گذاشته می‌شود. برای دیدنش: cat $PATCH اگر لازم بود: git apply $PATCH" fi # کامیتِ محلی روی سرور (جلوتر از origin) را هم reset --hard بی‌صدا دور می‌ریخت؛ تا # ۲۰۲۶-۱۰-۰۳ فقط تغییرِ کامیت‌نشده ذخیره می‌شد، برخلافِ آن‌چه CLAUDE.md می‌گفت. if [ -n "$(git log --oneline "origin/$BRANCH..HEAD" 2>/dev/null)" ]; then mkdir -p .deploy-backups COMMITS=".deploy-backups/local-commits-$(date +%Y%m%d-%H%M%S).patch" git format-patch --stdout "origin/$BRANCH..HEAD" > "$COMMITS" 2>/dev/null || true warn "روی سرور کامیتِ محلی بود. در $COMMITS ذخیره شد و کنار گذاشته می‌شود. اگر لازم بود: git am $COMMITS" fi # ⚠️ اینجا عمداً `git clean` زده نمی‌شود و هرگز هم نباید زده شود: # db.sqlite3 و .env و .django_secret_key هیچ‌کدام در گیت نیستند و داخلِ همین # پوشه‌اند. یک `git clean -fd` یعنی نابودیِ دیتابیس، تنظیمات، و کلیدی که # همه‌ی نشست‌های ورود با آن امضا شده‌اند. reset --hard فقط فایل‌های ردیابی‌شده # را برمی‌گرداند و به این‌ها کار ندارد. git reset --hard "origin/$BRANCH" \ || fail "برگرداندن به origin/$BRANCH نشد؛ هیچ تغییری اعمال نشد." # اگر همین اسکریپت در pull عوض شد، نسخه‌ی تازه از اول اجرا می‌شود. # # چرا لازم است: bash اسکریپت را یک‌جا نمی‌خواند، حین اجرا از روی دیسک ادامه # می‌دهد. پس وقتی pull همین فایل را زیر پای خودش عوض می‌کند، ادامه‌ی اجرا # مخلوطی از نسخه‌ی قدیم و جدید می‌شود. یک بار همین اتفاق افتاد: نسخه‌ی قدیمی # که هنوز در حال اجرا بود سرویسِ تیمِ دیگری را ری‌استارت کرد، در حالی که # نسخه‌ی اصلاح‌شده همان لحظه روی دیسک نشسته بود. # # DEPLOY_REEXEC جلوی حلقه‌ی بی‌پایان را می‌گیرد: نسخه‌ی تازه دیگر خودش را # صدا نمی‌زند چون بعد از pullِ دوم دیگر چیزی عوض نمی‌شود. if [ "${DEPLOY_REEXEC:-0}" != "1" ] \ && [ -n "$SELF_BEFORE" ] \ && [ "$SELF_BEFORE" != "$(cksum "$0" 2>/dev/null || true)" ]; then printf ' این اسکریپت خودش به‌روز شد؛ نسخه‌ی تازه از اول اجرا می‌شود.\n' DEPLOY_REEXEC=1 SKIP_PULL=1 exec "$0" "$@" fi 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 # ─── ۳.۶ مجوزِ فایل‌های داده ─────────────────────────────────────────────── # این ماشین بینِ چند تیم مشترک است. تا ۲۰۲۶-۱۰-۰۴ دیتابیس و همه‌ی بکاپ‌ها 0644 بودند و # پوشه‌ها قابلِ پیمایش؛ کاربرِ nobody روی سرور توانست سرِ db.sqlite3 و یک بکاپ را بخواند # (شماره‌ها، refresh tokenهای بی‌هش، گفتگوها). در هر انتشار دوباره بسته می‌شود، چون بکاپِ # دستیِ تازه دوباره با umaskِ پیش‌فرض (0644) ساخته می‌شود. nginx فقط به سوکتِ /run وصل # می‌شود و از این پوشه چیزی نمی‌خواند (sites-enabled/mathapp، همان روز خوانده شد)؛ اگر # سرویسِ دیگری این دیتابیس را مستقیم می‌خواند، از این به بعد خطای دسترسی می‌گیرد. step "مجوزِ فایل‌های داده" chmod o-rwx . || warn "مجوزِ پوشه‌ی پروژه عوض نشد." for f in db.sqlite3 db.sqlite3-wal db.sqlite3-shm db.sqlite3-journal; do if [ -e "$f" ]; then chmod 600 "$f"; fi done if [ -d .deploy-backups ]; then chmod 700 .deploy-backups find .deploy-backups -type f -exec chmod 600 {} + fi printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌اند.\n' "$(id -un)" # ─── ۴. دیتابیس ──────────────────────────────────────────────────────────── 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 شکست خورد؛ کد به‌روز شد ولی محتوا دست‌نخورده ماند." # جدولِ لیدربورد کاملاً مشتق‌شده است و تا پیش از این هیچ تولیدکننده‌ای نداشت — # یعنی /api/leaderboard روی سرورِ واقعی همیشه `entries: []` می‌داد. این مرحله # آن را در هر ریلیز از روی XPی واقعی از نو می‌سازد. # # ⚠️ این «تازه‌سازیِ هر ریلیز» است، نه یک زمان‌بندِ واقعی: بینِ دو دیپلوی، # رتبه‌ها ثابت می‌مانند. وقتی ترافیک واقعی شد، همین دستور باید در یک cron # (مثلاً هر ساعت) بنشیند. # # ایدمپوتنت است و شکستش دیپلوی را نمی‌خواباند. step "بازسازیِ لیدربورد" python manage.py rebuild_leaderboard 2>&1 | sed 's/^/ /' \ || warn "rebuild_leaderboard شکست خورد؛ رتبه‌ها از اجرای قبلی مانده‌اند." # ─── ۵. فایل‌های استاتیک ─────────────────────────────────────────────────── # بی‌قید و شرط اجرا می‌شود، حتی وقتی 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 حتی وقتی چیزی # پیدا نکند هم کدِ ۰ برمی‌گرداند. # عمداً نامِ عامِ `gunicorn` در این فهرست نیست. روی سرورِ واقعی چند اپِ # مختلف کنارِ هم هستند و یک `gunicorn.service` وجود دارد که مالِ تیمِ # دیگری است — حدس زدنِ آن یعنی ری‌استارت کردنِ سرویسِ یک نفرِ دیگر. اگر # نامِ سرویس در این فهرست نبود، TADRISINO_SERVICE را صریح بدهید. for candidate in zareian-mathapp gunicorn-mathapp tadrisino tadrisino-api; do if systemctl cat "${candidate}.service" >/dev/null 2>&1; then SERVICE="$candidate"; break fi done fi RESTART_OK=0 if [ -n "$SERVICE" ]; then # زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه می‌شود. # # چرا: قبلاً فقط خروجیِ `systemctl restart` نگاه می‌شد و بعد سلامت از روی # آدرسِ عمومی چک می‌شد. ولی وقتی ری‌استارت شکست می‌خورد، پروسه‌ی *قدیمی* # هنوز بالا بود و همان ۲۰۰ را می‌داد — یعنی اسکریپت «✓ سالم» می‌گفت در # حالی که کدِ جدید اصلاً بار نشده بود. روی سرور دو روز همین‌طور ماند: کد و # محتوا و فایل‌های استاتیک روی دیسک بودند و هیچ‌کدام سرو نمی‌شدند. BEFORE="$(systemctl show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)" if sudo systemctl restart "$SERVICE"; then AFTER="$(systemctl show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)" if [ -z "$BEFORE" ] || [ -z "$AFTER" ]; then # نتوانستیم ثابت کنیم ری‌استارت شده. «نمی‌دانم» را موفقیت حساب نمی‌کنیم — # همین اشتباه بود که دو روز پنهان ماند. warn "زمانِ بالا آمدنِ $SERVICE خوانده نشد، پس ری‌استارت تأیید نشد. خودتان بررسی کنید: systemctl status $SERVICE" elif [ "$BEFORE" = "$AFTER" ]; then warn "دستورِ ری‌استارت خطا نداد ولی سرویس بالا نیامد (هنوز از $BEFORE در حال اجراست). یعنی کدِ جدید بار نشده. لاگ: sudo journalctl -u $SERVICE -n 50 --no-pager" else RESTART_OK=1 printf ' سرویسِ %s ری‌استارت شد\n' "$SERVICE" fi else warn "ری‌استارتِ $SERVICE شکست خورد (شاید sudo رمز می‌خواهد). خودتان اجرا کنید: sudo systemctl restart $SERVICE" fi else warn "سرویسِ systemd پیدا نشد؛ خودتان ری‌استارت کنید، مثلاً: sudo systemctl restart <نامِ سرویس> و برای اینکه دفعه‌ی بعد خودکار شود: TADRISINO_SERVICE=<نامِ سرویس> ./deploy.sh" fi # ─── ۸. آیا واقعاً بالا آمد؟ ─────────────────────────────────────────────── step "بررسیِ سلامت" if [ -z "${TADRISINO_HEALTH_URL:-}" ]; then PUBLIC_URL="$(python -c 'import django, os; os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings"); django.setup(); from django.conf import settings; print(settings.PUBLIC_BASE_URL)' 2>/dev/null | tail -n 1)" if [ -n "$PUBLIC_URL" ]; then HEALTH_URLS+=("$PUBLIC_URL/api/health" "$PUBLIC_URL/api/health/") fi fi 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" # ۲۰۰ گرفتن به‌تنهایی یعنی «چیزی جواب می‌دهد»، نه «کدِ جدید بالاست». if [ "$RESTART_OK" != "1" ]; then warn "ولی سرویس ری‌استارت نشد — این جوابْ از پروسه‌ی قدیمی است و کدِ جدید هنوز سرو نمی‌شود. تا ری‌استارت نکنید دیپلوی ناتمام است." exit 1 fi 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