دو حالتی که فقط روی سرورِ واقعی پیش میآیند، نه اینجا. اسکریپتهای قدیمیِ activate به PS1 بدونِ محافظ دست میزنند و زیرِ set -u با «unbound variable» کلِ دیپلوی را میخوابانند — venv اینجا سالم است ولی venv سرور لزوماً نه، پس set -u دورِ همان یک خط موقتاً خاموش میشود. بررسیِ سلامت فقط 127.0.0.1:8000 را میزد. اگر gunicorn روی سوکت یا پورتِ دیگری باشد، دیپلویِ کاملاً موفق هم با پیامِ خطا تمام میشد. حالا آدرسِ عمومی هم امتحان میشود و در پایان میگوید کدام آدرسها را زده. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
11 KiB
Bash
Executable File
198 lines
11 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 شکست خورد؛ سرویس ریاستارت نشد."
|
||
|
||
# ─── ۵. فایلهای استاتیک ───────────────────────────────────────────────────
|
||
# فقط در حالتِ پروداکشن لازم است: با 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
|