A domain change is coming (the Zarinpal terminal rejects the current callback domain). ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS defaults, the Bazaar redirect URI and deploy.sh's public health URL each spelled the domain out. They now derive from PUBLIC_BASE_URL and FRONTEND_URL, which keep today's domain as their default; env values for the three lists are still added on top, so the old domain can stay allowed while DNS moves. ZARINPAL_CALLBACK_URL used os.environ.get(key, default), so an empty key in .env produced an empty callback and every payment would fail closed. It now uses the same `.strip() or` fallback as its neighbours; the new test caught it. config/tests_domains.py loads settings in a subprocess with a new domain and fails if any derived value still mentions the old one. docs/operations.md gains the step-by-step domain move (DNS, .env, Zarinpal/Bazaar panels, frontend config.js, verification). CLAUDE.md records that the frontend is now ours and where the rebuild stands. 624 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
20 KiB
Bash
Executable File
306 lines
20 KiB
Bash
Executable File
#!/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
|
||
|
||
# ⚠️ اینجا عمداً `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
|
||
|
||
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
|
||
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
|