466 lines
31 KiB
Bash
Executable File
466 lines
31 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 ست نباشد چند آدرس امتحان میشود: پورتِ محلی ممکن
|
||
# است فرق کند (سوکتِ یونیکس، پورتِ دیگر)، و در آن حالت دیپلویِ موفق هم پیامِ
|
||
# خطا میگرفت. آدرسِ عمومی نشان میدهد سرویس واقعاً از بیرون جواب میدهد؛ آن
|
||
# را پایینتر، بعد از فعال شدنِ 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
|