bash اسکریپت را یکجا نمیخواند؛ حین اجرا از روی دیسک ادامه میدهد. پس وقتی `git pull` همین فایل را عوض میکند، ادامهی اجرا مخلوطی از نسخهی قدیم و جدید است. یک بار همین اتفاق افتاد و گران تمام شد: نسخهی قدیمی که در حال اجرا بود سرویسِ تیمِ دیگری را ریاستارت کرد، در حالی که نسخهی اصلاحشده همان لحظه روی دیسک نشسته بود. حالا امضای فایل قبل و بعدِ pull مقایسه میشود و اگر عوض شده باشد، نسخهی تازه از اول اجرا میشود (با SKIP_PULL=1 و یک نگهبان در برابرِ حلقهی بیپایان). و یک حفرهی کوچکترِ همخانواده: اگر زمانِ بالا آمدنِ سرویس اصلاً خوانده نشود، دیگر موفقیت فرض نمیشود. «نمیدانم» همان چیزی بود که دو روز پنهانش کرد. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
16 KiB
Bash
Executable File
257 lines
16 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"
|
||
"http://127.0.0.1:8000/api/health/"
|
||
"https://api.math.tadrisino2.ir/api/health"
|
||
"https://api.math.tadrisino2.ir/api/health/"
|
||
)
|
||
fi
|
||
|
||
# ─── ۱. کدِ تازه ────────────────────────────────────────────────────────────
|
||
if [ "${SKIP_PULL:-0}" != "1" ]; then
|
||
step "گرفتنِ کدِ تازه"
|
||
# امضای خودِ این فایل قبل از pull. توضیحش پایینتر.
|
||
SELF_BEFORE="$(cksum "$0" 2>/dev/null || true)"
|
||
git pull --ff-only || fail "git pull نشد. اگر تغییرِ محلی دارید اول تعیین تکلیفش کنید."
|
||
|
||
# اگر همین اسکریپت در 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 شکست خورد؛ کد بهروز شد ولی محتوا دستنخورده ماند."
|
||
|
||
# ─── ۵. فایلهای استاتیک ───────────────────────────────────────────────────
|
||
# بیقید و شرط اجرا میشود، حتی وقتی 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 "بررسیِ سلامت"
|
||
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
|