diff --git a/config/health.py b/config/health.py index c9681f4..b98d24e 100644 --- a/config/health.py +++ b/config/health.py @@ -275,6 +275,24 @@ def _offsite_warnings(offsite: dict) -> list[str]: return [msg] if msg else [] +# بیش از این یعنی آرشیوِ شبانه (`ops/applogs/archive-app-log.sh`) چند روز است اجرا نشده؛ +# با ترافیکِ ۶۰۰ کاربرِ فعال یک روز حدودِ ۳۰ مگ است (برآورد، نه سنجش). +APP_LOG_MAX_BYTES = 256 * 2**20 + + +def _app_log_warnings() -> list[str]: + """با چرخشِ بیرونی، برنامه app.log را پاک نمی‌کند؛ اگر آرشیوِ شبانه بخوابد فایل بی‌انتها بزرگ می‌شود.""" + if not settings.LOG_DIR or settings.LOG_FILE_ROTATION != 'external': + return [] + try: + size = os.path.getsize(os.path.join(settings.LOG_DIR, 'app.log')) + except FileNotFoundError: + return [] + if size > APP_LOG_MAX_BYTES: + return [f'app.log {size // 2**20} مگ است: آرشیوِ شبانه‌ی لاگ (zareian-applog-archive.timer) اجرا نشده.'] + return [] + + def _warnings() -> list[str]: """ چیزهایی که خرابی نیستند ولی روی یک سرورِ عمومی خطرناک‌اند. @@ -313,6 +331,7 @@ def _warnings() -> list[str]: 'اعتبارِ سرویسِ هوش مصنوعی (GapGPT) تمام شده: دستیار برای همه‌ی کاربرها ' '«در دسترس نیست» می‌دهد تا حساب شارژ شود.' ) + out.extend(_app_log_warnings()) backend = settings.CACHES['default']['BACKEND'] if not settings.DEBUG and 'LocMem' in backend: out.append('کش محلیِ هر پروسه است؛ محدودیتِ نرخ بینِ workerها مشترک نیست.') diff --git a/config/settings.py b/config/settings.py index 2b9514c..c288e9f 100644 --- a/config/settings.py +++ b/config/settings.py @@ -1060,13 +1060,28 @@ OFFSITE_WAL_SLOT = os.environ.get('OFFSITE_WAL_SLOT', '') # بیش از این عقب‌ماندن هشدار است. ۶۴ مگ ≈ چهار فایلِ WAL؛ در ترافیکِ عادی عقب‌ماندگی نزدیکِ صفر است. OFFSITE_WAL_MAX_LAG_BYTES = 64 * 2**20 +# چرخشِ فایلِ لاگ: 'size' (پیش‌فرض، Docker و محلی) = ۶ فایلِ ۱۰ مگی و قدیمی‌ها پاک می‌شوند؛ +# 'external' (سرور) = برنامه هرگز پاک نمی‌کند، `ops/applogs/archive-app-log.sh` هر شب app.log را جابه‌جا و +# فشرده می‌کند و ۲۴۷ آن را برای همیشه نگه می‌دارد. WatchedFileHandler پس از جابه‌جایی فایلِ تازه باز می‌کند، +# که با ۴ پروسه‌ی gunicorn امن‌تر از چرخشِ داخلیِ RotatingFileHandler است (آن هر پروسه جدا می‌چرخاند). +LOG_FILE_ROTATION = os.environ.get('LOG_FILE_ROTATION', 'size') +if LOG_FILE_ROTATION not in ('size', 'external'): + raise ImproperlyConfigured(f"LOG_FILE_ROTATION باید 'size' یا 'external' باشد، نه {LOG_FILE_ROTATION!r}") + if LOG_DIR: os.makedirs(LOG_DIR, exist_ok=True) - LOGGING['handlers']['file'] = { - 'class': 'logging.handlers.RotatingFileHandler', - 'filename': os.path.join(LOG_DIR, 'app.log'), - 'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate - 'backupCount': 5, - 'formatter': 'json', - } + if LOG_FILE_ROTATION == 'external': + LOGGING['handlers']['file'] = { + 'class': 'logging.handlers.WatchedFileHandler', + 'filename': os.path.join(LOG_DIR, 'app.log'), + 'formatter': 'json', + } + else: + LOGGING['handlers']['file'] = { + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': os.path.join(LOG_DIR, 'app.log'), + 'maxBytes': 10 * 1024 * 1024, # 10MB در هر فایل، قبل از rotate + 'backupCount': 5, + 'formatter': 'json', + } LOGGING['root']['handlers'].append('file') diff --git a/config/tests_app_log_archive.py b/config/tests_app_log_archive.py new file mode 100644 index 0000000..76914cb --- /dev/null +++ b/config/tests_app_log_archive.py @@ -0,0 +1,26 @@ +import os +import tempfile + +from django.test import SimpleTestCase, override_settings + +from config import health + + +class AppLogSizeWarningTests(SimpleTestCase): + """با چرخشِ بیرونی برنامه app.log را پاک نمی‌کند؛ اگر آرشیوِ شبانه بخوابد /api/health باید بگوید.""" + + def _with_log(self, size, rotation): + d = tempfile.mkdtemp() + with open(os.path.join(d, 'app.log'), 'wb') as f: + f.truncate(size) + with override_settings(LOG_DIR=d, LOG_FILE_ROTATION=rotation): + return health._app_log_warnings() + + def test_big_log_with_external_rotation_warns(self): + self.assertEqual(len(self._with_log(health.APP_LOG_MAX_BYTES + 1, 'external')), 1) + + def test_small_log_is_quiet(self): + self.assertEqual(self._with_log(1024, 'external'), []) + + def test_size_rotation_never_warns(self): + self.assertEqual(self._with_log(health.APP_LOG_MAX_BYTES + 1, 'size'), []) diff --git a/ops/applogs/archive-app-log.sh b/ops/applogs/archive-app-log.sh new file mode 100755 index 0000000..2037bd4 --- /dev/null +++ b/ops/applogs/archive-app-log.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# روی ۲۴۹، هر شب (zareian-applog-archive.timer). app.log را به logs/archive/ می‌برد و فشرده می‌کند. +# برنامه با LOG_FILE_ROTATION=external از WatchedFileHandler استفاده می‌کند و پس از جابه‌جایی خودش app.log تازه +# می‌سازد؛ خطی که در همان لحظه نوشته شود در فایلِ جابه‌جاشده می‌ماند، گم نمی‌شود. +# این‌جا فقط ۳۰ روز می‌ماند (دیسکِ ۲۴۹ پر است) و فقط فایلی پاک می‌شود که ۲۴۷ گرفتنش را تأیید کرده +# (`.shipped`)؛ نسخه‌ی همیشگی روی ۲۴۷ است (pull-app-logs.sh). فقط زیرِ ~/mathapp-v2/logs می‌نویسد. +set -euo pipefail +LOGS=/home/administrator/mathapp-v2/logs +ARCH="$LOGS/archive" +mkdir -p "$ARCH" +chmod 750 "$ARCH" +stamp=$(date -u +%Y%m%dT%H%M%SZ) + +# app.log.1..5 از RotatingFileHandlerِ پیشین (پیش از LOG_FILE_ROTATION=external) هم یک بار بایگانی می‌شوند. +for f in "$LOGS"/app.log.[0-9]*; do + [ -e "$f" ] || continue + n=${f##*.} + mv "$f" "$ARCH/app-$stamp-old$n.log" +done +if [ -s "$LOGS/app.log" ]; then + mv "$LOGS/app.log" "$ARCH/app-$stamp.log" + sleep 5 # نوشتنِ در جریانِ workerها روی فایلِ قدیمی تمام شود +fi +for f in "$ARCH"/app-*.log; do + [ -e "$f" ] || continue + gzip -9 "$f" + chmod 640 "$f.gz" +done + +find "$ARCH" -name 'app-*.log.gz' -mtime +30 | while read -r gz; do + [ -e "$gz.shipped" ] && rm -f -- "$gz" "$gz.shipped" +done +echo "archived $(ls "$ARCH"/app-*.log.gz 2>/dev/null | wc -l) files in $ARCH" diff --git a/ops/applogs/pull-app-logs.sh b/ops/applogs/pull-app-logs.sh new file mode 100755 index 0000000..9e58e96 --- /dev/null +++ b/ops/applogs/pull-app-logs.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# روی ۲۴۷، هر ساعت (zareian-applog-pull.timer). بایگانیِ فشرده‌ی لاگِ برنامه را از ۲۴۹ می‌گیرد و +# **برای همیشه** در ~/zareian-pgbackup/app-logs/ نگه می‌دارد (تصمیمِ مالک ۲۰۲۶-۱۰-۱۳؛ برآورد حدودِ ۱ گیگ در سال +# با ۶۰۰ کاربرِ فعال). هیچ فایلی این‌جا پاک نمی‌شود. کلید فقط serve-archive.sh را اجرا می‌کند. +set -euo pipefail +DEST=/home/administrator/zareian-pgbackup/app-logs +mkdir -p "$DEST" +r() { ssh -i /home/administrator/.ssh/zareian_applogs -o BatchMode=yes -o ConnectTimeout=20 \ + administrator@192.168.200.164 "$@"; } + +free_kb=$(df --output=avail -k "$DEST" | tail -1) +[ "$free_kb" -gt 3000000 ] || { echo "disk too full ($free_kb KB free); skipping" >&2; exit 1; } + +got=0 +for name in $(r list); do + [[ "$name" =~ ^app-[0-9]{8}T[0-9]{6}Z(-old[0-9]+)?\.log\.gz$ ]] || { echo "skip odd name: $name" >&2; continue; } + if [ ! -s "$DEST/$name" ]; then + r get "$name" > "$DEST/.$name.part" + gzip -t "$DEST/.$name.part" + chmod 600 "$DEST/.$name.part" + mv "$DEST/.$name.part" "$DEST/$name" + got=$((got + 1)) + fi + r ack "$name" +done +echo "fetched $got new; $(ls "$DEST" | wc -l) files, $(du -sh "$DEST" | cut -f1) in $DEST" diff --git a/ops/applogs/serve-archive.sh b/ops/applogs/serve-archive.sh new file mode 100755 index 0000000..44e8c49 --- /dev/null +++ b/ops/applogs/serve-archive.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# روی ۲۴۹: فرمانِ اجباریِ کلیدِ zareian-applogs (authorized_keys با restrict,command="…این فایل"). +# آن کلید (روی ۲۴۷) جز این سه کار هیچ کاری نمی‌تواند: شِل، پورت و فایلِ دیگری ندارد. +# list نامِ فایل‌های بایگانی +# get NAME محتوای یک فایل +# ack NAME «۲۴۷ گرفت و سالم بود» → NAME.shipped (پس از ۳۰ روز این‌جا پاک شود) +set -euo pipefail +ARCH=/home/administrator/mathapp-v2/logs/archive +read -r cmd name extra <<<"${SSH_ORIGINAL_COMMAND:-}" +[ -z "${extra:-}" ] || { echo "bad request" >&2; exit 2; } +valid() { [[ "$1" =~ ^app-[0-9]{8}T[0-9]{6}Z(-old[0-9]+)?\.log\.gz$ ]] && [ -f "$ARCH/$1" ]; } +case "$cmd" in + list) cd "$ARCH" && ls -1 app-*.log.gz 2>/dev/null || true ;; + get) valid "${name:-}" || { echo "no such file" >&2; exit 2; }; cat -- "$ARCH/$name" ;; + ack) valid "${name:-}" || { echo "no such file" >&2; exit 2; }; touch -- "$ARCH/$name.shipped" ;; + *) echo "bad request" >&2; exit 2 ;; +esac diff --git a/ops/applogs/zareian-applog-archive.service b/ops/applogs/zareian-applog-archive.service new file mode 100644 index 0000000..f9f9f61 --- /dev/null +++ b/ops/applogs/zareian-applog-archive.service @@ -0,0 +1,8 @@ +# روی ۲۴۹. +[Unit] +Description=Zareian MathApp: nightly gzip of app.log into logs/archive + +[Service] +Type=oneshot +User=administrator +ExecStart=/home/administrator/mathapp-v2/ops/applogs/archive-app-log.sh diff --git a/ops/applogs/zareian-applog-archive.timer b/ops/applogs/zareian-applog-archive.timer new file mode 100644 index 0000000..e6be568 --- /dev/null +++ b/ops/applogs/zareian-applog-archive.timer @@ -0,0 +1,10 @@ +# روی ۲۴۹. ساعتِ سرور تهران است. +[Unit] +Description=Zareian MathApp: nightly app.log archive + +[Timer] +OnCalendar=*-*-* 00:05:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ops/applogs/zareian-applog-pull.service b/ops/applogs/zareian-applog-pull.service new file mode 100644 index 0000000..b52058f --- /dev/null +++ b/ops/applogs/zareian-applog-pull.service @@ -0,0 +1,8 @@ +# روی ۲۴۷. +[Unit] +Description=Zareian MathApp: fetch archived app logs from 249 (kept forever) + +[Service] +Type=oneshot +User=administrator +ExecStart=/home/administrator/zareian-pgbackup/bin/pull-app-logs.sh diff --git a/ops/applogs/zareian-applog-pull.timer b/ops/applogs/zareian-applog-pull.timer new file mode 100644 index 0000000..c591808 --- /dev/null +++ b/ops/applogs/zareian-applog-pull.timer @@ -0,0 +1,11 @@ +# روی ۲۴۷. +[Unit] +Description=Zareian MathApp: hourly fetch of archived app logs + +[Timer] +OnCalendar=hourly +RandomizedDelaySec=300 +Persistent=true + +[Install] +WantedBy=timers.target