Files
MathApp/deploy.sh
Zareian 9c3db68cbb carry the curriculum in the repo so a fresh server is not empty
سرورِ production از روزِ اول `{"grades": []}` برگردانده: دیتابیس gitignore است
(درست هم هست) و تنها راهِ پر کردنش اجرای `rag_import_collection` و بعد
`seed_curriculum_from_textbook` روی همان سرور بود — یعنی وابستگی به Qdrant، به
اعتبارِ AI که تمام شده، و به کسی که SSH دارد. نتیجه این شد که هیچ‌کس نتوانست
چیزی را تست کند.

حالا برنامه‌ی درسی در `content/seed/curriculum.json` کامیت می‌شود (۴ پایه، ۳۱
فصل، ۱۶۸ مبحث، ۱۳۰۰ درس، ۵۲۷ کیلوبایت متن) و `deploy.sh` خودش بالا می‌آوردش.
سرورِ تازه نه Qdrant می‌خواهد نه ترمینال.

`db.sqlite3` عمداً کامیت نمی‌شود: باینری است، دیف نمی‌شود، و هر دیپلوی محتوای
سرور — و فردا پاسخ‌های کاربران — را با نسخه‌ی لوکالِ یک نفر جایگزین می‌کند.

سوال‌ها با مسیرِ عنوان ذخیره می‌شوند نه با lessonId. این تصمیمِ اصلیِ کامیت
است: لوکالِ ما ۱۳۰۷ درس دارد با idهای ۳ تا ۲۰۰۵ (جای خالی، از پاک شدنِ دیتای
دمو)، و همان کوریکولوم روی دیتابیسِ خالی idهای ۱ تا ۱۳۰۷ می‌گیرد. با id،
سوالِ درسِ ۳ روی سرور به درسِ بی‌ربطی می‌چسبید — بدونِ هیچ خطایی. تست
`test_question_follows_the_title_path_not_the_id` همین را نگه می‌دارد.

`seed_content` ایدمپوتنت است و هیچ‌چیز پاک نمی‌کند: کوریکولوم با عنوان
هم‌سان‌سازی می‌شود و سوالِ تکراری رد می‌شود (`skip_existing`). روی سروری که
کاربرِ واقعی دارد، دیپلوی نباید بتواند محتوا یا پاسخی را از بین ببرد — و
ادمین باید بتواند سوالِ سیدشده را اصلاح کند بی‌آنکه دیپلویِ بعدی برش گرداند.

ردیف‌های `[DEMO]`/`[SEED]` موقعِ export کنار گذاشته می‌شوند؛ آن‌ها همان‌هایی‌اند
که `purge_demo_data` برای پاک کردنشان نوشته شد و جایشان در فایلی که مستقیم روی
production لود می‌شود نیست.

هنوز صفر سوالِ واقعی داریم: هر ۱۴ سوالِ لوکال رویِ ردیف‌های دمو بودند. مسیرِ
سوال‌ها ساخته و تست شده، ولی تا وقتی اعتبارِ AI شارژ نشود فایلش خالی می‌ماند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 20:20:26 +03:30

208 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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=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
# `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