gendesign/ops/lib-backup.sh
bot-backend 8945ea5d04
Some checks failed
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m6s
CI / openapi-codegen-check (pull_request) Successful in 1m55s
fix(ops): оповещения уходят в тему «алерты», а не в переговорку (#2203)
Канал включили — и алерты бэкапов посыпались в ОБЩУЮ тему форума. В форуме
Telegram адрес сообщения это пара «чат + тема»: без message_thread_id всё
попадает в General, причём без единой ошибки. sendMessage возвращает 200,
доставка «успешна», просто не туда.

Отказ того же класса, что и всё остальное сегодня: зелено везде, а человек,
которому адресован алерт, его не видит.

Оба отправителя (ops/lib-backup.sh и ops/uptime-healthcheck.sh) получили
условную подстановку ${TELEGRAM_TOPIC_ID:+-d "message_thread_id=..."}.
Условная намеренно: пустой message_thread_id= Telegram отвергает вместе со
всем сообщением, а молчащий алерт хуже алерта не в той теме. Нет переменной —
нет параметра, поведение прежнее бит в бит.

Тест ИСПОЛНЯЕТ настоящий notify(), извлечённый из файла построчно, подсовывая
подставной curl и проверяя, что реально ушло бы в сеть. Проверять подстроку в
файле бессмысленно: она может стоять в мёртвой ветке. Копировать функцию в
тест — тоже: копия разойдётся с оригиналом на первой правке. Сорсить файл
целиком нельзя: у uptime-healthcheck.sh нет guard'а по BASH_SOURCE, и сорсинг
запустил бы настоящие сетевые проверки.
2026-08-27 13:12:10 +03:00

197 lines
10 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Shared helpers for ops/backup.sh, ops/backup-forgejo.sh, and
# tradein-mvp/deploy/backup-tradein-db.sh (#2203, missed-run detection).
#
# 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).
#
# Load with (script computes its own dir so this works regardless of cron's
# CWD or which repo subdir the caller lives in):
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# source "$SCRIPT_DIR/lib-backup.sh" # caller is in ops/
# source "$SCRIPT_DIR/../../ops/lib-backup.sh" # caller is in tradein-mvp/deploy/
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.
#
# 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
# goes through notify(), never curl/telegram directly.
notify() {
local text="$1"
local backup_env="${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}"
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
# shellcheck source=/dev/null
[[ -f "$backup_env" ]] && source "$backup_env"
fi
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
log "NOTIFY (telegram disabled — set TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID in ${backup_env}): $text"
notify_fallback_mail "$text"
return 0
fi
# #3059: одиночный curl отправлял алерт «на удачу». Замер 26.08 с Poincare —
# 3 отказа на 40 подключений (7.5%) к закреплённому (#3093) 149.154.167.220,
# все таймаутом на СТАДИИ ПОДКЛЮЧЕНИЯ. То есть каждый двадцатый-тридцатый
# алерт впустую сжигал запасной канал (почту) вместо того, чтобы просто
# переподключиться: почта — последнее средство на случай, когда Telegram
# недоступен ПО-НАСТОЯЩЕМУ, а не пропустил один SYN.
#
# Повтор безопасен: отказ происходит до отправки запроса, поэтому дубль
# сообщения практически исключён, а пропущенный алерт о неудавшемся бэкапе
# — именно то, ради предотвращения чего этот файл и существует.
local attempt
for attempt in 1 2 3; do
if curl -fsS --max-time 15 \
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
${TELEGRAM_TOPIC_ID:+-d "message_thread_id=${TELEGRAM_TOPIC_ID}"} \
-d "disable_web_page_preview=true" \
--data-urlencode "text=${text}" \
>/dev/null 2>&1; then
[[ "$attempt" -gt 1 ]] && log "telegram sendMessage: доставлено с попытки ${attempt}"
return 0
fi
[[ "$attempt" -lt 3 ]] && sleep "${NOTIFY_RETRY_DELAY:-2}"
done
log "WARN: telegram sendMessage failed — 3 попытки подряд, пробую запасной канал"
notify_fallback_mail "$text"
}
# --- запасной канал оповещения (#3059) ----------------------------------
# Зачем: раньше единственным каналом был Telegram, и `|| log WARN` означало,
# что при его недоступности алерт просто ТЕРЯЛСЯ — оставалась строка в логе,
# который никто не читает, пока не случится беда. Самоскрывающаяся поломка:
# канал, которым мы узнаём о проблемах, сам и есть проблема.
#
# Это не паранойя, а замер (#3059, 2026-08-23): с нового хоста Poincare из семи
# публикуемых адресов api.telegram.org отвечает РОВНО ОДИН (149.154.167.220),
# и скан всей подсети 149.154.167.0/24 не нашёл больше ни одного. Через прокси
# Telegram не проходит вовсе — узлы российские. После переезда весь
# Telegram-канал висит на одном адресе, без запасного.
#
# Почему почта: с Poincare проверено — smtp.beget.com:465 OPEN (587 закрыт).
# Транспорт — тот же curl, который уже является жёсткой зависимостью файла;
# новых пакетов не требуется.
#
# Настраивается тем же env-файлом, что и Telegram. Не задано — ведёт себя как
# раньше. Кредов в репозитории нет:
# ALERT_SMTP_URL=smtps://smtp.beget.com:465
# ALERT_SMTP_USER= / ALERT_SMTP_PASS= / ALERT_MAIL_FROM= / ALERT_MAIL_TO=
notify_fallback_mail() {
local text="$1"
if [[ -z "${ALERT_SMTP_URL:-}" || -z "${ALERT_SMTP_USER:-}" \
|| -z "${ALERT_SMTP_PASS:-}" || -z "${ALERT_MAIL_FROM:-}" \
|| -z "${ALERT_MAIL_TO:-}" ]]; then
# ГРОМКО: это последний рубеж, тишина здесь = потерянный алерт.
log "АЛЕРТ НЕ ДОСТАВЛЕН (telegram недоступен, почта не настроена): $text"
return 1
fi
local subject body rc=0
subject=$(printf '%s' "$text" | head -c 120 | tr '\n' ' ')
body=$(printf 'From: %s\nTo: %s\nSubject: [gendesign] %s\n\n%s\n' \
"$ALERT_MAIL_FROM" "$ALERT_MAIL_TO" "$subject" "$text")
# Пароль идёт через --user, поэтому stderr curl целиком не печатаем — в нём
# может оказаться строка подключения. Логируем только код возврата.
printf '%s' "$body" | curl -fsS --max-time 30 --ssl-reqd \
--url "$ALERT_SMTP_URL" \
--user "${ALERT_SMTP_USER}:${ALERT_SMTP_PASS}" \
--mail-from "$ALERT_MAIL_FROM" \
--mail-rcpt "$ALERT_MAIL_TO" \
--upload-file - >/dev/null 2>&1 || rc=$?
if (( rc == 0 )); then
log "Алерт доставлен запасным каналом (почта) вместо Telegram"
return 0
fi
log "АЛЕРТ НЕ ДОСТАВЛЕН НИ ОДНИМ КАНАЛОМ (telegram упал, curl smtp rc=${rc}): $text"
return 1
}
# --- sentinel (missed-run detection) ------------------------------------
# write_sentinel <path> — call ONLY after every guard in the caller has
# already passed (mirrors the existing "never mark success before a dump is
# verified good" discipline in backup.sh/backup-tradein-db.sh). Content is
# just an ISO-8601 UTC timestamp for human debugging; the actual staleness
# check below is based on the file's mtime, not on parsing that content.
write_sentinel() {
local sentinel_file="$1"
mkdir -p "$(dirname "$sentinel_file")"
date -u +'%Y-%m-%dT%H:%M:%SZ' > "$sentinel_file"
}
# sentinel_age_hours <path> — echoes the sentinel's age in whole hours on
# stdout. Returns 1 (nothing echoed) if the file doesn't exist or its mtime
# can't be read — caller treats that as "stale" (a backup that never
# succeeded is exactly the case this exists to catch).
sentinel_age_hours() {
local sentinel_file="$1" now_epoch sentinel_epoch
[[ -f "$sentinel_file" ]] || return 1
now_epoch=$(date -u +%s)
# GNU stat (prod VM, Debian) first; BSD stat fallback (local/macOS dev).
sentinel_epoch=$(stat -c %Y "$sentinel_file" 2>/dev/null || stat -f %m "$sentinel_file" 2>/dev/null) || return 1
echo $(( (now_epoch - sentinel_epoch) / 3600 ))
}
# --- transition-tracked alert state --------------------------------------
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
# flat "<label> <status>" file, one line per label, so repeated runs alert
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
# single run — avoids Telegram spam from an hourly staleness-check cron.
# Intentionally a separate, independent implementation (not shared code with
# uptime-healthcheck.sh) — that script is out of scope for this change.
backup_prev_status() {
local state_file="$1" label="$2" v
[[ -f "$state_file" ]] || { echo "unknown"; return; }
v="$(grep -E "^${label} " "$state_file" 2>/dev/null | tail -1 | awk '{print $2}')"
echo "${v:-unknown}"
}
backup_set_status() {
local state_file="$1" label="$2" status="$3" tmp
mkdir -p "$(dirname "$state_file")"
tmp="$(mktemp)"
if [[ -f "$state_file" ]]; then
grep -vE "^${label} " "$state_file" > "$tmp" 2>/dev/null || true
fi
echo "${label} ${status}" >> "$tmp"
mv "$tmp" "$state_file"
}
# --- dump integrity (opt-in for NEW scripts only) ------------------------
# Identical logic to the verify_dump_integrity() already duplicated in
# ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh — deliberately
# NOT deduped there (those two are proven/hardened; don't touch working
# code for a refactor nobody asked for). This copy exists so NEW scripts
# (ops/backup-forgejo.sh) get the same guard without a third copy-paste.
verify_dump_integrity() {
local file="$1" trailer="$2" label="$3"
if ! gzip -t "$file" 2>/dev/null; then
log "ERROR: ${label} failed gzip integrity check -> $file" >&2
return 1
fi
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ERROR: ${label} missing trailer '${trailer}' -> $file (truncated dump?)" >&2
return 1
fi
return 0
}