make a deploy fail loudly when the service never restarted
سرور دو روز کدِ قدیمی را سرو کرد و هیچکس نفهمید. علتش این بود که `systemctl restart` شکست خورده بود ولی پروسهی قبلی هنوز بالا بود و همان ۲۰۰ را میداد — پس اسکریپت «✓ سرویس سالم بالا آمد» چاپ کرد و با کدِ صفر تمام شد. محتوا و فایلهای استاتیک هم روی دیسک بودند و سرو نمیشدند. حالا زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه میشود. اگر عوض نشده باشد، ۲۰۰ گرفتن دیگر موفقیت حساب نمیشود و اسکریپت با خطا تمام میکند. نامِ عامِ `gunicorn` هم از فهرستِ حدسِ سرویس برداشته شد: روی سرورِ واقعی یک `gunicorn.service` هست که مالِ تیمِ دیگری است، و حدس زدنش یعنی ریاستارت کردنِ سرویسِ یک نفرِ دیگر. نامِ درستِ ما `gunicorn-mathapp` است. `api/health/` هم کنارِ `api/health` ثبت شد. پایشِ سرور شکلِ اسلشدار را صدا میزد و چون ثبت نبود، کسی روی سرور `config/urls.py` را دستی ویرایش کرده بود — و از آن به بعد هر `git pull` روی همان فایل گیر میکرد. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
57
CLAUDE.md
57
CLAUDE.md
@@ -218,6 +218,63 @@ reliably pull the final answer out of a solution and produced ~40% broken output
|
|||||||
there is no way to log into the API without creating an `OTPCode` row in the admin
|
there is no way to log into the API without creating an `OTPCode` row in the admin
|
||||||
(`/admin/accounts/otpcode/add/`) — this is how to hand a tester a working login.
|
(`/admin/accounts/otpcode/add/`) — this is how to hand a tester a working login.
|
||||||
|
|
||||||
|
## The server: read this before running anything on it
|
||||||
|
|
||||||
|
`api.math.tadrisino2.ir` runs on a **shared company machine** (`administrator@78.38.35.249`,
|
||||||
|
hostname `haghzad`), not a box that belongs to this project. Our app is one tenant among
|
||||||
|
many. Also on it, all in production use by other people:
|
||||||
|
|
||||||
|
- **Gitea itself** (`gitea.service`) — the company's git server, including this repo's
|
||||||
|
origin. Breaking it means nobody can push or pull anything, anywhere.
|
||||||
|
- Mail (`postfix`, `dovecot`), DNS (`named`), `mariadb`, `postgresql@14`, `rabbitmq`,
|
||||||
|
`metabase`, `redis-server`, `nginx`.
|
||||||
|
- Other apps: `apirag`, `artinafront`, `metaverse`, `celerybeat`, and a **generic
|
||||||
|
`gunicorn.service` that is not ours**.
|
||||||
|
- Other people's home directories: `aria`, `artina`, `behdad`, `zahra`, `git`, `gitea`.
|
||||||
|
They are mode `0750` — unreadable as `administrator`, trivially readable under `sudo`.
|
||||||
|
Do not read them. Nothing in this project needs them.
|
||||||
|
|
||||||
|
**Our account is `administrator`, and `sudo -l` reports `(ALL : ALL) ALL`.** It is also in
|
||||||
|
the `docker` and `lxd` groups, each of which is root by another route. There is no
|
||||||
|
technical guard rail between a careless command and the whole company's data. The only
|
||||||
|
guard rail is the operator.
|
||||||
|
|
||||||
|
Ours is exactly one directory: **`/home/administrator/mathapp-v2`**, served by
|
||||||
|
**`gunicorn-mathapp.service`** (unix socket, proxied by nginx). Note `~/mathapp` also
|
||||||
|
exists, is not a git repo, and is not what runs — do not touch it.
|
||||||
|
|
||||||
|
Rules that follow from this, and are not negotiable:
|
||||||
|
|
||||||
|
1. **Never run a destructive command outside `~/mathapp-v2`.** No `rm -rf`, no
|
||||||
|
`git clean`, no `chown -R`, no dropping databases, anywhere else. When a command
|
||||||
|
takes a path, write the path out in full rather than relying on the current directory.
|
||||||
|
2. **Restart only `gunicorn-mathapp`.** `systemctl restart gunicorn` is a different
|
||||||
|
team's app. `deploy.sh` no longer guesses that name, and neither should you.
|
||||||
|
3. **Never delete the project directory or "re-clone fresh".** `db.sqlite3`, `.env` and
|
||||||
|
`.django_secret_key` live inside it and are all gitignored. A fresh clone silently
|
||||||
|
destroys the database, the config and every logged-in session. Use
|
||||||
|
`git reset --hard origin/main` when the tree needs to go back to a known state.
|
||||||
|
4. **Never `git clean`** there for the same reason.
|
||||||
|
5. **Read `.env` only through Django.** `python -c` printing `settings.CACHES[...]` and
|
||||||
|
booleans is fine; `cat .env` is not, and neither is pasting its values into a chat.
|
||||||
|
6. **Take a timestamped backup before editing anything under `/etc`,** and say where you
|
||||||
|
put it.
|
||||||
|
7. **Disk is the standing risk.** It has been sitting at ~95% full (about 6 GB free on a
|
||||||
|
113 GB volume). Check `df -h /` before anything that writes a lot — a full disk on
|
||||||
|
this machine takes the mail server and Gitea down with us, not just our app.
|
||||||
|
|
||||||
|
`DEBUG` must never be `1` on this server: it turns on `DEV_MASTER_OTP` (`0000`), which
|
||||||
|
accepts any phone number as a valid login, and renders tracebacks with settings in them.
|
||||||
|
`DJANGO_DEBUG` belongs in `.env` only — not in the systemd unit, where it once sat as
|
||||||
|
`Environment="DJANGO_DEBUG=1"` and was saved only by `.env` happening to override it.
|
||||||
|
|
||||||
|
`DJANGO_SECRET_KEY` is what signs our bearer tokens. It must be a real random value that
|
||||||
|
exists nowhere in git. If the env var is simply absent and `DEBUG=0`, `config/settings.py`
|
||||||
|
generates a strong one and stores it in `.django_secret_key` (mode `0600`) — that is the
|
||||||
|
preferred setup, because there is then no key to leak. **Never let the server run on the
|
||||||
|
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
|
||||||
|
anyone who can read this repo can forge a token for any user.**
|
||||||
|
|
||||||
## State as of 2026-09-17 — read this first
|
## State as of 2026-09-17 — read this first
|
||||||
|
|
||||||
**Backend is done and verified; nothing is blocked on us.** The full game loop was driven
|
**Backend is done and verified; nothing is blocked on us.** The full game loop was driven
|
||||||
|
|||||||
@@ -43,7 +43,14 @@ urlpatterns = [
|
|||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|
||||||
# بررسیِ سلامتِ نسخهی مستقرشده — بدونِ احراز، برای پایشِ بیرونی.
|
# بررسیِ سلامتِ نسخهی مستقرشده — بدونِ احراز، برای پایشِ بیرونی.
|
||||||
|
#
|
||||||
|
# هر دو شکل ثبت میشود، با و بدونِ اسلشِ آخر. دلیلش تجربهی واقعی است:
|
||||||
|
# پایشِ سرور `/api/health/` صدا میزد و چون فقط شکلِ بیاسلش ثبت بود،
|
||||||
|
# کسی روی سرور همین فایل را دستی ویرایش کرد تا کار کند — و از آن لحظه
|
||||||
|
# هر `git pull` بعدی روی همین فایل گیر میکرد. یک مسیرِ اضافه ارزانش
|
||||||
|
# است در برابرِ یک فایلِ دستکاریشده روی پروداکشن.
|
||||||
path('api/health', HealthView.as_view(), name='health'),
|
path('api/health', HealthView.as_view(), name='health'),
|
||||||
|
path('api/health/', HealthView.as_view(), name='health-slash'),
|
||||||
|
|
||||||
# OpenAPI schema + Swagger UI + Redoc
|
# OpenAPI schema + Swagger UI + Redoc
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
|
|||||||
34
deploy.sh
34
deploy.sh
@@ -33,7 +33,9 @@ if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then
|
|||||||
else
|
else
|
||||||
HEALTH_URLS=(
|
HEALTH_URLS=(
|
||||||
"http://127.0.0.1:8000/api/health"
|
"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"
|
||||||
|
"https://api.math.tadrisino2.ir/api/health/"
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -150,18 +152,36 @@ SERVICE="${TADRISINO_SERVICE:-}"
|
|||||||
if [ -z "$SERVICE" ] && command -v systemctl >/dev/null 2>&1; then
|
if [ -z "$SERVICE" ] && command -v systemctl >/dev/null 2>&1; then
|
||||||
# `systemctl cat` تنها بررسیِ قابلِ اتکاست: list-unit-files حتی وقتی چیزی
|
# `systemctl cat` تنها بررسیِ قابلِ اتکاست: list-unit-files حتی وقتی چیزی
|
||||||
# پیدا نکند هم کدِ ۰ برمیگرداند.
|
# پیدا نکند هم کدِ ۰ برمیگرداند.
|
||||||
for candidate in tadrisino tadrisino-api mathapp gunicorn; do
|
# عمداً نامِ عامِ `gunicorn` در این فهرست نیست. روی سرورِ واقعی چند اپِ
|
||||||
|
# مختلف کنارِ هم هستند و یک `gunicorn.service` وجود دارد که مالِ تیمِ
|
||||||
|
# دیگری است — حدس زدنِ آن یعنی ریاستارت کردنِ سرویسِ یک نفرِ دیگر. اگر
|
||||||
|
# نامِ سرویس در این فهرست نبود، TADRISINO_SERVICE را صریح بدهید.
|
||||||
|
for candidate in tadrisino tadrisino-api gunicorn-mathapp mathapp; do
|
||||||
if systemctl cat "${candidate}.service" >/dev/null 2>&1; then
|
if systemctl cat "${candidate}.service" >/dev/null 2>&1; then
|
||||||
SERVICE="$candidate"; break
|
SERVICE="$candidate"; break
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
RESTART_OK=0
|
||||||
if [ -n "$SERVICE" ]; then
|
if [ -n "$SERVICE" ]; then
|
||||||
# شکستِ ریاستارت نباید اسکریپت را بیصدا تمام کند؛ بررسیِ سلامتِ پایین
|
# زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه میشود.
|
||||||
# همچنان باید اجرا شود تا معلوم شود سرویسِ قبلی هنوز بالاست یا نه.
|
#
|
||||||
|
# چرا: قبلاً فقط خروجیِ `systemctl restart` نگاه میشد و بعد سلامت از روی
|
||||||
|
# آدرسِ عمومی چک میشد. ولی وقتی ریاستارت شکست میخورد، پروسهی *قدیمی*
|
||||||
|
# هنوز بالا بود و همان ۲۰۰ را میداد — یعنی اسکریپت «✓ سالم» میگفت در
|
||||||
|
# حالی که کدِ جدید اصلاً بار نشده بود. روی سرور دو روز همینطور ماند: کد و
|
||||||
|
# محتوا و فایلهای استاتیک روی دیسک بودند و هیچکدام سرو نمیشدند.
|
||||||
|
BEFORE="$(systemctl show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)"
|
||||||
if sudo systemctl restart "$SERVICE"; then
|
if sudo systemctl restart "$SERVICE"; then
|
||||||
printf ' سرویسِ %s ریاستارت شد\n' "$SERVICE"
|
AFTER="$(systemctl show "$SERVICE" -p ActiveEnterTimestamp --value 2>/dev/null || true)"
|
||||||
|
if [ -n "$BEFORE" ] && [ "$BEFORE" = "$AFTER" ]; then
|
||||||
|
warn "دستورِ ریاستارت خطا نداد ولی سرویس بالا نیامد (هنوز از $BEFORE در حال اجراست).
|
||||||
|
یعنی کدِ جدید بار نشده. لاگ: sudo journalctl -u $SERVICE -n 50 --no-pager"
|
||||||
|
else
|
||||||
|
RESTART_OK=1
|
||||||
|
printf ' سرویسِ %s ریاستارت شد\n' "$SERVICE"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
warn "ریاستارتِ $SERVICE شکست خورد (شاید sudo رمز میخواهد). خودتان اجرا کنید:
|
warn "ریاستارتِ $SERVICE شکست خورد (شاید sudo رمز میخواهد). خودتان اجرا کنید:
|
||||||
sudo systemctl restart $SERVICE"
|
sudo systemctl restart $SERVICE"
|
||||||
@@ -185,6 +205,12 @@ for attempt in 1 2 3 4 5 6 7 8 9 10; do
|
|||||||
if [ "$CODE" = "200" ]; then
|
if [ "$CODE" = "200" ]; then
|
||||||
printf '%s✓ سرویس سالم بالا آمد%s (%s)\n' "$C_OK" "$C_OFF" "$url"
|
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"
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|||||||
Reference in New Issue
Block a user