پنلِ ادمین روی سرور بدونِ CSS بالا آمد و هیچ خطایی هم در لاگ نبود. علت ترتیبِ کارها بود: این مرحله وقتی DEBUG روشن بود رد میشد، چون در آن حالت whitenoise از finders میخواند و جمعآوری لازم نیست. کسی با DEBUG روشن دیپلوی کرد و بعد DEBUG را خاموش کرد — کارِ درستی هم بود — و از آن لحظه جنگو دنبالِ فایلهایی میگشت که هیچوقت جمع نشده بودند. یعنی درستیِ دیپلوی به ترتیبِ کارِ یک آدم گره خورده بود. چند ثانیه صرفهجویی، به قیمتِ شکستی که فقط در مرورگر دیده میشود. حالا بیقید و شرط اجرا میشود؛ idempotent است و در هر دو حالت درست کار میکند. staticfiles/ هم به gitignore اضافه شد: حالا که این مرحله همیشه اجرا میشود، روی ماشینِ هر توسعهدهندهای ساخته میشود و ۴٫۴ مگابایت فایلِ تولیدی نباید با یک `git add .` واردِ مخزن شود. بررسی شد که با DEBUG=False کلِ مسیر کار میکند: استاتیک ۲۰۰، صفحهی ادمین ۲۰۰، API ۲۰۰، و ورود با OTP از پنل. کدِ 0000 همانطور که باید، رد میشود. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
207 lines
12 KiB
Bash
Executable File
207 lines
12 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 ست نباشد چند آدرس امتحان میشود: پورتِ محلی ممکن
|
||
# است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ
|
||
# خطا میگرفت. آدرسِ عمومی نشان میدهد سرویس واقعاً از بیرون جواب میدهد.
|
||
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
|