All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
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 8s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Проверка хостов за шесть дней до переезда показала, что на Poincare пустой crontab: ни бэкапов, ни сторожей, ни конфигов. Сегодня это неважно — базы ещё на Beget, где всё работает. Тридцатого августа они переезжают, и бэкапы там просто не начнутся. Хуже, что отказ был бы тихим. Выгрузка в S3 была опциональной: без четырёх переменных скрипт писал "backup stays local only" и выходил с кодом ноль. Не ошибка, не алерт — успешный прогон. Сторож свежести на новом хосте тоже не установлен, так что промолчали бы оба. Теперь отсутствие любой из S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY роняет прогон с ненулевым кодом — до снятия дампа, потому что незачем тратить время и место на дамп, которому некуда уехать. Образец поведения взят из backup-forgejo.sh, где так было с самого начала. BACKUP_ALLOW_LOCAL_ONLY=1 оставляет прежний путь для машины без ключей. Ревью показало, что первая версия этой форточки возвращала ровно ту дыру, которую чинит PR: локальный прогон всё равно писал сентинел и выходил нулём, поэтому сторож свежести оставался зелёным, а единственным следом был WARNING в логе, который никто не читает. Достаточно было раскомментировать одну строку в /etc/default — и «настроено» выглядело бы неотличимо от рабочего. Теперь такой прогон сентинел НЕ пишет и сразу зовёт notify(). Попутно найден живой баг: в lib-backup.sh внутри блока после || литеральное `\n` разбиралось шеллом как команда `n`, поэтому запасной канал оповещения по почте не срабатывал никогда. То есть при недоступности Telegram мы теряли уведомления молча — тот же класс тишины. restore-drill.sh поставлен в расписание Poincare: он существовал, но не запускался нигде, и восстановимость дампов автоматически не проверялась ни разу. gzip -t и трейлер pg_dump говорят «файл не битый», а это не то же самое, что «база поднимается». У самой дрели своего сторожа нет и MAILTO в cron не задан, поэтому её падение теперь тоже уходит в notify(). Заодно docker rm -f получил -v: регулярный запуск иначе оставлял бы dangling-том размером с базу до воскресной чистки. Имя бакета в примере исправлено на gendsgn-backups — на хостах настроен именно он, forgejo пишет туда же под префиксом forgejo/. Две находки ревью намеренно не закрыты. Фолбэк на общий конфиг в tradein-скрипте остался elif: если появится /etc/default/tradein-backup без S3-переменных, прогон упадёт вместо чтения общего файла — под новой политикой это и есть желаемое поведение. Дрель проверяет только серию gendesign: restore-drill.sh не умеет батч, а расширять его в этом PR значит смешивать задачи. Хостовая часть — установка crontab и конфига на Poincare — в PR не входит. Конфиг уже положен вручную и выгрузка с Poincare проверена живым объектом; crontab ставится в окно переезда, потому что продуктового postgres там пока нет и ночные прогоны падали бы впустую. Refs #3085, #3008
178 lines
9 KiB
Bash
Executable file
178 lines
9 KiB
Bash
Executable file
#!/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
|
||
|
||
curl -fsS --max-time 15 \
|
||
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||
-d "disable_web_page_preview=true" \
|
||
--data-urlencode "text=${text}" \
|
||
>/dev/null 2>&1 \
|
||
|| { log "WARN: telegram sendMessage failed — пробую запасной канал"; 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
|
||
}
|