From de118db58f832460d602ab9a8871613af74a5c73 Mon Sep 17 00:00:00 2001 From: Zareian Date: Thu, 17 Sep 2026 22:56:51 +0330 Subject: [PATCH] make a deploy fail loudly when the service never restarted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit سرور دو روز کدِ قدیمی را سرو کرد و هیچ‌کس نفهمید. علتش این بود که `systemctl restart` شکست خورده بود ولی پروسه‌ی قبلی هنوز بالا بود و همان ۲۰۰ را می‌داد — پس اسکریپت «✓ سرویس سالم بالا آمد» چاپ کرد و با کدِ صفر تمام شد. محتوا و فایل‌های استاتیک هم روی دیسک بودند و سرو نمی‌شدند. حالا زمانِ بالا آمدنِ سرویس قبل و بعد مقایسه می‌شود. اگر عوض نشده باشد، ۲۰۰ گرفتن دیگر موفقیت حساب نمی‌شود و اسکریپت با خطا تمام می‌کند. نامِ عامِ `gunicorn` هم از فهرستِ حدسِ سرویس برداشته شد: روی سرورِ واقعی یک `gunicorn.service` هست که مالِ تیمِ دیگری است، و حدس زدنش یعنی ری‌استارت کردنِ سرویسِ یک نفرِ دیگر. نامِ درستِ ما `gunicorn-mathapp` است. `api/health/` هم کنارِ `api/health` ثبت شد. پایشِ سرور شکلِ اسلش‌دار را صدا می‌زد و چون ثبت نبود، کسی روی سرور `config/urls.py` را دستی ویرایش کرده بود — و از آن به بعد هر `git pull` روی همان فایل گیر می‌کرد. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++ config/urls.py | 7 +++++++ deploy.sh | 34 ++++++++++++++++++++++++++---- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6d00d24..d6f0512 100644 --- a/CLAUDE.md +++ b/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 (`/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 **Backend is done and verified; nothing is blocked on us.** The full game loop was driven diff --git a/config/urls.py b/config/urls.py index a9ee445..227965d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -43,7 +43,14 @@ urlpatterns = [ 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-slash'), # OpenAPI schema + Swagger UI + Redoc path('api/schema/', SpectacularAPIView.as_view(), name='schema'), diff --git a/deploy.sh b/deploy.sh index cff046f..fbad0b9 100755 --- a/deploy.sh +++ b/deploy.sh @@ -33,7 +33,9 @@ if [ -n "${TADRISINO_HEALTH_URL:-}" ]; then 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 @@ -150,18 +152,36 @@ 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 + # عمداً نامِ عامِ `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 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 - 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 warn "ری‌استارتِ $SERVICE شکست خورد (شاید sudo رمز می‌خواهد). خودتان اجرا کنید: 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 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