Files
ForbiddenStarsApp/deploy/backup/fs-backup.sh
T
NotBigGhostandClaude Opus 5 5b89dc9d2f Бэкапы: отчёты и команды в 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
2026-09-19 02:38:49 +03:00

1067 lines
53 KiB
Bash
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.
#!/bin/sh
# fs-backup — бэкапы Forbidden Stars на restic (работает внутри контейнера backup).
#
# docker compose exec backup fs-backup help
#
# Раскладка снимка: /fs/forbidden_stars.db (консистентная копия БД), /fs/uploads/,
# /fs/achievements/ — та же, что у архивов fs_*.tar.gz старого scripts/backup.sh.
# Репозитории: local — том backup-data (/backup/repo), vps — SFTP на VPS (если задан).
# Подробная инструкция: deploy/backup/README.md.
set -eu
# ─── Пути ─────────────────────────────────────────────────────────────────────
DB_VOLUME=/fs-db # том db-data: живая БД приложения
DB_NAME=forbidden_stars.db
SNAP_ROOT=/fs # корень снимка
DATA_DIRS="uploads achievements" # тома, смонтированные в $SNAP_ROOT/<имя>
LOCAL_REPO=/backup/repo
STATE_DIR=/backup/state # статусы запусков, known_hosts VPS, lock
RUNTIME_DIR=/tmp/fs-backup
NEW=.restore-new # промежуточная директория восстановления (внутри тома)
OLD=.restore-old # текущие данные на время замены
UNPACK=.restore-unpack # распаковка архива при import
PHASE=.restore-phase # фаза замены в томе (для recover после обрыва)
# ─── Настройки (из environment compose) ───────────────────────────────────────
SNAP_HOST="${BACKUP_HOSTNAME:-fs-prod}"
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
KEEP_MONTHLY="${BACKUP_KEEP_MONTHLY:-12}"
VERIFY_SUBSET="${BACKUP_VERIFY_SUBSET:-10%}"
MAX_AGE_HOURS="${BACKUP_MAX_AGE_HOURS:-30}"
VPS_DIR="${BACKUP_VPS_DIR:-/srv/fs-backups/restic}"
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() { LAST_ERROR="$*"; log "ОШИБКА: $*"; exit 1; }
require_password() {
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
}
vps_enabled() { [ -n "${BACKUP_VPS_HOST:-}" ]; }
repos() { if vps_enabled; then echo "local vps"; else echo "local"; fi; }
repo_url() {
case "$1" in
local) echo "$LOCAL_REPO" ;;
vps) echo "sftp:fs-vps:$VPS_DIR" ;;
*) die "неизвестный репозиторий '$1' (local или vps)" ;;
esac
}
check_repo_name() {
case "$1" in
local) ;;
vps) vps_enabled || die "VPS не настроен: BACKUP_VPS_HOST в .env пуст." ;;
*) die "неизвестный репозиторий '$1' (local или vps)" ;;
esac
}
# r <local|vps> <аргументы restic…>
r() {
_url="$(repo_url "$1")"
shift
restic -r "$_url" "$@"
}
# SSH-доступ к VPS для restic (sftp:fs-vps:…): ключ из BACKUP_SSH_KEY_B64, known_hosts
# хранится в томе — ключ хоста VPS запоминается при первом подключении.
setup_ssh() {
vps_enabled || return 0
[ -n "${BACKUP_SSH_KEY_B64:-}" ] || die "BACKUP_VPS_HOST задан, а BACKUP_SSH_KEY_B64 пуст — нечем входить на VPS."
mkdir -p "$HOME/.ssh" "$RUNTIME_DIR"
chmod 700 "$HOME/.ssh" "$RUNTIME_DIR"
printf '%s' "$BACKUP_SSH_KEY_B64" | tr -d ' \r\n\t' | base64 -d > "$RUNTIME_DIR/id_backup" 2>/dev/null \
|| die "BACKUP_SSH_KEY_B64 не декодируется из base64 — скопируйте строку заново (README, шаг 2)."
chmod 600 "$RUNTIME_DIR/id_backup"
grep -q 'PRIVATE KEY' "$RUNTIME_DIR/id_backup" \
|| die "BACKUP_SSH_KEY_B64 — не приватный SSH-ключ (закодирован .pub вместо приватного?)."
cat > "$HOME/.ssh/config" <<EOF
Host fs-vps
HostName ${BACKUP_VPS_HOST}
User ${BACKUP_VPS_USER:-fsbackup}
Port ${BACKUP_VPS_PORT:-22}
IdentityFile $RUNTIME_DIR/id_backup
IdentitiesOnly yes
BatchMode yes
StrictHostKeyChecking accept-new
UserKnownHostsFile $STATE_DIR/known_hosts
ServerAliveInterval 30
ServerAliveCountMax 6
EOF
chmod 600 "$HOME/.ssh/config"
}
# Одна операция за раз (расписание и ручные команды не должны пересекаться).
take_lock() {
exec 9>"$STATE_DIR/lock"
flock -n 9 || die "уже выполняется другая операция бэкапа — дождитесь окончания (docker compose logs -f backup)."
}
mark() { # mark <имя> ok|err <текст>
printf '%s\t%s\n' "$(date +%s)" "$3" > "$STATE_DIR/$1.$2"
if [ "$2" = ok ]; then rm -f "$STATE_DIR/$1.err"; fi
}
human() { # байты → «12.3 MB»
awk -v b="${1:-0}" 'BEGIN { split("B KB MB GB TB", u, " "); i = 1;
while (b >= 1024 && i < 5) { b /= 1024; i++ }
printf (i == 1 ? "%d %s" : "%.1f %s"), b, u[i] }'
}
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/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/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
[ "$(sqlite3 "file:$1?immutable=1" 'PRAGMA integrity_check;' 2>&1)" = "ok" ]
}
db_counts() { # → «игроков|партий»
sqlite3 "file:$1?immutable=1" \
"SELECT (SELECT count(*) FROM users WHERE role='player'), (SELECT count(*) FROM matches);" 2>/dev/null \
|| echo "?|?"
}
# Консистентная копия живой БД в $SNAP_ROOT (VACUUM INTO — один снимок-транзакция,
# приложению не мешает: в WAL читатели не блокируют писателей).
stage_db() {
[ -f "$DB_VOLUME/$DB_NAME" ] || die "БД $DB_VOLUME/$DB_NAME не найдена — приложение ещё ни разу не запускалось?"
rm -f "$SNAP_ROOT/$DB_NAME"
sqlite3 -cmd '.timeout 30000' "$DB_VOLUME/$DB_NAME" "VACUUM INTO '$SNAP_ROOT/$DB_NAME';" >&2 \
|| die "не удалось снять копию БД."
}
# Создать репозиторий, если его ещё нет (формат v2 — со сжатием).
ensure_repo() {
set +e
r "$1" cat config > /dev/null 2> "$RUNTIME_DIR/cat.err"
_rc=$?
set -e
case "$_rc" in
0) return 0 ;;
10)
log "Репозиторий $1 ещё не создан — создаю ($(repo_url "$1"), формат v2 со сжатием)…"
r "$1" init --repository-version 2 >&2
;;
12) die "неверный BACKUP_PASSWORD для репозитория $1 (пароль отличается от того, с которым он создан)." ;;
*) cat "$RUNTIME_DIR/cat.err" >&2; log "Репозиторий $1 недоступен (код restic $_rc)."; return 1 ;;
esac
}
backup_to() { # backup_to <repo> [--tag …]
_repo="$1"
shift
ensure_repo "$_repo" || return 1
r "$_repo" backup --host "$SNAP_HOST" \
--exclude "$NEW" --exclude "$OLD" --exclude "$UNPACK" --exclude "$PHASE" \
"$@" "$SNAP_ROOT" >&2
}
# Именованные снимки (run --tag …, метка keep) и страховочные (pre-restore) политика не
# удаляет — только вручную: fs-backup restic <repo> forget <id> --prune. Три последних
# снимка остаются всегда (keep-daily иначе заменил бы более ранний снимок того же дня).
forget_repo() {
log "Очистка по политике: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. (+ 3 последних, keep и pre-restore)…"
r "$1" forget --host "$SNAP_HOST" --group-by host --keep-last 3 \
--keep-daily "$KEEP_DAILY" --keep-weekly "$KEEP_WEEKLY" --keep-monthly "$KEEP_MONTHLY" \
--keep-tag keep --keep-tag pre-restore --prune >&2
}
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
guard_empty() { # guard_empty <игроков> <партий>
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
for _repo in $(repos); do
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
'[.[0].tags[]? | select(startswith("players:") or startswith("matches:"))
| ltrimstr("players:") | ltrimstr("matches:") | (tonumber? // 0)] | add // 0')" || _prev=0
if [ "${_prev:-0}" -gt 0 ]; then
for _r in $(repos); do mark "run-$_r" err "БД пуста, а в репозитории есть данные — бэкап не сделан (см. README, «Катастрофа»)"; done
log "В БД нет ни игроков, ни партий, а последний снимок в репозитории $_repo — с данными."
log "Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю."
log "Восстановите данные (deploy/backup/README.md, «Катастрофа»). Если БД очищена намеренно:"
die "fs-backup run --allow-empty"
fi
done
}
latest_short_id() {
r "$1" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r '.[0].short_id // empty'
}
# resolve_snapshot <repo> <id|latest> → полный id (или die)
resolve_snapshot() {
if [ "$2" = latest ]; then
_json="$(r "$1" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null)" || _json="[]"
else
_json="$(r "$1" snapshots "$2" --json 2>/dev/null)" || _json="[]"
fi
_id="$(printf '%s' "$_json" | jq -r '.[0].id // empty')"
[ -n "$_id" ] || die "снимок '$2' не найден в репозитории $1 (список: fs-backup list $1)."
echo "$_id"
}
warn_leftovers() {
_vols="$(interrupted_volumes)"
if [ -n "$_vols" ]; then
log "ВНИМАНИЕ: восстановление было прервано (тома: ${_vols% }). Данные могут быть смешанными."
log " Остановите app и выполните: fs-backup recover (README, раздел «Неполадки»)."
fi
}
# ─── run ──────────────────────────────────────────────────────────────────────
cmd_run() {
_kind=manual
_tag=""
_allow_empty=no
while [ $# -gt 0 ]; do
case "$1" in
--scheduled) _kind=scheduled ;;
--allow-empty) _allow_empty=yes ;;
--tag)
[ $# -ge 2 ] || die "--tag требует значение"
shift
case "$1" in *[!A-Za-z0-9._-]*|'') die "метка может содержать только латиницу, цифры, '.', '_', '-'" ;; esac
_tag="$1"
;;
*) die "run: неизвестный аргумент '$1'" ;;
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 '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
log "Снимок данных: консистентная копия БД…"
stage_db
db_ok "$SNAP_ROOT/$DB_NAME" \
|| die "копия БД не прошла PRAGMA integrity_check — снимок НЕ создан, старые снимки не тронуты."
_counts="$(db_counts "$SNAP_ROOT/$DB_NAME")"
_players="${_counts%%|*}"
_matches="${_counts##*|}"
log "БД в порядке: игроков $_players, партий $_matches."
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
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"} \
&& forget_repo "$_repo" \
&& 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"
_failed="$_failed $_repo"
log "ОШИБКА в репозитории $_repo — остальные репозитории продолжаю."
fi
done
[ -z "$_failed" ] || die "бэкап не удался в:$_failed"
log "Бэкап завершён."
}
# ─── list ─────────────────────────────────────────────────────────────────────
cmd_list() {
_repo="${1:-local}"
check_repo_name "$_repo"
require_password
setup_ssh
_json="$(r "$_repo" snapshots --json)" || die "не удалось прочитать репозиторий $_repo."
_n="$(printf '%s' "$_json" | jq 'length')"
echo "Снимки в репозитории $_repo ($(repo_url "$_repo")): $_n шт., время — ${TZ:-UTC}"
[ "$_n" -gt 0 ] || { echo " (пусто)"; return 0; }
echo "ID Время Игроков Партий Размер Прирост Метки"
printf '%s' "$_json" | jq -r '
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
sort_by(.time) | .[] | [
.short_id,
(.time[0:16] | sub("T"; " ")),
tagval("players:"),
tagval("matches:"),
(.summary.total_bytes_processed // 0),
(.summary.data_added_packed // .summary.data_added // 0),
([.tags[]? | select((startswith("players:") or startswith("matches:")) | not)] | join(","))
] | @tsv' |
awk -F '\t' '
function h(b, u, i) { split("B KB MB GB TB", u, " "); i = 1;
while (b >= 1024 && i < 5) { b /= 1024; i++ }
return sprintf(i == 1 ? "%d %s" : "%.1f %s", b, u[i]) }
{ printf "%-9s %-17s %7s %6s %9s %9s %s\n", $1, $2, $3, $4, h($5), h($6), $7 }'
}
# ─── status ───────────────────────────────────────────────────────────────────
print_mark() { # print_mark <подпись> <файл-без-суффикса>
if [ -f "$2.ok" ]; then
echo " $1 $(fmt_epoch "$(cut -f1 "$2.ok")") — $(cut -f2- "$2.ok")"
else
echo " $1 ещё не было"
fi
if [ -f "$2.err" ]; then
echo " последняя ОШИБКА: $(fmt_epoch "$(cut -f1 "$2.err")") — $(cut -f2- "$2.err")"
fi
}
cmd_status() {
require_password
setup_ssh
echo "Бэкапы Forbidden Stars"
echo " Расписание бэкапа: ${BACKUP_SCHEDULE:-отключено}"
echo " Расписание проверки: ${BACKUP_VERIFY_SCHEDULE:-отключено}"
echo " Часовой пояс: ${TZ:-UTC}"
echo " Хранение: $KEEP_DAILY дн. / $KEEP_WEEKLY нед. / $KEEP_MONTHLY мес. + 3 последних, именованные (keep) и pre-restore"
echo " Сжатие restic: $RESTIC_COMPRESSION"
echo " Имя хоста в снимках: $SNAP_HOST"
vps_enabled || echo " VPS: не настроен (только локальная копия)"
for _repo in $(repos); do
echo
echo "[$_repo] $(repo_url "$_repo")"
print_mark "Последний бэкап:" "$STATE_DIR/run-$_repo"
print_mark "Последняя проверка данных:" "$STATE_DIR/verify-$_repo"
if _stats="$(r "$_repo" stats --mode raw-data --json 2>/dev/null)"; then
printf '%s' "$_stats" | jq -r '"\(.snapshots_count)\t\(.total_size)\t\(.total_uncompressed_size // .total_size)\t\(.compression_space_saving // 0 | floor)"' |
while IFS="$(printf '\t')" read -r _cnt _size _raw _saving; do
echo " Снимков: $_cnt"
echo " Размер репозитория: $(human "$_size") (без сжатия $(human "$_raw"), экономия ${_saving}%)"
done
else
echo " Репозиторий: недоступен или ещё не создан"
fi
done
warn_leftovers
}
# ─── health (healthcheck) ─────────────────────────────────────────────────────
cmd_health() {
[ -n "$RESTIC_PASSWORD" ] || { echo "BACKUP_PASSWORD не задан — бэкапы отключены"; exit 1; }
[ -n "${BACKUP_SCHEDULE:-}" ] || { echo "расписание отключено"; exit 0; }
_now="$(date +%s)"
for _repo in $(repos); do
[ -f "$STATE_DIR/run-$_repo.ok" ] || { echo "$_repo: успешных бэкапов ещё не было"; exit 1; }
_ts="$(cut -f1 "$STATE_DIR/run-$_repo.ok")"
if [ $((_now - _ts)) -gt $((MAX_AGE_HOURS * 3600)) ]; then
echo "$_repo: последний успешный бэкап старше $MAX_AGE_HOURS ч"
exit 1
fi
done
echo ok
}
cmd_has_snapshots() {
[ -n "$RESTIC_PASSWORD" ] || exit 1
_n="$(r local snapshots --json 2>/dev/null | jq 'length' 2>/dev/null)" || exit 1
[ "${_n:-0}" -gt 0 ]
}
# ─── verify ───────────────────────────────────────────────────────────────────
cmd_verify() {
_verified=""
_failed=""
trap '_rc=$?; notify_verify "$_rc"' EXIT
require_password
setup_ssh
take_lock
for _repo in $(repos); do
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
_tmp="$RUNTIME_DIR/verify.db"
rm -f "$_tmp"
if r "$_repo" check --read-data-subset="$VERIFY_SUBSET" >&2 \
&& r "$_repo" dump --host "$SNAP_HOST" latest "$SNAP_ROOT/$DB_NAME" > "$_tmp" \
&& 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"
_failed="$_failed $_repo"
log "ОШИБКА проверки репозитория $_repo."
fi
rm -f "$_tmp"
done
[ -z "$_failed" ] || die "проверка не прошла в:$_failed"
log "Проверка завершена."
}
# ─── export ───────────────────────────────────────────────────────────────────
cmd_export() {
_snap=""
_repo=local
while [ $# -gt 0 ]; do
case "$1" in
--repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;;
-*) die "export: неизвестный аргумент '$1'" ;;
*) _snap="$1" ;;
esac
shift
done
check_repo_name "$_repo"
[ ! -t 1 ] || die "export пишет tar в stdout — перенаправьте в файл: docker compose exec -T backup fs-backup export latest > fs.tar"
require_password
setup_ssh
_id="$(resolve_snapshot "$_repo" "${_snap:-latest}")"
log "Экспорт снимка ${_id%"${_id#????????}"} из $_repo (tar без сжатия)…"
r "$_repo" dump --archive tar "$_id:$SNAP_ROOT" /
log "Экспорт завершён."
}
# info <id|latest> [--repo …] → «short_id<TAB>YYYYmmdd_HHMM» (для скриптов на ПК)
cmd_info() {
_snap="latest"
_repo=local
while [ $# -gt 0 ]; do
case "$1" in
--repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;;
*) _snap="$1" ;;
esac
shift
done
check_repo_name "$_repo"
require_password
setup_ssh
_id="$(resolve_snapshot "$_repo" "$_snap")"
r "$_repo" snapshots "$_id" --json | jq -r '.[0] | "\(.short_id)\t\(.time[0:16] | gsub("[-:]"; "") | sub("T"; "_"))"'
}
# ─── restore / import: общая часть ───────────────────────────────────────────
# Отказ, если приложение запущено: любой HTTP-ответ от app:8000 (даже ошибка) значит, что
# uvicorn жив. Остановленный контейнер не резолвится в сети compose — ответа не будет.
ensure_app_stopped() {
if wget -S -T 3 -O /dev/null "http://$APP_HOST:8000/api/health" 2>&1 | grep -q 'HTTP/'; then
die "приложение работает — восстанавливать поверх него нельзя. Сначала: docker compose stop app"
fi
}
check_space() { # check_space <нужно байт>
_need_kb=$(( ${1:-0} * 11 / 10 / 1024 + 51200 )) # +10% и 50 МБ запаса
for _d in "$DB_VOLUME" "$SNAP_ROOT/uploads" "$SNAP_ROOT/achievements"; do
_avail="$(df -Pk "$_d" | awk 'NR == 2 { print $4 }')"
[ "${_avail:-0}" -ge "$_need_kb" ] \
|| die "мало места в $_d: свободно $(human $((_avail * 1024))), нужно ~$(human $((_need_kb * 1024)))."
done
}
clean_staging() {
for _p in "${DB_VOLUME:?}/$NEW" "${SNAP_ROOT:?}/uploads/$UNPACK" "$SNAP_ROOT/uploads/$NEW" "$SNAP_ROOT/achievements/$NEW"; do
[ -e "$_p" ] || continue
chmod -R u+rwx "$_p" 2>/dev/null || true # каталоги без права записи иначе не удалить
rm -rf "$_p"
done
}
prepare_staging() {
clean_staging
mkdir -p "$DB_VOLUME/$NEW"
for _d in $DATA_DIRS; do mkdir -p "$SNAP_ROOT/$_d/$NEW"; done
}
count_files() { find "$1" -type f | wc -l | tr -d ' '; }
# Шаг 3: проверка развёрнутых данных. verify_staging <файлов uploads> <файлов achievements>
verify_staging() {
log "Проверка развёрнутых данных…"
_db="$DB_VOLUME/$NEW/$DB_NAME"
[ -s "$_db" ] || die "в восстанавливаемых данных нет БД — текущие данные НЕ тронуты."
db_ok "$_db" || die "восстановленная БД не прошла PRAGMA integrity_check — текущие данные НЕ тронуты."
_tables="$(sqlite3 "file:$_db?immutable=1" \
"SELECT count(*) FROM sqlite_master WHERE type='table' AND name IN ('users','matches','alembic_version');")"
[ "$_tables" = 3 ] || die "в восстановленной БД нет ключевых таблиц — текущие данные НЕ тронуты."
set -- "$1" "$2"
for _d in $DATA_DIRS; do
_got="$(count_files "$SNAP_ROOT/$_d/$NEW")"
[ "$_got" -eq "$1" ] || die "$_d: развёрнуто $_got файлов, ожидалось $1 — текущие данные НЕ тронуты."
shift
done
_counts="$(db_counts "$_db")"
log "Развёрнутые данные в порядке: игроков ${_counts%%|*}, партий ${_counts##*|}."
}
# Шаг 4: страховочный снимок текущих данных в локальный репозиторий.
pre_restore_snapshot() {
_has_files="$(find "$SNAP_ROOT/uploads" "$SNAP_ROOT/achievements" -mindepth 1 -maxdepth 1 \
! -name "$NEW" ! -name "$OLD" ! -name "$UNPACK" | head -n 1)"
if [ ! -f "$DB_VOLUME/$DB_NAME" ] && [ -z "$_has_files" ]; then
log "Текущих данных нет — страховочный снимок не нужен."
return 0
fi
log "Страховочный снимок текущих данных (метка pre-restore)…"
rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal"
if [ -f "$DB_VOLUME/$DB_NAME" ]; then
if ! sqlite3 -cmd '.timeout 30000' "$DB_VOLUME/$DB_NAME" "VACUUM INTO '$SNAP_ROOT/$DB_NAME';" 2>/dev/null; then
log "Текущая БД не читается штатно — сохраняю её файлы как есть."
rm -f "$SNAP_ROOT/$DB_NAME"
cp "$DB_VOLUME/$DB_NAME" "$SNAP_ROOT/$DB_NAME"
if [ -f "$DB_VOLUME/$DB_NAME-wal" ]; then cp "$DB_VOLUME/$DB_NAME-wal" "$SNAP_ROOT/$DB_NAME-wal"; fi
fi
fi
_counts="$(db_counts "$SNAP_ROOT/$DB_NAME")"
if ! backup_to local --tag pre-restore --tag "players:${_counts%%|*}" --tag "matches:${_counts##*|}"; then
rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal"
die "страховочный снимок не удался — текущие данные НЕ тронуты."
fi
rm -f "$SNAP_ROOT/$DB_NAME" "$SNAP_ROOT/$DB_NAME-wal"
log "Страховочный снимок: $(latest_short_id local)."
}
# Элементы тома, участвующие в замене. Для БД — только файлы SQLite (в томе db-data
# лежат ещё точки монтирования uploads/achievements приложения — их не трогаем).
move_items() { # move_items <из> <в> <all|db>
if [ "$3" = db ]; then
for _f in "$DB_NAME" "$DB_NAME-wal" "$DB_NAME-shm" "$DB_NAME-journal"; do
if [ -e "$1/$_f" ]; then mv "$1/$_f" "$2/" || return 1; fi
done
return 0
fi
for _item in "$1"/* "$1"/.[!.]* "$1"/..?*; do
[ -e "$_item" ] || [ -L "$_item" ] || continue
case "${_item##*/}" in "$NEW"|"$OLD"|"$UNPACK"|"$PHASE") continue ;; esac
mv "$_item" "$2/" || return 1
done
}
volume_path() { if [ "$1" = db ]; then echo "$DB_VOLUME"; else echo "$SNAP_ROOT/$1"; fi; }
volume_mode() { if [ "$1" = db ]; then echo db; else echo all; fi; }
# Замена в одном томе в две фазы; файл фазы ($PHASE) переживает обрыв питания, по нему
# fs-backup recover понимает, как вернуть данные.
# old — фаза A: текущие данные переносятся в .restore-old (новые ещё не тронуты)
# new — фаза B: новые данные переносятся из .restore-new на место (старые целиком в .restore-old)
# swapped — том заменён; committed — заменены все тома, идёт уборка .restore-old
# Код возврата: 0 — заменено, 1 — сбой в фазе A, 2 — сбой в фазе B.
swap_in() { # swap_in <том> <all|db>
echo old > "$1/$PHASE" || return 1
mkdir -p "$1/$OLD" || return 1
move_items "$1" "$1/$OLD" "$2" || return 1
echo new > "$1/$PHASE" || return 1
move_items "$1/$NEW" "$1" "$2" || return 2
rm -rf "${1:?}/$NEW"
echo swapped > "$1/$PHASE" || return 2
}
# Вернуть тому прежнее содержимое. <1|2> — фаза сбоя: в фазе B (и для уже заменённого тома)
# сначала убрать новые данные обратно в .restore-new, в фазе A на месте лежат только старые.
swap_back() { # swap_back <том> <all|db> <1|2>
if [ "$3" = 2 ]; then
mkdir -p "$1/$NEW"
move_items "$1" "$1/$NEW" "$2" || true
fi
move_items "$1/$OLD" "$1" "$2" || true
if rmdir "$1/$OLD" 2>/dev/null || [ ! -d "$1/$OLD" ]; then
rm -f "$1/$PHASE"
else
log "ВНИМАНИЕ: в $1/$OLD остались файлы, которые не удалось вернуть — перенесите их вручную."
fi
}
# Шаги 4–6: страховка, замена через rename в пределах тома, уборка.
apply_staging() { # apply_staging <skip_safety yes|no>
if [ "$1" = no ]; then pre_restore_snapshot; fi
log "Замена данных (rename в пределах каждого тома)…"
_done=""
for _v in db $DATA_DIRS; do
_vp="$(volume_path "$_v")"
_vm="$(volume_mode "$_v")"
set +e
swap_in "$_vp" "$_vm"
_rc=$?
set -e
if [ "$_rc" -ne 0 ]; then
log "Сбой замены в томе '$_v' — возвращаю прежние данные во все тома…"
swap_back "$_vp" "$_vm" "$_rc"
for _u in $_done; do swap_back "$(volume_path "$_u")" "$(volume_mode "$_u")" 2; done
die "замена не удалась, прежние данные возвращены на место."
fi
_done="$_v $_done"
done
for _v in db $DATA_DIRS; do echo committed > "$(volume_path "$_v")/$PHASE"; done
for _v in db $DATA_DIRS; do
_vp="$(volume_path "$_v")"
rm -rf "${_vp:?}/$OLD"
rm -f "$_vp/$PHASE"
done
trap - EXIT
log "Данные восстановлены. Запустите приложение: docker compose start app"
}
# Следы прерванной замены (обрыв питания, kill): файлы фазы или .restore-old в томах.
# Вывод — имена томов в одну строку через пробел.
interrupted_volumes() {
for _v in db $DATA_DIRS; do
_vp="$(volume_path "$_v")"
if [ -f "$_vp/$PHASE" ] || [ -d "$_vp/$OLD" ]; then printf '%s ' "$_v"; fi
done
}
refuse_if_interrupted() {
[ -z "$(interrupted_volumes)" ] \
|| die "найдены следы прерванного восстановления — сначала выполните: fs-backup recover"
}
parse_restore_args() { # общие флаги restore/import → _pos _repo _yes _skip
_pos=""
_repo=local
_yes=no
_skip=no
while [ $# -gt 0 ]; do
case "$1" in
--repo) [ $# -ge 2 ] || die "--repo требует значение"; shift; _repo="$1" ;;
--yes) _yes=yes ;;
--no-pre-restore) _skip=yes ;;
-*) die "неизвестный аргумент '$1'" ;;
*) _pos="$1" ;;
esac
shift
done
}
# ─── restore ──────────────────────────────────────────────────────────────────
cmd_restore() {
parse_restore_args "$@"
[ -n "$_pos" ] || die "укажите снимок: fs-backup restore <id|latest> [--repo local|vps] --yes"
check_repo_name "$_repo"
require_password
setup_ssh
take_lock
ensure_app_stopped
refuse_if_interrupted
_id="$(resolve_snapshot "$_repo" "$_pos")"
r "$_repo" snapshots "$_id" --compact >&2 || true
if [ "$_yes" != yes ]; then
log "Этот снимок ЗАМЕНИТ текущие БД, uploads и achievements (текущие попадут в снимок pre-restore)."
log "Если всё верно — повторите команду с --yes."
exit 2
fi
# Шаг 1: объём и число файлов по манифесту снимка.
_manifest="$RUNTIME_DIR/manifest.tsv"
r "$_repo" ls --json "$_id" | jq -r 'select(.struct_type == "node" and .type == "file") | "\(.size)\t\(.path)"' > "$_manifest" \
|| die "не удалось прочитать содержимое снимка."
_total="$(awk -F '\t' '{ s += $1 } END { print s + 0 }' "$_manifest")"
_n_uploads="$(awk -F '\t' -v p="$SNAP_ROOT/uploads/" 'index($2, p) == 1 { n++ } END { print n + 0 }' "$_manifest")"
_n_ach="$(awk -F '\t' -v p="$SNAP_ROOT/achievements/" 'index($2, p) == 1 { n++ } END { print n + 0 }' "$_manifest")"
log "Снимок: $(human "$_total"), файлов в uploads: $_n_uploads, в achievements: $_n_ach."
check_space "$_total"
# Шаг 2: разворачивание в .restore-new внутри каждого тома.
trap 'clean_staging' EXIT
prepare_staging
log "Разворачивание снимка в промежуточные директории ($NEW)…"
r "$_repo" dump "$_id" "$SNAP_ROOT/$DB_NAME" > "$DB_VOLUME/$NEW/$DB_NAME" \
|| die "не удалось извлечь БД из снимка — текущие данные НЕ тронуты."
for _d in $DATA_DIRS; do
r "$_repo" restore "$_id:$SNAP_ROOT/$_d" --target "$SNAP_ROOT/$_d/$NEW" --verify >&2 \
|| die "не удалось развернуть $_d — текущие данные НЕ тронуты."
done
# Шаг 3–6.
verify_staging "$_n_uploads" "$_n_ach"
apply_staging "$_skip"
}
# ─── import ───────────────────────────────────────────────────────────────────
cmd_import() {
parse_restore_args "$@"
_file="$_pos"
[ -n "$_file" ] || die "укажите архив: fs-backup import /import/fs.tar --yes"
[ -f "$_file" ] || die "файл $_file не найден. Скопируйте архив в контейнер: docker compose cp fs.tar backup:/import/fs.tar"
if [ "$_skip" = no ]; then require_password; fi
take_lock
ensure_app_stopped
refuse_if_interrupted
# Сжатие определяем по сигнатуре: gzip (старые fs_*.tar.gz) или обычный tar (export).
_z=""
if [ "$(head -c 2 "$_file" | od -An -tx1 | tr -d ' \n')" = "1f8b" ]; then _z="-z"; fi
_list="$RUNTIME_DIR/import.list"
tar $_z -tf "$_file" > "$_list.raw" 2>/dev/null || die "архив повреждён или это не tar."
sed -e 's#^\./##' -e 's#^/##' "$_list.raw" > "$_list"
# Tar, обрезанный ровно по границе файла, читается «успешно», но без хвоста. Целый tar
# не меньше суммы (заголовок 512 + данные с выравниванием до 512) по всем записям плюс
# два нулевых блока маркера конца. У .tar.gz обрыв и так ловит CRC gzip.
if [ -z "$_z" ]; then
_min="$(tar -tvf "$_file" | awk '{ t += 512 + int(($3 + 511) / 512) * 512 } END { print t + 1024 }')"
[ "$(stat -c %s "$_file")" -ge "$_min" ] || die "архив обрезан (нет конца tar) — скачайте его заново."
fi
grep -qx "$DB_NAME" "$_list" || die "в архиве нет $DB_NAME — это не бэкап Forbidden Stars."
_n_uploads="$(grep '^uploads/.' "$_list" | grep -vc '/$' || true)"
_n_ach="$(grep '^achievements/.' "$_list" | grep -vc '/$' || true)"
_size="$(stat -c %s "$_file")"
if [ -n "$_z" ]; then _size=$((_size * 2)); fi
log "Архив: $_file ($(human "$(stat -c %s "$_file")")), файлов в uploads: $_n_uploads, в achievements: $_n_ach."
if [ "$_yes" != yes ]; then
log "Архив ЗАМЕНИТ текущие БД, uploads и achievements (текущие попадут в снимок pre-restore)."
log "Если всё верно — повторите команду с --yes."
exit 2
fi
check_space "$_size"
# Шаг 2: распаковка в том uploads (самый большой), затем раскладка по .restore-new томов:
# uploads — rename в пределах тома, БД и achievements (маленькие) — копированием.
trap 'clean_staging' EXIT
prepare_staging
_u="$SNAP_ROOT/uploads/$UNPACK"
mkdir -p "$_u"
log "Распаковка архива в промежуточную директорию…"
tar $_z -xf "$_file" -C "$_u" || die "не удалось распаковать архив — текущие данные НЕ тронуты."
cp "$_u/$DB_NAME" "$DB_VOLUME/$NEW/$DB_NAME"
if [ -d "$_u/uploads" ]; then
rmdir "$SNAP_ROOT/uploads/$NEW"
mv "$_u/uploads" "$SNAP_ROOT/uploads/$NEW"
fi
if [ -d "$_u/achievements" ]; then cp -a "$_u/achievements/." "$SNAP_ROOT/achievements/$NEW/"; fi
rm -rf "$_u"
verify_staging "$_n_uploads" "$_n_ach"
apply_staging "$_skip"
}
# ─── recover: разбор прерванного восстановления ──────────────────────────────
# Если все тома успели замениться (committed) — дочищаем, новые данные остаются.
# Иначе возвращаем каждому тому прежние данные по его фазе; затем restore можно повторить.
cmd_recover() {
take_lock
ensure_app_stopped
_vols="$(interrupted_volumes)"
if [ -z "$_vols" ]; then
log "Следов прерванного восстановления нет — делать ничего не нужно."
return 0
fi
_committed=no
for _v in $_vols; do
if [ "$(cat "$(volume_path "$_v")/$PHASE" 2>/dev/null)" = committed ]; then _committed=yes; fi
done
if [ "$_committed" = yes ]; then
log "Замена успела завершиться во всех томах — дочищаю промежуточные директории…"
for _v in $_vols; do
_vp="$(volume_path "$_v")"
rm -rf "${_vp:?}/$OLD"
rm -f "$_vp/$PHASE"
done
clean_staging
log "Готово: восстановленные данные на месте. Запустите приложение: docker compose start app"
return 0
fi
log "Замена не завершилась — возвращаю прежние данные (тома: ${_vols% })…"
for _v in $_vols; do
_vp="$(volume_path "$_v")"
case "$(cat "$_vp/$PHASE" 2>/dev/null)" in
old) swap_back "$_vp" "$(volume_mode "$_v")" 1 ;;
*) swap_back "$_vp" "$(volume_mode "$_v")" 2 ;;
esac
done
[ -z "$(interrupted_volumes)" ] || die "вернуть удалось не всё — см. сообщения выше."
clean_staging
log "Готово: данные — как до восстановления. Запустите приложение (docker compose start app)"
log "или повторите восстановление нужного снимка."
}
# ─── restic (для продвинутых операций) ────────────────────────────────────────
cmd_restic() {
[ $# -ge 1 ] || die "использование: fs-backup restic <local|vps> <команда restic…>"
_repo="$1"
shift
check_repo_name "$_repo"
require_password
setup_ssh
r "$_repo" "$@"
}
cmd_init() {
require_password
setup_ssh
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:
docker compose exec backup fs-backup <команда>
Повседневное:
status состояние: последние бэкапы/проверки, размеры репозиториев
list [local|vps] хронология снимков (время, игроков, партий, размер, прирост)
run [--tag имя] [--allow-empty]
сделать снимок сейчас; с --tag снимок именованный и очисткой
не удаляется (например, --tag before-update); --allow-empty —
разрешить снимок пустой БД, когда в истории есть данные
verify проверить целостность данных в репозиториях
Восстановление (сначала: docker compose stop app; после: docker compose start app):
restore <id|latest> [--repo local|vps] --yes
восстановить снимок (через промежуточную директорию,
текущие данные сохраняются в снимок pre-restore)
import /import/<файл.tar|.tar.gz> --yes
восстановить из архива (export или старый fs_*.tar.gz);
архив положить в контейнер: docker compose cp fs.tar backup:/import/
recover разобрать прерванное восстановление (обрыв питания и т.п.)
Выгрузка:
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 с настройками контейнера
health проверка для healthcheck
Подробная инструкция: deploy/backup/README.md
EOF
}
# ─── main ─────────────────────────────────────────────────────────────────────
mkdir -p "$RUNTIME_DIR" "$STATE_DIR" 2>/dev/null || true
_cmd="${1:-help}"
[ $# -eq 0 ] || shift
case "$_cmd" in
run) cmd_run "$@" ;;
list) cmd_list "$@" ;;
status) cmd_status "$@" ;;
verify) cmd_verify "$@" ;;
restore) cmd_restore "$@" ;;
import) cmd_import "$@" ;;
recover) cmd_recover "$@" ;;
export) cmd_export "$@" ;;
info) cmd_info "$@" ;;
init) cmd_init "$@" ;;
restic) cmd_restic "$@" ;;
health) cmd_health "$@" ;;
has-snapshots) cmd_has_snapshots "$@" ;;
telegram) cmd_telegram "$@" ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac