Бэкапы: отчёты и команды в Telegram-боте
Каждый run и verify шлёт в чаты BACKUP_TELEGRAM_CHAT_ID одно сообщение: успех со сводкой (игроки/партии, по репозиторию снимок, число снимков, размер) или сбой с причиной — в том числе ранний отказ (нет пароля, занят lock, битая БД): обработчик EXIT берёт текст последнего die. Недоступный Telegram бэкап не ломает. Бот-слушатель (fs-backup telegram bot, long polling, стартует из entrypoint в фоне) отвечает на /backups (хранящиеся снимки), /status и /help только чатам из белого списка; чужие chat id пишет в журнал. telegram chats — узнать свой id, telegram test — проверить связь. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения (тот же бот), BACKUP_TELEGRAM_BOT_TOKEN переопределяет. В образ добавлен curl. Настройка — deploy/backup/README.md, раздел 10. #83 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
+232
-4
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
|
||||
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
|
||||
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
|
||||
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
|
||||
TG_TOKEN="${BACKUP_TELEGRAM_BOT_TOKEN:-}"
|
||||
TG_CHATS="${BACKUP_TELEGRAM_CHAT_ID:-}"
|
||||
TG_API="${BACKUP_TELEGRAM_API:-https://api.telegram.org}"
|
||||
LAST_ERROR="" # текст последнего die — причина сбоя в отчёте Telegram
|
||||
|
||||
# ─── Общие функции ────────────────────────────────────────────────────────────
|
||||
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
||||
die() { log "ОШИБКА: $*"; exit 1; }
|
||||
die() { LAST_ERROR="$*"; log "ОШИБКА: $*"; exit 1; }
|
||||
|
||||
require_password() {
|
||||
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
||||
@@ -118,6 +122,99 @@ human() { # байты → «12.3 MB»
|
||||
|
||||
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
||||
|
||||
# ─── Telegram (#83) ───────────────────────────────────────────────────────────
|
||||
# Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота
|
||||
# BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не
|
||||
# страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст.
|
||||
tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; }
|
||||
|
||||
tg_escape() { sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'; }
|
||||
|
||||
tg_chat_ids() { printf '%s' "$TG_CHATS" | tr ',;' ' '; }
|
||||
|
||||
tg_api() { # tg_api <метод> [аргументы curl…] → JSON ответа
|
||||
_method="$1"
|
||||
shift
|
||||
curl -fsS --max-time 70 "$@" "$TG_API/bot$TG_TOKEN/$_method" 2>/dev/null
|
||||
}
|
||||
|
||||
tg_post() { # tg_post <chat_id> <html> — одно сообщение в один чат
|
||||
tg_api sendMessage -o /dev/null \
|
||||
--data-urlencode "chat_id=$1" \
|
||||
--data-urlencode "text=$2" \
|
||||
--data-urlencode "parse_mode=HTML" \
|
||||
--data-urlencode "disable_web_page_preview=true"
|
||||
}
|
||||
|
||||
tg_send() { # tg_send <html> — во все чаты владельца
|
||||
tg_enabled || return 0
|
||||
for _chat in $(tg_chat_ids); do
|
||||
tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено."
|
||||
done
|
||||
}
|
||||
|
||||
repo_summary() { # repo_summary <repo> → «снимков 30 · 45.2 MB»
|
||||
if _st="$(r "$1" stats --mode raw-data --json 2>/dev/null)"; then
|
||||
printf '%s' "$_st" | jq -r '"\(.snapshots_count)\t\(.total_size)"' | {
|
||||
IFS="$(printf '\t')" read -r _cnt _size
|
||||
echo "снимков $_cnt · $(human "$_size")"
|
||||
}
|
||||
else
|
||||
echo "размер недоступен"
|
||||
fi
|
||||
}
|
||||
|
||||
# Итог run/verify одним сообщением. Списки — «репо:поле:поле» через пробел; их собирают
|
||||
# cmd_run/cmd_verify, а вызывает обработчик EXIT — так отчёт уходит и при раннем отказе.
|
||||
notify_run() { # notify_run <код выхода>
|
||||
tg_enabled || return 0
|
||||
_when="$(date '+%d.%m %H:%M')"
|
||||
_db="БД: игроков $_players, партий $_matches"
|
||||
if [ "$1" -eq 0 ]; then
|
||||
_text="✅ <b>Бэкап</b> $_when
|
||||
$_db"
|
||||
else
|
||||
_text="❌ <b>Бэкап не удался</b> $_when
|
||||
$(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)"
|
||||
[ "$_players" = "?" ] || _text="$_text
|
||||
$_db"
|
||||
fi
|
||||
for _item in $_ok_repos; do
|
||||
_text="$_text
|
||||
• ${_item%%:*}: снимок <code>${_item#*:}</code> · $(repo_summary "${_item%%:*}")"
|
||||
done
|
||||
for _repo in $_failed; do
|
||||
_text="$_text
|
||||
• $_repo: ошибка"
|
||||
done
|
||||
[ "$1" -eq 0 ] || _text="$_text
|
||||
Подробности: <code>docker compose logs backup</code>"
|
||||
tg_send "$_text"
|
||||
}
|
||||
|
||||
notify_verify() { # notify_verify <код выхода>
|
||||
tg_enabled || return 0
|
||||
_when="$(date '+%d.%m %H:%M')"
|
||||
if [ "$1" -eq 0 ]; then
|
||||
_text="🔍 <b>Проверка данных: OK</b> $_when"
|
||||
else
|
||||
_text="❌ <b>Проверка данных не прошла</b> $_when
|
||||
$(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)"
|
||||
fi
|
||||
for _item in $_verified; do
|
||||
_rest="${_item#*:}"
|
||||
_text="$_text
|
||||
• ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}"
|
||||
done
|
||||
for _repo in $_failed; do
|
||||
_text="$_text
|
||||
• $_repo: ошибка"
|
||||
done
|
||||
[ "$1" -eq 0 ] || _text="$_text
|
||||
Подробности: <code>docker compose logs backup</code>"
|
||||
tg_send "$_text"
|
||||
}
|
||||
|
||||
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
||||
db_ok() {
|
||||
[ -s "$1" ] || return 1
|
||||
@@ -236,11 +333,18 @@ cmd_run() {
|
||||
esac
|
||||
shift
|
||||
done
|
||||
_players="?"
|
||||
_matches="?"
|
||||
_ok_repos=""
|
||||
_failed=""
|
||||
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
|
||||
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
|
||||
trap '_rc=$?; notify_run "$_rc"' EXIT
|
||||
require_password
|
||||
setup_ssh
|
||||
take_lock
|
||||
warn_leftovers
|
||||
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
|
||||
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
|
||||
|
||||
log "Снимок данных: консистентная копия БД…"
|
||||
stage_db
|
||||
@@ -252,7 +356,6 @@ cmd_run() {
|
||||
log "БД в порядке: игроков $_players, партий $_matches."
|
||||
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
||||
|
||||
_failed=""
|
||||
for _repo in $(repos); do
|
||||
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
||||
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
||||
@@ -260,6 +363,7 @@ cmd_run() {
|
||||
&& r "$_repo" check >&2; then
|
||||
_sid="$(latest_short_id "$_repo")"
|
||||
mark "run-$_repo" ok "снимок $_sid"
|
||||
_ok_repos="$_ok_repos $_repo:$_sid"
|
||||
log "OK: репозиторий $_repo, снимок $_sid."
|
||||
else
|
||||
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
||||
@@ -365,10 +469,12 @@ cmd_has_snapshots() {
|
||||
|
||||
# ─── verify ───────────────────────────────────────────────────────────────────
|
||||
cmd_verify() {
|
||||
_verified=""
|
||||
_failed=""
|
||||
trap '_rc=$?; notify_verify "$_rc"' EXIT
|
||||
require_password
|
||||
setup_ssh
|
||||
take_lock
|
||||
_failed=""
|
||||
for _repo in $(repos); do
|
||||
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
||||
_tmp="$RUNTIME_DIR/verify.db"
|
||||
@@ -378,6 +484,7 @@ cmd_verify() {
|
||||
&& db_ok "$_tmp"; then
|
||||
_counts="$(db_counts "$_tmp")"
|
||||
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
||||
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
|
||||
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
||||
else
|
||||
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
||||
@@ -779,6 +886,121 @@ cmd_init() {
|
||||
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
||||
}
|
||||
|
||||
# ─── telegram ─────────────────────────────────────────────────────────────────
|
||||
tg_help_text() {
|
||||
cat <<'EOF'
|
||||
<b>Бэкапы Forbidden Stars</b>
|
||||
/backups — хранящиеся снимки по репозиториям
|
||||
/status — последние бэкапы и проверки, размеры
|
||||
Отчёт о каждом бэкапе и проверке приходит сюда сам.
|
||||
EOF
|
||||
}
|
||||
|
||||
tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних
|
||||
[ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; }
|
||||
setup_ssh
|
||||
for _repo in $(repos); do
|
||||
echo "<b>$_repo</b> · $(repo_summary "$_repo")"
|
||||
if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then
|
||||
printf '%s' "$_json" | jq -r '
|
||||
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
|
||||
def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)];
|
||||
sort_by(.time) | .[-10:] | reverse | .[] |
|
||||
"<code>\(.time[5:16] | sub("T"; " "))</code> игроков \(tagval("players:")), партий \(tagval("matches:"))"
|
||||
+ (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)'
|
||||
else
|
||||
echo "репозиторий недоступен или занят — повторите позже"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
}
|
||||
|
||||
tg_allowed() { # tg_allowed <chat_id> — чат из белого списка владельца
|
||||
for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done
|
||||
return 1
|
||||
}
|
||||
|
||||
tg_reply() { # tg_reply <chat_id> <текст команды>
|
||||
# Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота.
|
||||
case "$2" in
|
||||
/backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;;
|
||||
/status*) _reply="<pre>$(cmd_status 2>&1 | head -n 60 | tg_escape)</pre>" ;;
|
||||
*) _reply="$(tg_help_text)" ;;
|
||||
esac
|
||||
[ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup."
|
||||
tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен."
|
||||
}
|
||||
|
||||
# Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие
|
||||
# пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение
|
||||
# restic совместимо с идущим бэкапом.
|
||||
cmd_telegram_bot() {
|
||||
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота."
|
||||
_offset_file="$STATE_DIR/telegram.offset"
|
||||
_updates="$RUNTIME_DIR/telegram-updates"
|
||||
log "Telegram-бот: слушаю команды /backups, /status, /help."
|
||||
while :; do
|
||||
_offset="$(cat "$_offset_file" 2>/dev/null || echo 0)"
|
||||
if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \
|
||||
--data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then
|
||||
sleep 30
|
||||
continue
|
||||
fi
|
||||
# Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются.
|
||||
printf '%s' "$_resp" | jq -r '.result[]? |
|
||||
[.update_id, (.message.chat.id // ""), (.message.from.username // ""),
|
||||
((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \
|
||||
> "$_updates" 2>/dev/null || { sleep 30; continue; }
|
||||
while IFS="$(printf '\037')" read -r _uid _chat _from _text; do
|
||||
echo $((_uid + 1)) > "$_offset_file"
|
||||
[ -n "$_chat" ] || continue
|
||||
if tg_allowed "$_chat"; then
|
||||
tg_reply "$_chat" "$_text"
|
||||
else
|
||||
log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID."
|
||||
fi
|
||||
done < "$_updates"
|
||||
done
|
||||
}
|
||||
|
||||
# Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не
|
||||
# подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он
|
||||
# забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»).
|
||||
cmd_telegram_chats() {
|
||||
[ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN."
|
||||
_resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \
|
||||
|| die "Telegram не ответил (неверный токен или нет сети)."
|
||||
_chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null)
|
||||
| "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')"
|
||||
if [ -z "$_chats" ]; then
|
||||
echo "Сообщений боту нет: напишите ему /start в Telegram и повторите."
|
||||
return 0
|
||||
fi
|
||||
echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):"
|
||||
printf '%s\n' "$_chats"
|
||||
}
|
||||
|
||||
cmd_telegram() {
|
||||
case "${1:-}" in
|
||||
bot) cmd_telegram_bot ;;
|
||||
chats) cmd_telegram_chats ;;
|
||||
test)
|
||||
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)."
|
||||
_sent=0
|
||||
for _chat in $(tg_chat_ids); do
|
||||
if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then
|
||||
_sent=$((_sent + 1))
|
||||
log "Telegram: тестовое сообщение в чат $_chat отправлено."
|
||||
else
|
||||
log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)."
|
||||
fi
|
||||
done
|
||||
[ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло."
|
||||
;;
|
||||
*) die "telegram: укажите chats, test или bot" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
||||
@@ -806,6 +1028,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из
|
||||
export <id|latest> [--repo local|vps] > fs.tar
|
||||
снимок в tar без сжатия (нужен exec -T)
|
||||
|
||||
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
|
||||
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
|
||||
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
|
||||
telegram bot бот-слушатель команд (контейнер запускает его сам)
|
||||
|
||||
Прочее:
|
||||
init создать репозитории (делается автоматически)
|
||||
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
||||
@@ -833,6 +1060,7 @@ case "$_cmd" in
|
||||
restic) cmd_restic "$@" ;;
|
||||
health) cmd_health "$@" ;;
|
||||
has-snapshots) cmd_has_snapshots "$@" ;;
|
||||
telegram) cmd_telegram "$@" ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) usage >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user