diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 7e2b4a2e..fb28e90c 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -77,7 +77,7 @@ on: # исполнял бы старую версию — молча и без единого сигнала. # Глоб, а не точечный список (#2203): класс бага — «любой ops-скрипт, # запускаемый по cron с VM», не только docker-prune.sh. Сейчас сюда попадают - # backup.sh, restore-drill.sh, restore.sh, uptime-healthcheck.sh — точечное + # backup.sh, restore-drill.sh, restore.sh — точечное # перечисление пришлось бы дополнять при каждом новом скрипте, и про это # снова забыли бы (см. как этот самый комментарий выше был точечным про # docker-prune.sh и не спас backup.sh). Глоб закрывает класс целиком. diff --git a/backend/tests/ops/test_2203_notify_topic.py b/backend/tests/ops/test_2203_notify_topic.py index cad0f108..68557413 100644 --- a/backend/tests/ops/test_2203_notify_topic.py +++ b/backend/tests/ops/test_2203_notify_topic.py @@ -1,4 +1,4 @@ -"""Оповещения уходят в тему «алерты», а не в общую (#2203/#3078). +"""Оповещения уходят в тему форума, а не в общую (#2203/#3078/#3164). Прод-факт, 27.08. Канал доставки включили, `notify()` заработал — и алерты посыпались в ОБЩУЮ тему форума (в чат переговорки), а не в «алерты». Причина: в @@ -16,9 +16,7 @@ реально ушло бы в сеть. Функция извлекается из файла построчно, а не копируется в тест: копия разошлась -бы с оригиналом на первой же правке. Сорсить файл целиком нельзя — у -`uptime-healthcheck.sh` нет guard'а по `BASH_SOURCE`, и сорсинг запустил бы -настоящие сетевые проверки. +бы с оригиналом на первой же правке. """ from __future__ import annotations @@ -34,7 +32,6 @@ import pytest REPO_ROOT = Path(__file__).resolve().parents[3] SENDERS = { "lib-backup.sh": REPO_ROOT / "ops" / "lib-backup.sh", - "uptime-healthcheck.sh": REPO_ROOT / "ops" / "uptime-healthcheck.sh", } FAKE_CURL = '#!/usr/bin/env bash\nprintf "%s\\n" "$@" >> "$ARGS_DUMP"\nexit 0\n' @@ -105,7 +102,7 @@ def _run_notify(tmp_path: Path, script: Path, *, topic: str | None) -> list[str] f'ARGS_DUMP="{_posix(dump)}"', 'TELEGRAM_BOT_TOKEN="123:FAKE"', 'TELEGRAM_CHAT_ID="-1004443088679"', - f"BACKUP_ENV_FILE=\"{_posix(tmp_path / 'missing.env')}\"", + f'BACKUP_ENV_FILE="{_posix(tmp_path / "missing.env")}"', ] if topic is not None: prefix.append(f'TELEGRAM_TOPIC_ID="{topic}"') @@ -153,3 +150,73 @@ def test_adres_i_tekst_na_meste(tmp_path: Path, name: str) -> None: args = _run_notify(tmp_path, SENDERS[name], topic="158") assert "chat_id=-1004443088679" in args, f"{name}: потерялся chat_id" assert any("тестовое сообщение" in a for a in args), f"{name}: потерялся текст" + + +# #3164. Решение владельца в #3163: инфраструктура — в тему «Metrics» (245), +# клиентские инциденты — в «алерты» (158). Бэкапы — инфраструктура. Тема живёт +# только в env-файле на хосте, поэтому образец — единственное место в репозитории, +# откуда следующий, кто заводит хост, узнает правильный адрес. +INFRA_TOPIC = "245" +BACKUP_ENV_EXAMPLES = [ + "gendesign-backup.default.example", + "gendesign-backup-couchdb.default.example", + "gendesign-backup-forgejo.default.example", +] + + +@pytest.mark.parametrize("example", BACKUP_ENV_EXAMPLES) +def test_host_po_obraztsu_shlyot_bekapy_v_metriki(tmp_path: Path, example: str) -> None: + """Хост, заведённый по образцу, шлёт алерт бэкапа в «Metrics», а не мимо. + + Исполняется настоящий сторож `check-backup-staleness.sh` с отсутствующим + маркером, а env-файл — образец, заполненный так, как он велит человеку: + раскомментированы строки `TELEGRAM_*`, подставлены токен и чат. + + Ловит и второе: тема обязана лежать В ТОМ ЖЕ файле, что токен и чат. + `notify()` читает `BACKUP_ENV_FILE`, только если токена или чата нет в + окружении, — тема из другого файла молча не подхватится. + """ + text = (REPO_ROOT / "ops" / example).read_text(encoding="utf-8") + filled = re.sub(r"(?m)^#(TELEGRAM_\w+=)", r"\1", text) + filled = re.sub(r"(?m)^TELEGRAM_BOT_TOKEN=.*$", "TELEGRAM_BOT_TOKEN=123:FAKE", filled) + filled = re.sub(r"(?m)^TELEGRAM_CHAT_ID=.*$", "TELEGRAM_CHAT_ID=-1004443088679", filled) + env_file = tmp_path / "backup.env" + env_file.write_text(filled, encoding="utf-8", newline="\n") + + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "curl" + fake.write_text(FAKE_CURL, encoding="utf-8", newline="\n") + fake.chmod(0o755) + dump = tmp_path / "args.txt" + + # Пустые TELEGRAM_* префиксом: переменные из окружения разработчика иначе + # перебили бы файл, и тест проверял бы не образец. + prefix = [ + f'PATH="{_posix(bin_dir)}:$PATH"', + f'ARGS_DUMP="{_posix(dump)}"', + 'TELEGRAM_BOT_TOKEN="" TELEGRAM_CHAT_ID="" TELEGRAM_TOPIC_ID=""', + f'BACKUP_ENV_FILE="{_posix(env_file)}"', + f'BACKUP_STALENESS_STATE_FILE="{_posix(tmp_path / "state")}"', + ] + script = REPO_ROOT / "ops" / "check-backup-staleness.sh" + proc = subprocess.run( + [ + "bash", + "-c", + " ".join(prefix) + + f' bash "{_posix(script)}" "{_posix(tmp_path / "no-sentinel")}" 26 "topic probe"', + ], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=30, + env=dict(os.environ), + ) + args = dump.read_text(encoding="utf-8").splitlines() if dump.exists() else [] + assert args, f"{example}: curl не был вызван.\n{proc.stdout}\n{proc.stderr}" + threads = [a for a in args if a.startswith("message_thread_id=")] + assert threads == [f"message_thread_id={INFRA_TOPIC}"], ( + f"{example}: алерт бэкапа адресован {threads or 'в общую тему'}, " + f"а по #3163 инфраструктура идёт в «Metrics» ({INFRA_TOPIC})" + ) diff --git a/backend/tests/ops/test_3059_alert_retry.py b/backend/tests/ops/test_3059_alert_retry.py index 071080c1..b2390eea 100644 --- a/backend/tests/ops/test_3059_alert_retry.py +++ b/backend/tests/ops/test_3059_alert_retry.py @@ -14,7 +14,9 @@ Poincare, 40 подключений к ЗАКРЕПЛЁННОМУ (#3093) 149.15 Как это ломало алерты: -* `ops/uptime-healthcheck.sh` — ОДИН `curl`, дальше `|| log WARN`. Каждый +* `ops/uptime-healthcheck.sh` (удалён в #3164: ни на одном хосте не был + запущен, доступность сайтов сторожат uptime-мониторы GlitchTip) — ОДИН + `curl`, дальше `|| log WARN`. Каждый отказ терял уведомление целиком. Watchdog, который не может дозваться, — худший вид самоскрывающейся поломки: чем хуже дела на проде, тем выше шанс, что о них не сообщат. При этом сам файл ниже повторяет свои HTTP-ПРОВЕРКИ @@ -51,7 +53,6 @@ import pytest # backend/tests/ops/<этот файл> → корень репозитория REPO_ROOT = Path(__file__).resolve().parents[3] -UPTIME = "ops/uptime-healthcheck.sh" LIB_BACKUP = "ops/lib-backup.sh" # См. подробное обоснование shutil.which в @@ -65,8 +66,7 @@ if BASH is None: # pragma: no cover - окружение без bash не за def _extract_function(script_rel: str, name: str = "notify") -> str: """Достаёт тело одной функции из скрипта — не весь файл. - Весь скрипт source'ить нельзя: uptime-healthcheck.sh ниже функций реально - ходит по прод-URL, а lib-backup.sh рассчитан на вызов из backup.sh. + Весь скрипт source'ить нельзя: lib-backup.sh рассчитан на вызов из backup.sh. Сопоставление точное (`name() {`), иначе `notify` поймал бы `notify_fallback_mail` — соседнюю функцию в том же файле. @@ -140,7 +140,7 @@ def _run_notify(script_rel: str, fail_times: int) -> tuple[int, str, str]: return calls, out, err -@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: """Один отказ — алерт всё равно доставляется со второй попытки. @@ -153,7 +153,7 @@ def test_transient_failure_is_retried_not_lost(script_rel: str) -> None: assert "НЕ ДОСТАВЛЕН" not in err, f"{script_rel}: доставленный алерт помечен потерянным" -@pytest.mark.parametrize("script_rel", [UPTIME, LIB_BACKUP]) +@pytest.mark.parametrize("script_rel", [LIB_BACKUP]) def test_gives_up_after_three_attempts(script_rel: str) -> None: """Повторы ограничены: три попытки, а не бесконечный цикл. @@ -164,12 +164,6 @@ def test_gives_up_after_three_attempts(script_rel: str) -> None: assert calls == 3, f"{script_rel}: ожидалось ровно 3 попытки, а curl позван {calls} раз" -def test_uptime_reports_undelivered_alert_loudly() -> None: - """Когда все три попытки провалились — это видно в логе, а не молча.""" - _calls, _out, err = _run_notify(UPTIME, fail_times=99) - assert "НЕ ДОСТАВЛЕН" in err, f"недоставленный алерт должен логироваться громко.\n{err}" - - def test_backup_does_not_burn_fallback_on_a_single_timeout() -> None: """Транзиентный таймаут не должен трогать запасной канал. diff --git a/ops/check-backup-staleness.sh b/ops/check-backup-staleness.sh index d21d041a..c3fdec35 100755 --- a/ops/check-backup-staleness.sh +++ b/ops/check-backup-staleness.sh @@ -10,9 +10,8 @@ # script says so loudly instead of the silence that let backups break for # weeks undetected before (#71). # -# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same -# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam -# Telegram once a backup is already known to be stale. +# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), so an +# hourly cron doesn't spam Telegram once a backup is already known to be stale. # # Usage (cron — one line per sentinel, run more often than the backup itself # so a stale state is caught promptly; hourly is a reasonable default for a @@ -27,9 +26,10 @@ # /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \ # >> /var/log/gendesign-backup-staleness.log 2>&1 # -# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see -# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup, -# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only. +# Alert channel: notify() in ops/lib-backup.sh — creds in +# ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}, TELEGRAM_BOT_TOKEN/ +# TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID (245 «Metrics», #3164). Without the first +# two, logs only. # # Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be # used as a plain healthcheck by anything that just wants the exit code). diff --git a/ops/crontab-beget.cron b/ops/crontab-beget.cron index 16639595..617a00aa 100644 --- a/ops/crontab-beget.cron +++ b/ops/crontab-beget.cron @@ -22,7 +22,11 @@ 15 4 * * * FORGEJO_BACKUP_ENV_FILE=/opt/gendesign/secrets/forgejo-backup.env bash /opt/gendesign/ops/backup-forgejo.sh >> /opt/gendesign/logs/backup-forgejo.log 2>&1 # Сторож пропущенных прогонов для forgejo-бэкапа. Порог 26ч. -0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 +# Канал оповещения сторожей и бэкапа волта — /opt/gendesign/secrets/backup-notify.env +# (TELEGRAM_BOT_TOKEN/CHAT_ID/TOPIC_ID, тема 245 «Metrics», #3164). Без +# BACKUP_ENV_FILE notify() ищет /etc/default/gendesign-backup, где канала на +# этом хосте нет, — алерты молча не уходят никуда. +0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 # ── Бэкап волта Obsidian (CouchDB) ────────────────────────────────────────── # Волт остаётся на этом хосте вместе с Forgejo и GlitchTip, поэтому запись @@ -33,11 +37,11 @@ # file-level копия работающего узла может оказаться рассогласованной. Время — # 04:45, между forgejo-бэкапом (04:15) и недельной уборкой докера (04:00 вс), # чтобы три задачи не дрались за диск и сеть одновременно. -45 4 * * * bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 +45 4 * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/backup-couchdb.sh >> /opt/gendesign/logs/backup-couchdb.log 2>&1 # Сторож пропущенных прогонов для бэкапа волта. Порог 26ч — как у остальных: # сутки плюс запас на сдвиг прогона. -0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 +0 * * * * BACKUP_ENV_FILE=/opt/gendesign/secrets/backup-notify.env bash /opt/gendesign/ops/check-backup-staleness.sh /opt/gendesign/backups/couchdb/.last_success 26 "couchdb backup" >> /opt/gendesign/logs/backup-staleness.log 2>&1 # ── Уборка докера ─────────────────────────────────────────────────────────── # Остаётся здесь, потому что здесь остаются CI-раннеры (#2881) — именно их diff --git a/ops/gendesign-backup-couchdb.default.example b/ops/gendesign-backup-couchdb.default.example index 81bccbcd..1766b312 100644 --- a/ops/gendesign-backup-couchdb.default.example +++ b/ops/gendesign-backup-couchdb.default.example @@ -51,5 +51,10 @@ COUCHDB_S3_SECRET_KEY= # Переопределяй только если этому бэкапу нужен ОТДЕЛЬНЫЙ адресат: #TELEGRAM_BOT_TOKEN= #TELEGRAM_CHAT_ID= +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 # Либо укажи другой файл, откуда notify() возьмёт настройки канала: #BACKUP_ENV_FILE=/etc/default/gendesign-backup diff --git a/ops/gendesign-backup-forgejo.default.example b/ops/gendesign-backup-forgejo.default.example index 78e9d4e5..b24dc34f 100644 --- a/ops/gendesign-backup-forgejo.default.example +++ b/ops/gendesign-backup-forgejo.default.example @@ -27,6 +27,17 @@ #FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED #FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED +# --- оповещения (Telegram) --- +# backup-forgejo.sh читает этот файл сам, поэтому канал задаётся здесь: без +# токена и чата notify() ищет их в /etc/default/gendesign-backup. +#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN +#TELEGRAM_CHAT_ID=123456789 +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 + # --- optional overrides (defaults are sensible; uncomment only to change) --- #FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match # the policy's Resource path exactly diff --git a/ops/gendesign-backup.default.example b/ops/gendesign-backup.default.example index ee6da0ad..a01c754f 100644 --- a/ops/gendesign-backup.default.example +++ b/ops/gendesign-backup.default.example @@ -24,13 +24,8 @@ # # Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron # entry, see its header) alerts when a backup's sentinel goes stale. It reads -# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from -# /etc/default/gendesign-uptime — deliberately a separate config so backup -# alerting doesn't depend on the uptime watchdog's env file existing). -# SAME variable names as ops/gendesign-uptime.default.example — point both -# files at the same bot/chat if you want one Telegram destination for -# everything, or use different bots/chats if you'd rather split the noise. -# Without these two set, ops/check-backup-staleness.sh still logs, just +# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID/TELEGRAM_TOPIC_ID from THIS file. +# Without the first two set, ops/check-backup-staleness.sh still logs, just # doesn't send a Telegram alert. # --- S3 off-site upload (Selectel S3-compatible). All four REQUIRED (#3085) — @@ -41,9 +36,14 @@ #S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY #S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY -# --- missed-run alerting (Telegram, shared bot with uptime watchdog) --- +# --- missed-run alerting (Telegram) --- #TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN #TELEGRAM_CHAT_ID=123456789 +# Тема форума (#3164). Бэкапы — инфраструктура, по решению #3163 они идут в +# «Metrics» (245); «алерты» (158) — только для клиентских инцидентов. Без +# переменной сообщение уходит в общую тему. Держать В ТОМ ЖЕ файле, что токен и +# чат: notify() читает env-файл, только если их нет в окружении. +#TELEGRAM_TOPIC_ID=245 # --- optional overrides (defaults are sensible; uncomment only to change) --- #BACKUP_ALLOW_LOCAL_ONLY=1 # explicit escape hatch: run without S3 creds and diff --git a/ops/gendesign-uptime.default.example b/ops/gendesign-uptime.default.example deleted file mode 100644 index db99c75f..00000000 --- a/ops/gendesign-uptime.default.example +++ /dev/null @@ -1,28 +0,0 @@ -# Environment file for ops/uptime-healthcheck.sh (external uptime watchdog, #75). -# -# Install as a ROOT-OWNED, chmod-600 file that is NOT in git, on whatever host -# runs the cron (ideally a host OTHER than the prod VM, so it survives a full -# VPS outage): -# sudo cp ops/gendesign-uptime.default.example /etc/default/gendesign-uptime -# sudo chmod 600 /etc/default/gendesign-uptime -# sudo $EDITOR /etc/default/gendesign-uptime # fill in real Telegram creds -# -# uptime-healthcheck.sh sources this file if present. With NO Telegram vars set, -# it still logs up/down but sends no alert (useful for a dry run first). -# -# Get a bot token from @BotFather; get your chat_id by messaging the bot then -# GET https://api.telegram.org/bot/getUpdates and reading message.chat.id. - -# --- Telegram alerting (both required to enable alerts) --- -#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN -#TELEGRAM_CHAT_ID=123456789 - -# --- optional overrides (defaults are sensible; uncomment only to change) --- -#BASE_URL=https://gendsgn.ru -#STATE_FILE=/var/tmp/gendesign-uptime-state -#CURL_TIMEOUT=15 -#RETRIES=2 -#RETRY_SLEEP=5 -# Custom check list (newline-separated "label|path|expected_status"): -#CHECKS="health|/health|200 -#market-pulse|/api/v1/analytics/market-pulse|200" diff --git a/ops/lib-backup.sh b/ops/lib-backup.sh index 417bfe3f..2d38960a 100755 --- a/ops/lib-backup.sh +++ b/ops/lib-backup.sh @@ -5,9 +5,7 @@ # SOURCED, not executed directly — no shebang execution of its own. Inherits # the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins + # coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by -# notify() when Telegram vars are actually set — curl is already a hard -# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no -# new dependency). +# notify() when Telegram vars are actually set). # # Load with (script computes its own dir so this works regardless of cron's # CWD or which repo subdir the caller lives in): @@ -18,14 +16,8 @@ log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; } # --- notify ------------------------------------------------------------ -# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) — -# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN / -# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file -# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so -# backup alerting doesn't depend on the uptime watchdog's config being -# present, and vice versa. Point both files at the same bot/chat if you want -# one Telegram destination for everything — that's an ops choice, not this -# script's concern. +# TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / TELEGRAM_TOPIC_ID from the env +# (or ${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}). Topic: #3164. # # No-op (logs only) when unset — this is the extension point: to wire a # different channel later, edit ONLY this function; every caller in this repo @@ -153,12 +145,9 @@ sentinel_age_hours() { } # --- transition-tracked alert state -------------------------------------- -# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a -# flat "