run отказывается бэкапить БД без игроков и партий, если последний снимок в репозитории был с данными: новый Pi до восстановления иначе отправил бы пустой снимок на VPS, он стал бы latest, а keep-daily мог вытеснить настоящий снимок того же дня. Обход — run --allow-empty. Очистка дополнительно всегда оставляет 3 последних снимка. Проверено сценарием «Pi умер»: отказ бэкапа → restore latest --repo vps → следующий бэкап проходит. #64 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013jBxs9nBCk5nBdTLzcGz91
839 lines
42 KiB
Bash
839 lines
42 KiB
Bash
#!/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}"
|
||
|
||
# ─── Общие функции ────────────────────────────────────────────────────────────
|
||
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
||
die() { 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"; }
|
||
|
||
# Проверка 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
|
||
require_password
|
||
setup_ssh
|
||
take_lock
|
||
warn_leftovers
|
||
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' 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
|
||
|
||
_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"} \
|
||
&& forget_repo "$_repo" \
|
||
&& r "$_repo" check >&2; then
|
||
_sid="$(latest_short_id "$_repo")"
|
||
mark "run-$_repo" ok "снимок $_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() {
|
||
require_password
|
||
setup_ssh
|
||
take_lock
|
||
_failed=""
|
||
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##*|}"
|
||
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
|
||
}
|
||
|
||
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)
|
||
|
||
Прочее:
|
||
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 "$@" ;;
|
||
help|-h|--help) usage ;;
|
||
*) usage >&2; exit 2 ;;
|
||
esac
|