#!/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 # یک آدرسِ سلامت را می‌زند و CODE و BODY را می‌گذارد. آدرسِ `unix:/مسیر` یعنی مستقیم از سوکتِ # gunicorn (با هدرِ Host دامنه‌ی خودمان): از داخلِ سرور آدرسِ عمومی از مسیرِ ArvanCloud برمی‌گردد و # ۲۰۲۶-۱۰-۰۷ ۵۰۴/۰۰۰ می‌داد، پس «سلامتِ» اسکریپت همیشه شکست می‌خورد حتی وقتی سرویس سالم بود. probe_health() { local target="$1" RESPONSE if [[ "$target" == unix:* ]]; then RESPONSE="$(curl -s -m 10 -w '\n%{http_code}' --unix-socket "${target#unix:}" \ -H "Host: ${PUBLIC_HOST:-localhost}" http://localhost/api/health 2>/dev/null || printf '\n000')" else RESPONSE="$(curl -s -m 10 -w '\n%{http_code}' "$target" 2>/dev/null || printf '\n000')" fi CODE="${RESPONSE##*$'\n'}" BODY="${RESPONSE%$'\n'*}" } # ─── ۱. کدِ تازه ──────────────────────────────────────────────────────────── 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 # DEPLOY_BUNDLE=/مسیر/فایل.bundle: کد را از یک git bundle می‌گیرد، نه از gitea. فقط وقتی صریحاً # داده شود؛ برای روزی که سرور به gitea نمی‌رسد (۲۰۲۶-۱۰-۰۷: از سرور ۵۰۴ از مسیرِ ArvanCloud، از بیرون # ۲۰۰). bundle را از جایی بسازید که به origin/ دسترسی دارد و هشِ کامیتِ چاپ‌شده را با آنجا # مقایسه کنید. بدونِ این متغیر هیچ جایگزینی نیست و نرسیدن به gitea همچنان دیپلو را می‌ایستاند. if [ -n "${DEPLOY_BUNDLE:-}" ]; then [ -f "$DEPLOY_BUNDLE" ] || fail "DEPLOY_BUNDLE=$DEPLOY_BUNDLE وجود ندارد." warn "کد از bundle گرفته می‌شود، نه از gitea: $DEPLOY_BUNDLE" git bundle verify "$DEPLOY_BUNDLE" >/dev/null 2>&1 || fail "bundle معتبر نیست یا کامیتِ پایه‌اش روی این سرور نیست." git fetch "$DEPLOY_BUNDLE" "$BRANCH:refs/remotes/origin/$BRANCH" \ || fail "گرفتن از bundle نشد." else git fetch origin "$BRANCH" \ || fail "به gitea وصل نشد. شبکه یا دسترسیِ مخزن را بررسی کنید (اگر فقط از این سرور نمی‌رسد: DEPLOY_BUNDLE، بالای همین فایل)." fi printf ' origin/%s = %s\n' "$BRANCH" "$(git rev-parse --short "origin/$BRANCH")" # اگر کسی روی سرور فایلی را دستی عوض کرده، اول نسخه‌ی پشتیبان، بعد کنار # گذاشتنش. # # چرا خودکار و نه توقف: قبلاً اینجا `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 # ─── ۳.۶ مجوزِ فایل‌های داده ─────────────────────────────────────────────── # این ماشین بینِ چند تیم مشترک است و دیتابیس شماره‌ها و گفتگوهای کاربران را دارد؛ پس پوشه‌ی # پروژه برای others بسته، دیتابیس 600 و بکاپ‌ها 700/600. در هر انتشار دوباره اعمال می‌شود، چون # بکاپِ دستیِ تازه با 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)" # ─── ۳.۷ بکاپِ دیتابیس پیش از migrate ────────────────────────────────────── # migrate تنها مرحله‌ای است که داده را برنمی‌گشت‌پذیر عوض می‌کند، پس درست پیش از آن بکاپ # می‌گیریم؛ تا اینجا بکاپ دستی بود و در یک انتشارِ بی‌بکاپ هیچ راهی به عقب نبود. # # با sqlite3.Connection.backup() نه cp: کپیِ ساده وسطِ یک نوشتن فایلِ نیمه‌کاره می‌دهد (sqlite3 # به‌عنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمی‌شود: دیپلوی بی‌بکاپ را # عمداً نمی‌پذیریم. فقط ۲ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و یکی قبل‌تر) نگه داشته می‌شود تا دیسکِ # مشترک پر نشود؛ بکاپ‌های دستی با نامِ دیگر دست نمی‌خورند. # آزموده‌نشده: Postgres (بعد از مهاجرت باید pg_dump جایگزین شود؛ اینجا فقط هشدار می‌دهد)، # و بکاپ از بیرونِ ماشین که هنوز نیست (بند ۱۷ در چک‌لیستِ لانچ). step "بکاپِ دیتابیس" python - <<'PYBACKUP' || fail "بکاپِ دیتابیس نشد؛ migrate اجرا نشد و هیچ تغییری روی داده اعمال نشد." import datetime import glob import os import shutil import sqlite3 import django os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') django.setup() from django.conf import settings db = settings.DATABASES['default'] if db['ENGINE'] != 'django.db.backends.sqlite3': print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite را پوشش می‌دهد. خودتان بکاپ بگیرید.") raise SystemExit(0) src_path = str(db['NAME']) if not os.path.exists(src_path): print(' دیتابیس هنوز وجود ندارد (نصبِ تازه)؛ چیزی برای بکاپ نیست.') raise SystemExit(0) os.makedirs('.deploy-backups', mode=0o700, exist_ok=True) need = os.path.getsize(src_path) * 2 free = shutil.disk_usage('.deploy-backups').free if free < need: raise SystemExit(f' فضای خالی ({free // 2**20} MB) کمتر از دو برابرِ دیتابیس ({need // 2**20} MB) است.') dst = '.deploy-backups/db-before-deploy-%s.sqlite3' % datetime.datetime.now().strftime('%Y%m%d-%H%M%S') src, out = sqlite3.connect(src_path), sqlite3.connect(dst) with out: src.backup(out) src.close() out.close() os.chmod(dst, 0o600) check = sqlite3.connect(dst) result = check.execute('PRAGMA integrity_check').fetchone()[0] check.close() if result != 'ok': raise SystemExit(f' بکاپ ساخته شد ولی integrity_check «{result}» داد: {dst}') print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، integrity_check: ok)') old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.sqlite3'))[:-2] for path in old: os.remove(path) if old: print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۲ تای آخر می‌ماند).') PYBACKUP # ─── ۴. دیتابیس ──────────────────────────────────────────────────────────── 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 "بررسیِ سلامت" PUBLIC_URL="" 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 PUBLIC_HOST="${PUBLIC_URL#*://}"; PUBLIC_HOST="${PUBLIC_HOST%%/*}" # سوکتِ gunicorn را از خودِ یونیت می‌خوانیم تا مسیرش در این فایل تکرار نشود. SOCK="" if [ -n "${SERVICE:-}" ] && command -v systemctl >/dev/null 2>&1; then SOCK="$(systemctl cat "${SERVICE}.service" 2>/dev/null | grep -oE 'unix:[^ "]+' | head -n 1 || true)" fi [ -n "$SOCK" ] && [ -S "${SOCK#unix:}" ] && HEALTH_URLS=("$SOCK" "${HEALTH_URLS[@]}") 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 probe_health "$url" 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