Files
MathApp/deploy.sh

466 lines
31 KiB
Bash
Executable File
Raw Permalink 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 ست نباشد چند آدرس امتحان می‌شود: پورتِ محلی ممکن
# است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ
# خطا می‌گرفت. آدرسِ عمومی نشان می‌دهد سرویس واقعاً از بیرون جواب می‌دهد؛ آن
# را پایین‌تر، بعد از فعال شدنِ 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/<branch> دسترسی دارد و هشِ کامیتِ چاپ‌شده را با آنجا
# مقایسه کنید. بدونِ این متغیر هیچ جایگزینی نیست و نرسیدن به 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
# فقط فایل‌های خودمان: بکاپی که با sudo ساخته شده (مثلاً کپیِ تنظیماتِ nginx) مالِ root است و chmodِ آن انتشار را
# وسطِ راه می‌انداخت (۲۰۲۶-۱۰-۱۴). پوشه 700 است، پس آن فایل‌ها هم برای others بسته‌اند؛ فقط هشدار.
find .deploy-backups -type f -user "$(id -un)" -exec chmod 600 {} +
others=$(find .deploy-backups -type f ! -user "$(id -un)" | wc -l)
[ "$others" -eq 0 ] || warn "$others فایل در .deploy-backups مالِ کاربرِ دیگری است (احتمالاً بکاپِ sudo)؛ مجوزش دست نخورد."
fi
printf ' دیتابیس و بکاپ‌ها فقط برای %s خواندنی‌اند.\n' "$(id -un)"
# ─── ۳.۷ بکاپِ دیتابیس پیش از migrate ──────────────────────────────────────
# migrate تنها مرحله‌ای است که داده را برنمی‌گشت‌پذیر عوض می‌کند، پس درست پیش از آن بکاپ
# می‌گیریم؛ تا اینجا بکاپ دستی بود و در یک انتشارِ بی‌بکاپ هیچ راهی به عقب نبود.
#
# با sqlite3.Connection.backup() نه cp: کپیِ ساده وسطِ یک نوشتن فایلِ نیمه‌کاره می‌دهد (sqlite3
# به‌عنوانِ فرمان روی سرور نیست). اگر بکاپ نشد، migrate اجرا نمی‌شود: دیپلوی بی‌بکاپ را
# عمداً نمی‌پذیریم. فقط ۳ بکاپِ خودکارِ آخر (db-before-deploy-*: همین انتشار و دو تای قبل) نگه داشته می‌شود تا دیسکِ
# مشترک پر نشود؛ بکاپ‌های دستی با نامِ دیگر دست نمی‌خورند.
# Postgres با pg_dump. این بکاپ فقط برای برگشتن از migrationِ خراب است؛ کپیِ زنده‌ی بیرونِ ماشین جداست
# (ops/pgbackup/، docs/operations.md «بکاپ و بازیابی»).
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.postgresql':
import subprocess
os.makedirs('.deploy-backups', mode=0o700, exist_ok=True)
dst = '.deploy-backups/db-before-deploy-%s.pgdump' % datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
env = dict(os.environ, PGPASSWORD=db['PASSWORD'])
# فرمتِ custom (فشرده)؛ رمز از env می‌رود نه argv چون ps روی این ماشین argv را به همه نشان می‌دهد.
subprocess.run(['pg_dump', '-Fc', '-h', db['HOST'], '-p', str(db['PORT']), '-U', db['USER'],
'-f', dst, db['NAME']], env=env, check=True)
os.chmod(dst, 0o600)
# فقط خوانا بودنِ فایل را می‌سنجد؛ بازیابیِ واقعی روی دیتابیسِ دیگر آزموده نشده.
subprocess.run(['pg_restore', '--list', dst], check=True, stdout=subprocess.DEVNULL)
print(f' {dst} ({os.path.getsize(dst) // 2**20} MB، pg_restore --list: ok)')
old = sorted(glob.glob('.deploy-backups/db-before-deploy-*.pgdump'))[:-3]
for path in old:
os.remove(path)
raise SystemExit(0)
if db['ENGINE'] != 'django.db.backends.sqlite3':
print(f" ⚠ موتورِ دیتابیس {db['ENGINE']} است؛ بکاپِ خودکار فقط SQLite و Postgres را پوشش می‌دهد. خودتان بکاپ بگیرید.")
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'))[:-3]
for path in old:
os.remove(path)
if old:
print(f' {len(old)} بکاپِ خودکارِ قدیمی‌تر پاک شد (۳ تای آخر می‌ماند).')
PYBACKUP
# ─── ۳.۸ نگه‌داریِ بکاپ‌ها ─────────────────────────────────────────────────
# .deploy-backups بی‌سقف بزرگ می‌شد و دیسکِ این ماشینِ مشترک ~۸۸٪ پُر است. قاعده‌ی هر انتشار:
# db-before-deploy-* ۳ تای آخر (مرحله‌ی قبل)
# local-changes/commits-* ۳ تای آخر از هر کدام
# هر چیزِ دیگر بکاپ‌های دستیِ پیش از تغییر (env-before-*، db-before-*، unit-*…): پس از ۳۰ روز
# پاک. کپیِ .env رمز دارد و یک ماه بعد کسی به آن برنمی‌گردد.
# کپیِ واقعیِ داده این‌جا نیست: کپیِ زنده روی سرورِ دیگر است (ops/pgbackup/).
step "نگه‌داریِ بکاپ‌ها"
if [ -d .deploy-backups ]; then
# find و نه ls: ls بی‌فایل خطا می‌دهد و با pipefail کلِ دیپلو را بی‌پیام می‌کشت.
for prefix in local-changes- local-commits-; do
find .deploy-backups -maxdepth 1 -type f -name "${prefix}*.patch" -printf '%T@ %p\n' \
| sort -rn | tail -n +4 | cut -d' ' -f2- | xargs -r rm -f --
done
find .deploy-backups -maxdepth 1 -type f ! -name 'db-before-deploy-*' ! -name 'local-changes-*' \
! -name 'local-commits-*' -mtime +30 -print -delete | sed 's/^/ پاک شد: /'
printf ' .deploy-backups: %s\n' "$(du -sh .deploy-backups | cut -f1)"
fi
# ─── ۴. دیتابیس ────────────────────────────────────────────────────────────
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