v1.4 — Настрйока бэкапа БД и хранимых медиа. Третьим контейнером с restic. Бэкап на VPS
This commit is contained in:
+33
-9
@@ -90,16 +90,40 @@ IMAGE_TAG=latest
|
|||||||
#APP_MEM_LIMIT=512m
|
#APP_MEM_LIMIT=512m
|
||||||
#APP_CPUS=1.5
|
#APP_CPUS=1.5
|
||||||
|
|
||||||
# ─── БЭКАП (оффсайт-копия на VPS; читает scripts/backup.sh на хосте Pi) ───────
|
# ─── БЭКАПЫ (контейнер backup: restic; подробно — deploy/backup/README.md) ────
|
||||||
# Полный бэкап (БД + uploads + achievements) кладётся локально в backups/, и — если задан
|
# Снимки БД + uploads + achievements по расписанию: локально на Pi (том backup-data) и —
|
||||||
# BACKUP_VPS_HOST — копируется на VPS (Pi → VPS push, т.к. Pi за CGNAT). Пусто = только локально.
|
# если задан BACKUP_VPS_HOST — на VPS по SFTP. Шифрование, дедупликация, сжатие — restic.
|
||||||
# Ключ бэкапа — deploy/backup/id_backup (в git НЕ идёт); его pubkey → authorized_keys у backup@VPS.
|
#
|
||||||
|
# Пароль шифрования репозиториев. ПОТЕРЯЕТЕ ПАРОЛЬ — НИ ОДИН БЭКАП НЕ ПРОЧИТАТЬ.
|
||||||
|
# Сохраните его в менеджер паролей. Пусто = бэкапы отключены. Без символа '$'.
|
||||||
|
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(32))"
|
||||||
|
BACKUP_PASSWORD=
|
||||||
|
# Когда делать бэкап и проверку данных (cron: минута час день месяц день_недели; TZ ниже).
|
||||||
|
BACKUP_SCHEDULE="0 4 * * *"
|
||||||
|
BACKUP_VERIFY_SCHEDULE="30 5 * * 0"
|
||||||
|
BACKUP_TZ=Europe/Moscow
|
||||||
|
# Сколько хранить: последний снимок каждого дня / недели / месяца.
|
||||||
|
# Именованные снимки (fs-backup run --tag …) и страховочные pre-restore не удаляются.
|
||||||
|
BACKUP_KEEP_DAILY=14
|
||||||
|
BACKUP_KEEP_WEEKLY=8
|
||||||
|
BACKUP_KEEP_MONTHLY=12
|
||||||
|
# Сжатие restic: auto (быстрее) | max (плотнее) | off.
|
||||||
|
BACKUP_COMPRESSION=max
|
||||||
|
# Контейнер помечается unhealthy, если последний успешный бэкап старше стольких часов.
|
||||||
|
BACKUP_MAX_AGE_HOURS=30
|
||||||
|
# Оффсайт-копия на VPS (SFTP). Пусто = только локальная копия на Pi.
|
||||||
BACKUP_VPS_HOST=
|
BACKUP_VPS_HOST=
|
||||||
BACKUP_VPS_USER=backup
|
BACKUP_VPS_USER=fsbackup
|
||||||
BACKUP_VPS_DIR=/srv/fs-backups
|
BACKUP_VPS_PORT=22
|
||||||
BACKUP_VPS_KEY=deploy/backup/id_backup
|
BACKUP_VPS_DIR=/srv/fs-backups/restic
|
||||||
BACKUP_KEEP_LOCAL=14
|
# Приватный SSH-ключ для VPS в base64 одной строкой (как TUNNEL_KEY_B64). На ПК ключ лежит
|
||||||
BACKUP_KEEP_REMOTE=30
|
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
||||||
|
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
||||||
|
BACKUP_SSH_KEY_B64=
|
||||||
|
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
||||||
|
# docker-compose.yml прода.
|
||||||
|
BACKUP_PI_SSH=pi@192.168.1.10
|
||||||
|
BACKUP_PI_DIR=~/forbidden-stars
|
||||||
|
|
||||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
||||||
|
|||||||
+1
-3
@@ -1,10 +1,8 @@
|
|||||||
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
# ── Нормализация переводов строк ──────────────────────────────────────────────
|
||||||
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
|
# Шелл-скрипты обязаны быть с LF: в Linux-контейнере и на Pi CRLF ломает shebang.
|
||||||
# entrypoint.sh контейнер чинит сам (sed в Dockerfile), но backup.sh запускается
|
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
||||||
# с хоста Pi — для него LF в репозитории критичен.
|
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
backend/entrypoint.sh text eol=lf
|
backend/entrypoint.sh text eol=lf
|
||||||
scripts/backup.sh text eol=lf
|
|
||||||
|
|
||||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ─────
|
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ─────
|
||||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ frontend/.vite/
|
|||||||
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
|
# Сгенерированный снапшот OpenAPI (контракт фронта закоммичен в schema.d.ts)
|
||||||
backend/openapi.json
|
backend/openapi.json
|
||||||
|
|
||||||
# Бэкапы (создаёт scripts/backup.sh на Pi)
|
# Бэкапы, скачанные на ПК (scripts/fs-backup.* pull) — не зашифрованы, в git не идут
|
||||||
backups/
|
backups/
|
||||||
|
|
||||||
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
# Фронт-макеты для проработки UI (локальные прототипы, не для репозитория)
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ HTTPS твоими сертификатами и проксирует трафи
|
|||||||
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`.
|
2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`.
|
||||||
3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или
|
3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или
|
||||||
ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`.
|
ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`.
|
||||||
|
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
|
||||||
|
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их и проверяют
|
||||||
|
восстановление на тест-клоне.
|
||||||
|
|
||||||
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК,
|
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК,
|
||||||
в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны.
|
в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны.
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# В контекст сборки попадают ТОЛЬКО файлы образа. Всё остальное (в первую очередь приватный
|
||||||
|
# ключ deploy/backup/id_backup, если он лежит здесь на ПК) в образ не попадёт.
|
||||||
|
*
|
||||||
|
!Dockerfile
|
||||||
|
!fs-backup.sh
|
||||||
|
!entrypoint.sh
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Контейнер бэкапов: restic (дедупликация, шифрование, сжатие zstd) + расписание supercronic.
|
||||||
|
# Снимает консистентную копию SQLite и файлы томов приложения, хранит снимки локально (том
|
||||||
|
# backup-data) и на VPS (SFTP). Управление — команда fs-backup (см. deploy/backup/README.md).
|
||||||
|
FROM restic/restic:0.19.1
|
||||||
|
|
||||||
|
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
|
||||||
|
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
||||||
|
RUN apk add --no-cache sqlite supercronic tini
|
||||||
|
|
||||||
|
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
||||||
|
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
||||||
|
RUN addgroup -g 10001 fsbackup \
|
||||||
|
&& adduser -D -u 10001 -G fsbackup -h /home/fsbackup fsbackup \
|
||||||
|
&& mkdir -p /fs/uploads /fs/achievements /fs-db /backup/repo /backup/state /backup/cache /import \
|
||||||
|
&& chown -R 10001:10001 /fs /fs-db /backup /import /home/fsbackup
|
||||||
|
|
||||||
|
COPY fs-backup.sh /usr/local/bin/fs-backup
|
||||||
|
COPY entrypoint.sh /usr/local/bin/fs-backup-entrypoint
|
||||||
|
RUN sed -i 's/\r$//' /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint \
|
||||||
|
&& chmod 755 /usr/local/bin/fs-backup /usr/local/bin/fs-backup-entrypoint
|
||||||
|
|
||||||
|
ENV HOME=/home/fsbackup \
|
||||||
|
RESTIC_CACHE_DIR=/backup/cache \
|
||||||
|
TZ=Europe/Moscow
|
||||||
|
|
||||||
|
USER 10001:10001
|
||||||
|
WORKDIR /home/fsbackup
|
||||||
|
|
||||||
|
# Здоров = последний успешный бэкап свежее BACKUP_MAX_AGE_HOURS (в каждом репозитории).
|
||||||
|
HEALTHCHECK --interval=10m --timeout=60s --start-period=2h --retries=1 \
|
||||||
|
CMD ["fs-backup", "health"]
|
||||||
|
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/fs-backup-entrypoint"]
|
||||||
@@ -0,0 +1,980 @@
|
|||||||
|
# Бэкапы Forbidden Stars
|
||||||
|
|
||||||
|
Пошаговая инструкция: как включить бэкапы, проверить, что они работают, скачать их на ПК
|
||||||
|
и восстановить данные — в том числе на новом Pi, если старый умер.
|
||||||
|
|
||||||
|
Команды даны целиком — копируйте их как есть. Где нужно подставить своё значение, это
|
||||||
|
написано в угловых скобках: `<IP-адрес-Pi>`. Каждый шаг заканчивается блоком
|
||||||
|
**«Что должно получиться»** — не переходите к следующему шагу, пока не получили то же самое.
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
0. [Как это устроено](#0-как-это-устроено)
|
||||||
|
1. [Пароль шифрования](#шаг-1-пароль-шифрования) — ПК
|
||||||
|
2. [SSH-ключ для VPS](#шаг-2-ssh-ключ-для-vps) — ПК
|
||||||
|
3. [VPS: пользователь только для SFTP](#шаг-3-vps-пользователь-только-для-sftp) — VPS
|
||||||
|
4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК
|
||||||
|
5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi
|
||||||
|
6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК
|
||||||
|
7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК
|
||||||
|
8. [Восстановление прода](#8-восстановление-прода) — Pi
|
||||||
|
9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi
|
||||||
|
10. [Повседневные действия](#10-повседневные-действия)
|
||||||
|
11. [Неполадки](#11-неполадки)
|
||||||
|
12. [Справочник: команды и переменные](#12-справочник-команды-и-переменные)
|
||||||
|
13. [Итоговый чек-лист](#13-итоговый-чек-лист)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Как это устроено
|
||||||
|
|
||||||
|
```
|
||||||
|
Raspberry Pi (прод) VPS 186.246.51.17
|
||||||
|
┌──────────────────────────────────────────┐ ┌─────────────────────────────┐
|
||||||
|
│ app ──► тома: БД, uploads, achievements │ │ /srv/fs-backups/restic │
|
||||||
|
│ ▲ читает (консистентно) │ SFTP │ (копия №2, зашифрована) │
|
||||||
|
│ backup ──────┘ │ ────────────► │ пользователь fsbackup: │
|
||||||
|
│ │ каждую ночь в 04:00 │ (Pi сам │ только SFTP, без shell │
|
||||||
|
│ ▼ │ ходит └─────────────────────────────┘
|
||||||
|
│ том backup-data (копия №1, зашифрована) │ наружу)
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
▲ ssh + scp по команде «pull»
|
||||||
|
┌─────────┴────────────────────────────────┐
|
||||||
|
│ ПК: backups\fs_<дата>_<id>.tar │ копия №3, по запросу, НЕ зашифрована
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
На Pi работает третий контейнер — `backup`. В нём [restic](https://restic.net) — известная
|
||||||
|
программа для бэкапов. Каждую ночь контейнер:
|
||||||
|
|
||||||
|
1. снимает **консистентную** копию БД. Сайт при этом работает, пользователи ничего не замечают;
|
||||||
|
2. проверяет копию (`PRAGMA integrity_check`). Битая копия не сохраняется, старые снимки
|
||||||
|
тоже не трогаются;
|
||||||
|
3. сохраняет **снимок** — БД + загруженные фото (`uploads`) + титулы (`achievements`) — в
|
||||||
|
два места: на сам Pi и на VPS;
|
||||||
|
4. удаляет старые снимки по правилам хранения;
|
||||||
|
5. проверяет, что репозитории целы.
|
||||||
|
|
||||||
|
Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных и убеждается,
|
||||||
|
что последний снимок действительно восстанавливается.
|
||||||
|
|
||||||
|
**Словарь**
|
||||||
|
|
||||||
|
| Слово | Что значит |
|
||||||
|
|---|---|
|
||||||
|
| **Снимок** (snapshot) | Состояние данных на момент бэкапа. У каждого есть короткий ID, например `3f2a9c1d`. |
|
||||||
|
| **Репозиторий** | Хранилище снимков. У нас их два: `local` (на Pi) и `vps` (на VPS). |
|
||||||
|
| **Пароль шифрования** | Им зашифрованы оба репозитория. Без него снимки прочитать нельзя — **никак**. |
|
||||||
|
| **Хранение** | Сколько снимков остаётся: последний за каждый из 14 дней, за каждую из 8 недель, за каждый из 12 месяцев, плюс 3 самых свежих. Одинаковые данные хранятся один раз (дедупликация), поэтому 30+ снимков занимают немногим больше одного. |
|
||||||
|
| **Именованный снимок** | Снимок, сделанный вручную с меткой (`--tag before-update`). Автоматически не удаляется. |
|
||||||
|
| **pre-restore** | Страховочный снимок, который автоматически делается перед каждым восстановлением: «как было до». Автоматически не удаляется. |
|
||||||
|
|
||||||
|
**Что попадает в снимок:** БД, `uploads`, `achievements`.
|
||||||
|
**Что НЕ попадает:** файл `.env` с секретами (пароли, токен бота, ключи). Его копию храните
|
||||||
|
отдельно — см. [шаг 1](#шаг-1-пароль-шифрования).
|
||||||
|
|
||||||
|
**Ограничения, о которых стоит знать**
|
||||||
|
|
||||||
|
- Копии на ПК (`backups\*.tar`) **не зашифрованы**: там данные игроков. Не выкладывайте их
|
||||||
|
никуда и удаляйте ненужные.
|
||||||
|
- Ключ, которым Pi заходит на VPS, умеет и удалять файлы в `/srv/fs-backups`. Если Pi будет
|
||||||
|
взломан, злоумышленник сможет удалить копию на VPS. Поэтому раз в месяц полезно скачивать
|
||||||
|
снимок на ПК ([раздел 10](#10-повседневные-действия)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 1. Пароль шифрования
|
||||||
|
|
||||||
|
**Где:** ПК. **Сколько времени:** 5 минут.
|
||||||
|
|
||||||
|
> ### ⚠️ Самое важное во всей инструкции
|
||||||
|
> Потеряете пароль — **ни один бэкап не восстановить**. Ни на Pi, ни на VPS. Никакого
|
||||||
|
> «сброса пароля» у restic нет, и это не баг, а суть шифрования.
|
||||||
|
|
||||||
|
1. Откройте PowerShell и сгенерируйте пароль:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `python` не найден, подойдёт такая команда:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$b = New-Object byte[] 32; [Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b); [Convert]::ToBase64String($b).TrimEnd('=').Replace('+','-').Replace('/','_')
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Сохраните результат в менеджер паролей (Bitwarden, KeePass, 1Password…), запись
|
||||||
|
назовите, например, **«Forbidden Stars — пароль бэкапов (restic)»**.
|
||||||
|
3. Сделайте вторую копию вне компьютера: распечатайте или запишите на бумагу и уберите
|
||||||
|
в надёжное место.
|
||||||
|
4. **Рекомендуется:** в той же записи менеджера паролей храните копию файла `.env` с Pi
|
||||||
|
(после шага 5). При гибели Pi это сэкономит час восстановления секретов.
|
||||||
|
|
||||||
|
**Что должно получиться:** строка из ~43 символов (латиница, цифры, `-`, `_`), сохранённая
|
||||||
|
в менеджере паролей и на бумаге.
|
||||||
|
|
||||||
|
> В пароле не должно быть символа `$` — docker compose воспринимает его как переменную.
|
||||||
|
> Обе команды выше `$` не генерируют.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 2. SSH-ключ для VPS
|
||||||
|
|
||||||
|
**Где:** ПК, PowerShell, папка репозитория. **Зачем:** этим ключом контейнер `backup`
|
||||||
|
на Pi будет заходить на VPS. Пароль к ключу не ставим: контейнер работает без человека.
|
||||||
|
|
||||||
|
1. Перейдите в папку репозитория (подставьте свой путь):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\<вы>\ForbidenStarsApp
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Создайте ключ:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh-keygen -t ed25519 -C "fs-backup" -f deploy\backup\id_backup
|
||||||
|
```
|
||||||
|
|
||||||
|
На вопросы `Enter passphrase` и `Enter same passphrase again` просто **дважды нажмите
|
||||||
|
Enter** (пароль к ключу пустой).
|
||||||
|
|
||||||
|
3. Проверьте, что появились два файла:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem deploy\backup\id_backup*
|
||||||
|
Get-Content deploy\backup\id_backup.pub
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте, что ключ не попадёт в git (команда должна вывести **пустоту**):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
git status --short deploy\backup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что должно получиться:**
|
||||||
|
- файлы `id_backup` (приватный, секрет) и `id_backup.pub` (публичный);
|
||||||
|
- содержимое `id_backup.pub` — одна строка вида `ssh-ed25519 AAAAC3Nza…много символов… fs-backup`;
|
||||||
|
- `git status` по этой папке ничего не показывает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 3. VPS: пользователь только для SFTP
|
||||||
|
|
||||||
|
**Где:** VPS. **Зачем:** отдельный пользователь `fsbackup` сможет только класть и читать
|
||||||
|
файлы бэкапов по SFTP: без shell, без туннелей, без входа по паролю.
|
||||||
|
|
||||||
|
> Почему `fsbackup`, а не `backup`: в Debian/Ubuntu системный пользователь `backup` уже
|
||||||
|
> существует (он служебный, домашняя папка `/var/backups`) — его трогать нельзя.
|
||||||
|
|
||||||
|
1. Зайдите на VPS (с ПК):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh root@186.246.51.17
|
||||||
|
```
|
||||||
|
|
||||||
|
Все команды ниже выполняются **на VPS** от root. Заходите не под root — добавляйте
|
||||||
|
`sudo` перед каждой командой.
|
||||||
|
|
||||||
|
2. Убедитесь, что такого пользователя ещё нет:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
id fsbackup
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается `id: 'fsbackup': no such user`. Если пользователь уже есть — пропустите пункт 3.
|
||||||
|
|
||||||
|
3. Создайте пользователя и папки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
useradd --create-home --shell /usr/sbin/nologin fsbackup
|
||||||
|
install -d -m 700 -o fsbackup -g fsbackup /home/fsbackup/.ssh
|
||||||
|
install -m 600 -o fsbackup -g fsbackup /dev/null /home/fsbackup/.ssh/authorized_keys
|
||||||
|
install -d -m 700 -o fsbackup -g fsbackup /srv/fs-backups
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Добавьте публичный ключ из шага 2.
|
||||||
|
- **На ПК**, во втором окне PowerShell в папке репозитория, скопируйте ключ в буфер обмена:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Content deploy\backup\id_backup.pub | Set-Clipboard
|
||||||
|
```
|
||||||
|
|
||||||
|
- **На VPS** наберите команду ниже, вставив ключ вместо `ВСТАВЬТЕ_КЛЮЧ`. Вставка в
|
||||||
|
терминале — правая кнопка мыши или `Ctrl+Shift+V`. Одинарные кавычки оставьте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'ВСТАВЬТЕ_КЛЮЧ' >> /home/fsbackup/.ssh/authorized_keys
|
||||||
|
cat /home/fsbackup/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
`cat` должен показать одну строку, которая начинается с `ssh-ed25519` и заканчивается на `fs-backup`.
|
||||||
|
|
||||||
|
5. Запретите этому пользователю всё, кроме SFTP. Скопируйте блок **целиком**, от `cat` до
|
||||||
|
последнего `EOF` включительно:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /etc/ssh/sshd_config.d/60-fs-backup.conf <<'EOF'
|
||||||
|
Match User fsbackup
|
||||||
|
ForceCommand internal-sftp -d /srv/fs-backups
|
||||||
|
PasswordAuthentication no
|
||||||
|
AllowTcpForwarding no
|
||||||
|
AllowAgentForwarding no
|
||||||
|
PermitTunnel no
|
||||||
|
X11Forwarding no
|
||||||
|
PermitTTY no
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Убедитесь, что основной конфиг подключает папку `sshd_config.d`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n '^Include' /etc/ssh/sshd_config
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается строка `Include /etc/ssh/sshd_config.d/*.conf`. **Если вывода нет** (старая
|
||||||
|
система), допишите блок в конец основного конфига:
|
||||||
|
`cat /etc/ssh/sshd_config.d/60-fs-backup.conf >> /etc/ssh/sshd_config`.
|
||||||
|
|
||||||
|
7. Проверьте конфиг и примените его. Текущая SSH-сессия при этом не оборвётся:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshd -t && echo "конфиг OK"
|
||||||
|
systemctl reload ssh || systemctl reload sshd
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `sshd -t` вывел ошибку, **не выходите из сессии** и исправьте файл
|
||||||
|
(`nano /etc/ssh/sshd_config.d/60-fs-backup.conf`). Ошибка в конфиге sshd может закрыть
|
||||||
|
вход на сервер.
|
||||||
|
|
||||||
|
8. Проверьте, что правила действуют только на `fsbackup`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sshd -T -C user=fsbackup,host=x,addr=1.2.3.4 | grep -i forcecommand
|
||||||
|
sshd -T -C user=root,host=x,addr=1.2.3.4 | grep -i forcecommand
|
||||||
|
```
|
||||||
|
|
||||||
|
Первая команда должна показать `forcecommand internal-sftp -d /srv/fs-backups`,
|
||||||
|
вторая — `forcecommand none`.
|
||||||
|
|
||||||
|
9. **С ПК** (новое окно PowerShell в папке репозитория) проверьте вход по SFTP:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
sftp -i deploy\backup\id_backup fsbackup@186.246.51.17
|
||||||
|
```
|
||||||
|
|
||||||
|
При первом подключении ответьте `yes` на вопрос `Are you sure you want to continue
|
||||||
|
connecting`. Появится приглашение `sftp>`. Наберите `pwd`, затем `bye`.
|
||||||
|
|
||||||
|
10. **С ПК** проверьте, что shell закрыт:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh -i deploy\backup\id_backup fsbackup@186.246.51.17
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что должно получиться:**
|
||||||
|
- `sshd -t` → `конфиг OK`;
|
||||||
|
- в `sftp` команда `pwd` отвечает `Remote working directory: /srv/fs-backups`;
|
||||||
|
- `ssh` из пункта 10 отвечает `This service allows sftp connections only.` и сразу отключается;
|
||||||
|
- вход root на VPS работает как раньше (проверьте новым окном: `ssh root@186.246.51.17`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 4. Сборка и публикация образов
|
||||||
|
|
||||||
|
**Где:** ПК с Docker Desktop, папка репозитория. **Зачем:** на Pi нет сборки — он скачивает
|
||||||
|
готовые образы из реестра Gitea. Новый образ `forbidden-stars-backup` нужно туда положить.
|
||||||
|
|
||||||
|
> `build-push` собирает **все три** образа (app, tunnel, backup) из текущей ветки ПК и
|
||||||
|
> публикует их с тегом из `IMAGE_TAG`. Убедитесь, что вы на ветке, которая должна быть на
|
||||||
|
> проде (обычно `main` после релиза): `git branch --show-current`.
|
||||||
|
|
||||||
|
1. Один раз войдите в реестр (если уже входили — пропустите):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker login gitea.arseniev.info
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Соберите и опубликуйте образы (первый раз — 5–15 минут):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\build-push.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Проверьте, что образ бэкапа есть в реестре и собран под arm64:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker buildx imagetools inspect gitea.arseniev.info/notbigghost/forbidden-stars-backup:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что должно получиться:**
|
||||||
|
- `build-push.ps1` заканчивается зелёной строкой `Done. On the Pi: ...`;
|
||||||
|
- `imagetools inspect` показывает `Platform: linux/arm64`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 5. Pi: включить бэкапы
|
||||||
|
|
||||||
|
**Где:** Pi. **Сколько времени:** 15 минут.
|
||||||
|
|
||||||
|
1. Зайдите на Pi и перейдите в папку прода:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh pi@<IP-адрес-Pi>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/forbidden-stars
|
||||||
|
ls
|
||||||
|
```
|
||||||
|
|
||||||
|
Должны быть видны `docker-compose.yml` и `.env`. Если папка называется иначе, дальше
|
||||||
|
везде используйте своё название.
|
||||||
|
|
||||||
|
2. Сохраните копию текущего compose-файла — на случай отката:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp docker-compose.yml docker-compose.yml.bak-$(date +%F)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Скачайте новый `docker-compose.yml`. Замените `main` на `dev`, если функционал ещё не
|
||||||
|
попал в релиз:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRANCH=main
|
||||||
|
curl -fsSLO https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/raw/branch/$BRANCH/docker-compose.yml
|
||||||
|
grep -n '^ backup:' docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
`grep` должен найти строку ` backup:`. Если не нашёл, в этой ветке функционала ещё нет.
|
||||||
|
|
||||||
|
4. Посмотрите, какие `BACKUP_*` уже есть в `.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n '^BACKUP_' .env
|
||||||
|
```
|
||||||
|
|
||||||
|
- Есть **старые** строки (`BACKUP_VPS_USER=backup`, `BACKUP_VPS_DIR=/srv/fs-backups`,
|
||||||
|
`BACKUP_VPS_KEY`, `BACKUP_KEEP_LOCAL`, `BACKUP_KEEP_REMOTE`)? **Удалите их все** в
|
||||||
|
пункте 5: они указывают на неправильного пользователя и папку.
|
||||||
|
- Уже есть **новые** строки (`BACKUP_PASSWORD`, `BACKUP_SSH_KEY_B64` и т.д. — если `.env`
|
||||||
|
делался из свежего `.env.example`)? Блок ниже **не дописывайте**, а заполните
|
||||||
|
существующие строки теми же значениями. Иначе переменные задвоятся.
|
||||||
|
|
||||||
|
5. Откройте `.env` и добавьте в конец блок ниже:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
В nano: стрелками вниз до конца файла, вставка — правая кнопка мыши, сохранить —
|
||||||
|
`Ctrl+O`, затем `Enter`, выйти — `Ctrl+X`.
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# ─── БЭКАПЫ ───
|
||||||
|
BACKUP_PASSWORD=<пароль из шага 1>
|
||||||
|
BACKUP_SCHEDULE="0 4 * * *"
|
||||||
|
BACKUP_VERIFY_SCHEDULE="30 5 * * 0"
|
||||||
|
BACKUP_TZ=Europe/Moscow
|
||||||
|
BACKUP_KEEP_DAILY=14
|
||||||
|
BACKUP_KEEP_WEEKLY=8
|
||||||
|
BACKUP_KEEP_MONTHLY=12
|
||||||
|
BACKUP_COMPRESSION=max
|
||||||
|
BACKUP_MAX_AGE_HOURS=30
|
||||||
|
BACKUP_VPS_HOST=186.246.51.17
|
||||||
|
BACKUP_VPS_USER=fsbackup
|
||||||
|
BACKUP_VPS_PORT=22
|
||||||
|
BACKUP_VPS_DIR=/srv/fs-backups/restic
|
||||||
|
BACKUP_SSH_KEY_B64=<длинная строка base64, см. ниже>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Как получить `BACKUP_SSH_KEY_B64`.** На ПК, в PowerShell в папке репозитория:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
[Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup"))) | Set-Clipboard
|
||||||
|
[Text.Encoding]::ASCII.GetString([Convert]::FromBase64String((Get-Clipboard))).Split("`n")[0]
|
||||||
|
```
|
||||||
|
|
||||||
|
Вторая команда проверяет содержимое буфера. Она должна вывести
|
||||||
|
`-----BEGIN OPENSSH PRIVATE KEY-----`. Теперь вставьте строку из буфера в `.env` после
|
||||||
|
`BACKUP_SSH_KEY_B64=`: **одной строкой, без пробелов и кавычек**.
|
||||||
|
|
||||||
|
**Что означает каждая строка**
|
||||||
|
|
||||||
|
| Переменная | Значение | Что делает |
|
||||||
|
|---|---|---|
|
||||||
|
| `BACKUP_PASSWORD` | пароль из шага 1 | шифрует оба репозитория; пусто = бэкапы выключены |
|
||||||
|
| `BACKUP_SCHEDULE` | `"0 4 * * *"` | когда делать бэкап: минута, час, день, месяц, день недели → каждый день в 04:00 |
|
||||||
|
| `BACKUP_VERIFY_SCHEDULE` | `"30 5 * * 0"` | когда проверять данные: воскресенье 05:30 |
|
||||||
|
| `BACKUP_TZ` | `Europe/Moscow` | часовой пояс расписания и времени снимков |
|
||||||
|
| `BACKUP_KEEP_DAILY/WEEKLY/MONTHLY` | `14` / `8` / `12` | сколько дней / недель / месяцев хранить снимки |
|
||||||
|
| `BACKUP_COMPRESSION` | `max` | сжатие restic: `auto` — быстрее, `max` — плотнее, `off` — без сжатия |
|
||||||
|
| `BACKUP_MAX_AGE_HOURS` | `30` | если успешного бэкапа не было дольше — контейнер помечается `unhealthy` |
|
||||||
|
| `BACKUP_VPS_HOST` | `186.246.51.17` | адрес VPS; пусто = копия только на Pi |
|
||||||
|
| `BACKUP_VPS_USER` | `fsbackup` | пользователь из шага 3 |
|
||||||
|
| `BACKUP_VPS_PORT` | `22` | SSH-порт VPS |
|
||||||
|
| `BACKUP_VPS_DIR` | `/srv/fs-backups/restic` | папка репозитория на VPS (создастся сама) |
|
||||||
|
| `BACKUP_SSH_KEY_B64` | base64 ключа | приватный ключ из шага 2 одной строкой |
|
||||||
|
|
||||||
|
6. Проверьте `.env`, **не показывая секреты на экране**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c '^BACKUP_' .env
|
||||||
|
grep '^BACKUP_SSH_KEY_B64=' .env | wc -c
|
||||||
|
grep '^BACKUP_PASSWORD=' .env | grep -c '\$'
|
||||||
|
docker compose config --quiet && echo "compose OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается:
|
||||||
|
- `14` (или `16`, если в `.env` есть ещё `BACKUP_PI_SSH` и `BACKUP_PI_DIR` из нового шаблона —
|
||||||
|
на Pi они не используются и не мешают);
|
||||||
|
- число больше `400`;
|
||||||
|
- `0`;
|
||||||
|
- `compose OK`.
|
||||||
|
|
||||||
|
Проверить, что ни одна переменная не задвоилась (вывод должен быть **пустым**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -o '^BACKUP_[A-Z_0-9]*' .env | sort | uniq -d
|
||||||
|
```
|
||||||
|
|
||||||
|
7. Запустите **только** контейнер бэкапа. Сайт при этом не перезапускается:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d backup
|
||||||
|
```
|
||||||
|
|
||||||
|
8. Смотрите журнал первого запуска (выход — `Ctrl+C`, контейнер продолжит работать):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f backup
|
||||||
|
```
|
||||||
|
|
||||||
|
Первый бэкап начинается сразу. В журнале должны появиться, в таком порядке:
|
||||||
|
- `Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу.`
|
||||||
|
- `БД в порядке: игроков N, партий M.`
|
||||||
|
- `Репозиторий local ещё не создан — создаю …`
|
||||||
|
- `OK: репозиторий local, снимок xxxxxxxx.`
|
||||||
|
- `Репозиторий vps ещё не создан — создаю (sftp:fs-vps:/srv/fs-backups/restic …`
|
||||||
|
- `OK: репозиторий vps, снимок yyyyyyyy.`
|
||||||
|
- `Бэкап завершён.`
|
||||||
|
- `Расписание (TZ=Europe/Moscow):` и две строки расписания.
|
||||||
|
|
||||||
|
9. Проверьте состояние и хронологию:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup status
|
||||||
|
docker compose exec backup fs-backup list
|
||||||
|
docker compose exec backup fs-backup list vps
|
||||||
|
docker compose ps backup
|
||||||
|
```
|
||||||
|
|
||||||
|
10. На VPS убедитесь, что копия пришла (там только зашифрованные файлы restic):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -la /srv/fs-backups/restic
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что должно получиться:**
|
||||||
|
- в `status` у `[local]` и `[vps]` есть строка `Последний бэкап: <сегодня> — снимок …`;
|
||||||
|
- `list` и `list vps` показывают по одному снимку с верным числом игроков и партий;
|
||||||
|
- на VPS в `/srv/fs-backups/restic` лежат `config`, `data`, `index`, `keys`, `snapshots`;
|
||||||
|
- `docker compose ps backup` показывает `Up`. Первые ~10 минут статус `(health: starting)`,
|
||||||
|
затем `(healthy)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 6. ПК: доступ к Pi и выгрузка бэкапов
|
||||||
|
|
||||||
|
**Где:** ПК. **Зачем:** одной командой скачивать снимок с Pi на ПК — третья копия, которая
|
||||||
|
не зависит ни от Pi, ни от VPS.
|
||||||
|
|
||||||
|
1. Проверьте вход на Pi **без пароля**:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
ssh pi@<IP-адрес-Pi> "echo ok && cd ~/forbidden-stars && docker compose ps backup"
|
||||||
|
```
|
||||||
|
|
||||||
|
Если выводится `ok` и строка контейнера `backup` без запроса пароля — переходите
|
||||||
|
к пункту 3.
|
||||||
|
|
||||||
|
2. Если спрашивает пароль, настройте вход по ключу (один раз).
|
||||||
|
|
||||||
|
Создайте ключ ПК, если его ещё нет. На вопросы `passphrase` дважды нажмите Enter:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
if (-not (Test-Path "$env:USERPROFILE\.ssh\id_ed25519")) { ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\id_ed25519" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Передайте публичный ключ на Pi (пароль Pi спросят в последний раз):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-Content "$env:USERPROFILE\.ssh\id_ed25519.pub" | ssh pi@<IP-адрес-Pi> "mkdir -p ~/.ssh && chmod 700 ~/.ssh && tr -d '\r' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys"
|
||||||
|
```
|
||||||
|
|
||||||
|
Повторите пункт 1 — пароль спрашиваться не должен.
|
||||||
|
|
||||||
|
3. Добавьте в `.env` **на ПК** (корень репозитория) или исправьте, если строки уже есть:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
BACKUP_PI_SSH=pi@<IP-адрес-Pi>
|
||||||
|
BACKUP_PI_DIR=~/forbidden-stars
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Проверьте скрипт:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\fs-backup.ps1 status
|
||||||
|
.\scripts\fs-backup.ps1 list
|
||||||
|
```
|
||||||
|
|
||||||
|
Если PowerShell пишет `running scripts is disabled on this system`, выполните один раз:
|
||||||
|
`Set-ExecutionPolicy -Scope CurrentUser RemoteSigned`.
|
||||||
|
|
||||||
|
5. Скачайте последний снимок:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\fs-backup.ps1 pull
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт делает всё сам:
|
||||||
|
- выгружает снимок в файл на Pi;
|
||||||
|
- копирует файл на ПК по `scp`;
|
||||||
|
- сверяет контрольную сумму sha256;
|
||||||
|
- проверяет, что внутри есть БД;
|
||||||
|
- удаляет временный файл на Pi.
|
||||||
|
|
||||||
|
6. Посмотрите, что внутри архива:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-ChildItem backups\fs_*.tar
|
||||||
|
tar -tf (Get-ChildItem backups\fs_*.tar | Sort-Object LastWriteTime | Select-Object -Last 1).FullName | Select-Object -First 10
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что должно получиться:**
|
||||||
|
- `pull` заканчивается зелёной строкой `OK: ...\backups\fs_<дата>_<время>_<id>.tar (… MB, N files, sha256 verified)`;
|
||||||
|
- внутри архива есть `forbidden_stars.db`, `uploads/…`, `achievements/…`.
|
||||||
|
|
||||||
|
> Архив — обычный `.tar`, его открывает 7-Zip. БД внутри — файл SQLite, его можно
|
||||||
|
> посмотреть в [DB Browser for SQLite](https://sqlitebrowser.org). Архив **не зашифрован**.
|
||||||
|
>
|
||||||
|
> Другие варианты: снимок с VPS — `.\scripts\fs-backup.ps1 pull -Repo vps`,
|
||||||
|
> конкретный снимок — `.\scripts\fs-backup.ps1 pull -Snapshot 3f2a9c1d` (ID из `list`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 7. Учебное восстановление на тест-клоне
|
||||||
|
|
||||||
|
**Где:** ПК с Docker Desktop. **Зачем:** убедиться, что бэкап действительно
|
||||||
|
восстанавливается, **до** того как это понадобится по-настоящему. Прод не затрагивается.
|
||||||
|
|
||||||
|
> Данные тест-клона на ПК будут заменены данными из архива. Прежние данные тест-клона
|
||||||
|
> сохраняются в его собственный снимок `pre-restore`.
|
||||||
|
|
||||||
|
1. Восстановите скачанный архив в тест-клон (подставьте имя своего файла):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260914_0400_3f2a9c1d.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
В первый раз Docker соберёт образы тест-клона — это несколько минут.
|
||||||
|
|
||||||
|
2. Проверьте, что приложение тест-клона поднялось:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose -f docker-compose.test.yml ps
|
||||||
|
docker compose -f docker-compose.test.yml logs --tail 20 app
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Посмотрите на сайт: в `.env` на ПК временно поставьте `APP_ENV=test` и запустите
|
||||||
|
`.\run.ps1`. Тест-клон откроется на `https://forbidden-stars.ru`: проверьте топ,
|
||||||
|
профили, историю партий. Потом верните `APP_ENV=development`.
|
||||||
|
|
||||||
|
**Что должно получиться:**
|
||||||
|
- в выводе `restore-test`:
|
||||||
|
- `Развёрнутые данные в порядке: игроков N, партий M.` — те же числа, что в `list` на Pi;
|
||||||
|
- `Данные восстановлены.`;
|
||||||
|
- `Done. The test clone now runs on the restored data.`;
|
||||||
|
- `docker compose ... ps` показывает `app` в состоянии `Up … (healthy)`;
|
||||||
|
- на сайте тест-клона — данные прода на момент снимка.
|
||||||
|
|
||||||
|
> Этим же способом можно восстановить в тест-клон старые архивы `fs_*.tar.gz` прежнего
|
||||||
|
> `scripts/backup.sh`: `.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260710_140914.tar.gz`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Восстановление прода
|
||||||
|
|
||||||
|
**Когда:** данные испорчены или удалены по ошибке, неудачная миграция, «откатить на вчера».
|
||||||
|
|
||||||
|
### Как это работает (почему это безопасно)
|
||||||
|
|
||||||
|
Восстановление **никогда не пишет поверх текущих данных напрямую**:
|
||||||
|
|
||||||
|
1. **Проверка.** Если приложение работает, восстановление отказывается запускаться. Затем
|
||||||
|
проверяется, хватит ли места на диске.
|
||||||
|
2. **Разворачивание.** Снимок целиком разворачивается в промежуточную папку
|
||||||
|
`.restore-new` внутри каждого тома.
|
||||||
|
3. **Проверка развёрнутого:** целостность БД, наличие таблиц, число файлов совпадает со
|
||||||
|
снимком. Любая ошибка → промежуточная папка удаляется, **текущие данные не тронуты**.
|
||||||
|
4. **Страховка.** Текущие данные сохраняются в снимок `pre-restore`.
|
||||||
|
5. **Замена.** Текущие данные переносятся в `.restore-old`, новые — на их место.
|
||||||
|
Используется переименование: мгновенно, без копирования. Сбой на этом шаге → всё
|
||||||
|
возвращается как было.
|
||||||
|
6. **Уборка.** `.restore-old` удаляется.
|
||||||
|
|
||||||
|
### Порядок действий
|
||||||
|
|
||||||
|
1. Зайдите на Pi и выберите снимок:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/forbidden-stars
|
||||||
|
docker compose exec backup fs-backup list
|
||||||
|
```
|
||||||
|
|
||||||
|
Пример вывода:
|
||||||
|
|
||||||
|
```
|
||||||
|
ID Время Игроков Партий Размер Прирост Метки
|
||||||
|
3f2a9c1d 2026-09-13 04:00 12 87 45.1 MB 12.3 KB scheduled
|
||||||
|
8b1e0f44 2026-09-14 04:00 12 88 45.2 MB 40.1 KB scheduled
|
||||||
|
```
|
||||||
|
|
||||||
|
| Столбец | Что показывает |
|
||||||
|
|---|---|
|
||||||
|
| `ID` | ID снимка — его нужно подставить в команду восстановления |
|
||||||
|
| `Время` | когда сделан снимок |
|
||||||
|
| `Игроков` / `Партий` | сколько игроков и партий было в БД в этот момент — по ним легко найти «до поломки» |
|
||||||
|
| `Размер` | полный объём данных снимка |
|
||||||
|
| `Прирост` | сколько места снимок реально добавил в репозиторий |
|
||||||
|
| `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, прочие — имя, данное вручную |
|
||||||
|
|
||||||
|
Если локальный репозиторий повреждён или пуст, смотрите копию на VPS:
|
||||||
|
`docker compose exec backup fs-backup list vps`.
|
||||||
|
|
||||||
|
2. **По желанию, но рекомендуется:** сначала отрепетируйте на ПК:
|
||||||
|
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)).
|
||||||
|
|
||||||
|
3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop app
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Восстановите снимок. С VPS — добавьте в конец `--repo vps`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup restore <ID> --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Запустите приложение и проверьте его:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose start app
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Через 30–60 секунд у `app` должно быть `(healthy)`. Откройте `https://forbiddenstars.ru`.
|
||||||
|
|
||||||
|
**Что должно получиться:** в выводе `restore` — `Развёрнутые данные в порядке: …`,
|
||||||
|
`Страховочный снимок: <id>`, `Данные восстановлены.`; сайт работает на данных из снимка.
|
||||||
|
|
||||||
|
**Если что-то пошло не так**
|
||||||
|
|
||||||
|
- `restore` закончился строкой `ОШИБКА: … текущие данные НЕ тронуты` — данные прежние,
|
||||||
|
просто запустите приложение (`docker compose start app`) и разберитесь с причиной
|
||||||
|
([раздел 11](#11-неполадки)).
|
||||||
|
- Восстановили не тот снимок — верните «как было до»: в `list` найдите самый свежий снимок
|
||||||
|
с меткой `pre-restore` и восстановите его так же (пункты 3–5).
|
||||||
|
- После старта сайт показывает заглушку дольше пары минут: `docker compose logs --tail 50 app`,
|
||||||
|
затем `docker compose restart tunnel`.
|
||||||
|
|
||||||
|
### Восстановление из файла-архива
|
||||||
|
|
||||||
|
Например, из архива, скачанного на ПК, или из старого `fs_*.tar.gz`.
|
||||||
|
|
||||||
|
1. С ПК скопируйте архив на Pi:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
scp backups\fs_20260914_0400_3f2a9c1d.tar pi@<IP-адрес-Pi>:~/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. На Pi:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/forbidden-stars
|
||||||
|
docker compose cp ~/fs_20260914_0400_3f2a9c1d.tar backup:/import/fs.tar
|
||||||
|
docker compose stop app
|
||||||
|
docker compose exec backup fs-backup import /import/fs.tar --yes
|
||||||
|
docker compose exec backup rm /import/fs.tar
|
||||||
|
docker compose start app
|
||||||
|
rm ~/fs_20260914_0400_3f2a9c1d.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
`import` проходит те же проверки и так же делает снимок `pre-restore`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Катастрофа: Pi умер
|
||||||
|
|
||||||
|
**Когда:** Pi сгорел, SD-карта испорчена, Pi украли. Копия на Pi потеряна, остаётся VPS
|
||||||
|
(и архивы на ПК).
|
||||||
|
|
||||||
|
**Что понадобится:**
|
||||||
|
- пароль бэкапов из менеджера паролей — **без него дальше идти бессмысленно**;
|
||||||
|
- копия `.env` старого Pi (если сохраняли, [шаг 1](#шаг-1-пароль-шифрования)).
|
||||||
|
|
||||||
|
1. Подготовьте новый Pi по [`deploy/pi/README.md`](../pi/README.md), пункты 1–3: Docker,
|
||||||
|
`docker-compose.yml`, `.env`.
|
||||||
|
- Есть копия старого `.env` — просто положите её.
|
||||||
|
- Нет копии — заполните `.env` заново. **`BACKUP_PASSWORD` должен быть прежним.**
|
||||||
|
Если ключа `deploy/backup/id_backup` на ПК больше нет, сделайте новый (шаг 2), добавьте
|
||||||
|
его `.pub` на VPS (шаг 3, пункт 4) и укажите новый base64 в `BACKUP_SSH_KEY_B64`.
|
||||||
|
2. Запустите всё:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/forbidden-stars
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
||||||
|
В журнале бэкапа это нормально (`docker compose logs backup`):
|
||||||
|
|
||||||
|
```
|
||||||
|
В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными.
|
||||||
|
Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю.
|
||||||
|
```
|
||||||
|
|
||||||
|
Это защита: пустой новый Pi не перезапишет историю на VPS.
|
||||||
|
|
||||||
|
4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup list vps
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Восстановите:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop app
|
||||||
|
docker compose exec backup fs-backup restore latest --repo vps --yes
|
||||||
|
docker compose start app
|
||||||
|
```
|
||||||
|
|
||||||
|
Вместо `latest` можно указать ID из `list vps`.
|
||||||
|
|
||||||
|
6. Проверьте сайт. Затем сделайте снимок вручную, чтобы локальная копия появилась сразу:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup run
|
||||||
|
docker compose exec backup fs-backup status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Что должно получиться:** на сайте прежние данные; `run` проходит (`OK: репозиторий local…`,
|
||||||
|
`OK: репозиторий vps…`); в `status` оба репозитория с сегодняшним бэкапом.
|
||||||
|
|
||||||
|
> **VPS тоже недоступен?** Восстанавливайте из последнего архива на ПК: скопируйте его на
|
||||||
|
> новый Pi и выполните [восстановление из файла-архива](#восстановление-из-файла-архива).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Повседневные действия
|
||||||
|
|
||||||
|
| Задача | На Pi (`cd ~/forbidden-stars`) | С ПК (папка репозитория) |
|
||||||
|
|---|---|---|
|
||||||
|
| Состояние бэкапов | `docker compose exec backup fs-backup status` | `.\scripts\fs-backup.ps1 status` |
|
||||||
|
| Хронология снимков | `docker compose exec backup fs-backup list` (`list vps`) | `.\scripts\fs-backup.ps1 list` (`-Repo vps`) |
|
||||||
|
| Снимок перед рискованным обновлением | `docker compose exec backup fs-backup run --tag before-update` | `.\scripts\fs-backup.ps1 now -Tag before-update` |
|
||||||
|
| Проверить целостность данных | `docker compose exec backup fs-backup verify` | `.\scripts\fs-backup.ps1 verify` |
|
||||||
|
| Скачать снимок на ПК | — | `.\scripts\fs-backup.ps1 pull` |
|
||||||
|
| Журнал контейнера | `docker compose logs --tail 100 backup` | — |
|
||||||
|
|
||||||
|
**Рекомендуемый ритм**
|
||||||
|
- **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`).
|
||||||
|
- **Раз в месяц:**
|
||||||
|
- скачать снимок на ПК (`pull`);
|
||||||
|
- раз в пару месяцев сделать учебное восстановление (`restore-test`);
|
||||||
|
- удалить с ПК старые архивы — они не зашифрованы.
|
||||||
|
- **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что
|
||||||
|
бэкапы перестали проходить (причину покажет `fs-backup status`).
|
||||||
|
|
||||||
|
**Изменить расписание или сроки хранения.** Отредактируйте `BACKUP_*` в `.env` на Pi, затем
|
||||||
|
пересоздайте контейнер:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d backup
|
||||||
|
```
|
||||||
|
|
||||||
|
**Удалить именованный или pre-restore снимок.** Автоматически они не удаляются, удалять
|
||||||
|
нужно в обоих репозиториях:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup restic local forget <ID> --prune
|
||||||
|
docker compose exec backup fs-backup restic vps forget <ID-на-vps> --prune
|
||||||
|
```
|
||||||
|
|
||||||
|
ID одного и того же снимка в `local` и `vps` разные — смотрите `list` и `list vps`.
|
||||||
|
|
||||||
|
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
||||||
|
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
||||||
|
|
||||||
|
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
||||||
|
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
||||||
|
> не трогает.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Неполадки
|
||||||
|
|
||||||
|
Первое, что стоит сделать при любой проблеме:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/forbidden-stars
|
||||||
|
docker compose exec backup fs-backup status
|
||||||
|
docker compose logs --tail 100 backup
|
||||||
|
```
|
||||||
|
|
||||||
|
### На Pi (журнал и команды `fs-backup`)
|
||||||
|
|
||||||
|
| Симптом | Причина | Что сделать |
|
||||||
|
|---|---|---|
|
||||||
|
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
||||||
|
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
||||||
|
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
||||||
|
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
||||||
|
| `Host key verification failed` | VPS переустановлен, у него новый ключ хоста | `docker compose exec backup rm /backup/state/known_hosts`, затем `docker compose exec backup fs-backup run` |
|
||||||
|
| `Репозиторий vps недоступен` и `Connection timed out` | VPS недоступен или неверный `BACKUP_VPS_HOST`/`PORT` | проверить VPS; локальная копия при этом продолжает делаться |
|
||||||
|
| `В БД нет ни игроков, ни партий, а последний снимок … — с данными` | новый или очищенный сервер — защита от затирания истории | новый Pi: [раздел 9](#9-катастрофа-pi-умер). Данные удалены намеренно: `fs-backup run --allow-empty` |
|
||||||
|
| `копия БД не прошла PRAGMA integrity_check` | живая БД повреждена | снимок не создаётся, старые целы. Восстановить последний хороший снимок ([раздел 8](#8-восстановление-прода)) |
|
||||||
|
| `приложение работает — восстанавливать поверх него нельзя` | не остановлен `app` | `docker compose stop app`, повторить команду |
|
||||||
|
| `найдены следы прерванного восстановления` | восстановление оборвалось (выключили питание и т.п.) | `docker compose stop app`, `docker compose exec backup fs-backup recover`, затем при необходимости повторить `restore` |
|
||||||
|
| `уже выполняется другая операция бэкапа` | идёт бэкап по расписанию или проверка | подождать: `docker compose logs -f backup` |
|
||||||
|
| `мало места в …` | диск Pi заполнен | `df -h`; удалить неиспользуемые образы (`docker image prune`); уменьшить `BACKUP_KEEP_*` |
|
||||||
|
| `снимок '…' не найден в репозитории` | опечатка в ID или снимок в другом репозитории | `fs-backup list` и `fs-backup list vps` |
|
||||||
|
| `service "backup" is not running` | контейнер не запущен | `docker compose up -d backup` |
|
||||||
|
| `permission denied while trying to connect to the Docker daemon socket` | пользователь Pi не в группе `docker` | `sudo usermod -aG docker $USER`, перезайти по SSH |
|
||||||
|
| `docker compose ps` → `backup` `unhealthy` | последний успешный бэкап старше `BACKUP_MAX_AGE_HOURS` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице |
|
||||||
|
|
||||||
|
**Про `recover`.** Команда сама определяет, на каком этапе оборвалось восстановление:
|
||||||
|
- если замена успела пройти во всех томах — дочищает промежуточные папки, восстановленные
|
||||||
|
данные остаются;
|
||||||
|
- иначе возвращает каждому тому прежние данные.
|
||||||
|
|
||||||
|
После неё можно спокойно запускать приложение или повторить восстановление.
|
||||||
|
|
||||||
|
**Пароль бэкапов потерян навсегда.** Прочитать существующие снимки невозможно. Начать заново
|
||||||
|
(**это удалит все старые бэкапы**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop backup
|
||||||
|
docker compose rm -f backup
|
||||||
|
docker volume ls | grep backup-data # имя тома, обычно forbidden-stars_backup-data
|
||||||
|
docker volume rm <имя тома>
|
||||||
|
```
|
||||||
|
|
||||||
|
На VPS: `rm -rf /srv/fs-backups/restic`. Затем новый пароль в `.env`,
|
||||||
|
`docker compose up -d backup`.
|
||||||
|
|
||||||
|
### На ПК (скрипт `fs-backup.ps1`)
|
||||||
|
|
||||||
|
| Симптом | Причина | Что сделать |
|
||||||
|
|---|---|---|
|
||||||
|
| `running scripts is disabled on this system` | политика запуска PowerShell | `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` |
|
||||||
|
| `Set BACKUP_PI_SSH in .env` | не заполнен адрес Pi | шаг 6, пункт 3 |
|
||||||
|
| `ssh: connect to host … Connection timed out` | неверный IP Pi или ПК не в той сети | проверить `ssh pi@<IP-адрес-Pi>` вручную |
|
||||||
|
| Пароль Pi спрашивается несколько раз за команду | не настроен вход по ключу | шаг 6, пункт 2 |
|
||||||
|
| `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` |
|
||||||
|
| `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) |
|
||||||
|
| `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `pull` |
|
||||||
|
| `restore-test`: `Import failed … The test clone data was not changed` | архив повреждён или неполный | скачать заново (`pull`); текст ошибки выше в выводе |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Справочник: команды и переменные
|
||||||
|
|
||||||
|
### `fs-backup` — внутри контейнера
|
||||||
|
|
||||||
|
Запуск на Pi из папки прода: `docker compose exec backup fs-backup <команда>`.
|
||||||
|
|
||||||
|
| Команда | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `status` | расписание, хранение, последний бэкап и проверка по каждому репозиторию, размеры |
|
||||||
|
| `list [local\|vps]` | хронология снимков |
|
||||||
|
| `run [--tag имя] [--allow-empty]` | снимок сейчас; `--tag` — именованный (не удаляется) |
|
||||||
|
| `verify` | проверка данных репозиториев и открываемости БД последнего снимка |
|
||||||
|
| `restore <ID\|latest> [--repo local\|vps] --yes` | восстановление снимка (приложение должно быть остановлено) |
|
||||||
|
| `import /import/<файл> --yes` | восстановление из `.tar` или старого `.tar.gz` |
|
||||||
|
| `recover` | разбор прерванного восстановления |
|
||||||
|
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
||||||
|
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
||||||
|
| `help` | краткая справка |
|
||||||
|
|
||||||
|
Без `--yes` команды `restore` и `import` только показывают, что собираются сделать.
|
||||||
|
|
||||||
|
### Скрипт ПК
|
||||||
|
|
||||||
|
`.\scripts\fs-backup.ps1` (Windows) или `scripts/fs-backup.sh` (Linux/macOS/Git Bash):
|
||||||
|
|
||||||
|
| Команда | Что делает |
|
||||||
|
|---|---|
|
||||||
|
| `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК |
|
||||||
|
| `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 |
|
||||||
|
| `restore-test -File <архив>` | учебное восстановление в локальный тест-клон |
|
||||||
|
| `-Target test` | выполнить `status`/`list`/`now`/`verify`/`pull` на локальном тест-клоне |
|
||||||
|
|
||||||
|
В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`,
|
||||||
|
`pull <ID> --repo vps`, `restore-test <архив>`, `--test` первым аргументом.
|
||||||
|
|
||||||
|
### Переменные `.env`
|
||||||
|
|
||||||
|
| Переменная | Где | По умолчанию | Назначение |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `BACKUP_PASSWORD` | Pi | — | пароль шифрования; пусто = бэкапы выключены |
|
||||||
|
| `BACKUP_SCHEDULE` | Pi | `0 4 * * *` | расписание бэкапа (cron) |
|
||||||
|
| `BACKUP_VERIFY_SCHEDULE` | Pi | `30 5 * * 0` | расписание проверки данных |
|
||||||
|
| `BACKUP_TZ` | Pi | `Europe/Moscow` | часовой пояс |
|
||||||
|
| `BACKUP_KEEP_DAILY` / `WEEKLY` / `MONTHLY` | Pi | `14` / `8` / `12` | глубина хранения |
|
||||||
|
| `BACKUP_COMPRESSION` | Pi | `max` | сжатие restic: `auto` / `max` / `off` |
|
||||||
|
| `BACKUP_MAX_AGE_HOURS` | Pi | `30` | порог `unhealthy` |
|
||||||
|
| `BACKUP_VPS_HOST` | Pi | — | адрес VPS; пусто = без оффсайт-копии |
|
||||||
|
| `BACKUP_VPS_USER` | Pi | `fsbackup` | SFTP-пользователь |
|
||||||
|
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
||||||
|
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
||||||
|
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
||||||
|
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
||||||
|
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
||||||
|
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
||||||
|
|
||||||
|
У скрипта ПК переменная окружения с тем же именем важнее значения из `.env`.
|
||||||
|
|
||||||
|
### Где что лежит
|
||||||
|
|
||||||
|
| Что | Где |
|
||||||
|
|---|---|
|
||||||
|
| Локальный репозиторий | том `backup-data` на Pi → `/backup/repo` в контейнере |
|
||||||
|
| Состояние (последние запуски, ключ хоста VPS) | том `backup-data` → `/backup/state` |
|
||||||
|
| Репозиторий на VPS | `/srv/fs-backups/restic` (пользователь `fsbackup`) |
|
||||||
|
| Архивы на ПК | `backups\` в папке репозитория (в git не попадают) |
|
||||||
|
| Ключ для VPS на ПК | `deploy\backup\id_backup` (в git не попадает) |
|
||||||
|
| Код | `deploy/backup/` (образ, `fs-backup.sh`), `scripts/fs-backup.ps1` / `.sh` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Итоговый чек-лист
|
||||||
|
|
||||||
|
**Настройка**
|
||||||
|
- [ ] Пароль бэкапов сохранён в менеджере паролей **и** на бумаге (шаг 1)
|
||||||
|
- [ ] Копия `.env` с Pi сохранена в менеджере паролей (шаг 1, пункт 4)
|
||||||
|
- [ ] Ключ `deploy\backup\id_backup` создан, в git не попадает (шаг 2)
|
||||||
|
- [ ] На VPS пользователь `fsbackup`, `sftp` работает, shell закрыт (шаг 3)
|
||||||
|
- [ ] Образы опубликованы, `forbidden-stars-backup` есть под arm64 (шаг 4)
|
||||||
|
- [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5)
|
||||||
|
- [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5)
|
||||||
|
- [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6)
|
||||||
|
- [ ] Учебное восстановление на тест-клоне прошло, данные на месте (шаг 7)
|
||||||
|
|
||||||
|
**Через сутки**
|
||||||
|
- [ ] В `list` появился снимок с меткой `scheduled` в 04:00
|
||||||
|
- [ ] В `list vps` — такой же
|
||||||
|
|
||||||
|
**Через неделю**
|
||||||
|
- [ ] В `status` есть строка `Последняя проверка данных: … данные целы`
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Точка входа контейнера backup: первый снимок (если снимков ещё нет) и расписание.
|
||||||
|
# Расписание — supercronic по BACKUP_SCHEDULE (бэкап) и BACKUP_VERIFY_SCHEDULE (проверка).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||||||
|
|
||||||
|
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
||||||
|
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
||||||
|
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
||||||
|
log "BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ."
|
||||||
|
log "Настройка по шагам: deploy/backup/README.md. После правки .env: docker compose up -d backup"
|
||||||
|
exec tail -f /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /tmp/fs-backup
|
||||||
|
CRONTAB=/tmp/fs-backup/crontab
|
||||||
|
: > "$CRONTAB"
|
||||||
|
if [ -n "${BACKUP_SCHEDULE:-}" ]; then
|
||||||
|
echo "${BACKUP_SCHEDULE} fs-backup run --scheduled" >> "$CRONTAB"
|
||||||
|
fi
|
||||||
|
if [ -n "${BACKUP_VERIFY_SCHEDULE:-}" ]; then
|
||||||
|
echo "${BACKUP_VERIFY_SCHEDULE} fs-backup verify" >> "$CRONTAB"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -s "$CRONTAB" ]; then
|
||||||
|
log "Расписание отключено — только ручной запуск: docker compose exec backup fs-backup run"
|
||||||
|
exec tail -f /dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! fs-backup has-snapshots; then
|
||||||
|
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу."
|
||||||
|
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Расписание (TZ=${TZ:-UTC}):"
|
||||||
|
sed 's/^/[backup] /' "$CRONTAB"
|
||||||
|
exec supercronic -passthrough-logs "$CRONTAB"
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
#!/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
|
||||||
+14
-4
@@ -111,10 +111,20 @@ docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:a
|
|||||||
Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
|
Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
|
||||||
контейнеры поднимутся сами (используют локальный образ, без повторного pull).
|
контейнеры поднимутся сами (используют локальный образ, без повторного pull).
|
||||||
|
|
||||||
## Бэкап (опционально)
|
## Бэкапы
|
||||||
Бэкап-скриптам нужен сам `docker-compose.yml` (он на Pi) + скрипты. Скопируй рядом
|
Отдельный контейнер `backup` в том же `docker-compose.yml`: каждую ночь делает зашифрованный
|
||||||
`scripts/backup.sh` и `scripts/restore.sh`, настрой `BACKUP_*` в `.env` и cron. Подробно —
|
снимок БД, `uploads` и `achievements` на Pi и на VPS. Дополнительных файлов на Pi не нужно,
|
||||||
комментарии в `scripts/backup.sh` и `deploy/vps/README.md` §8.
|
всё настраивается блоком `BACKUP_*` в `.env`. Пока `BACKUP_PASSWORD` пуст, бэкапы выключены.
|
||||||
|
|
||||||
|
Пошаговая настройка, восстановление и действия при гибели Pi —
|
||||||
|
[`deploy/backup/README.md`](../backup/README.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup status # состояние
|
||||||
|
docker compose exec backup fs-backup list # хронология снимков
|
||||||
|
```
|
||||||
|
|
||||||
|
> Не выполняйте `docker compose down -v`: флаг `-v` удаляет тома с данными и локальными бэкапами.
|
||||||
|
|
||||||
## Если что-то не так
|
## Если что-то не так
|
||||||
- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel`
|
- `https://forbiddenstars.ru` отдаёт заглушку/502 → туннель не поднят: `docker compose logs tunnel`
|
||||||
|
|||||||
+12
-13
@@ -110,16 +110,15 @@ systemctl reload caddy
|
|||||||
Логи Caddy: `journalctl -u caddy -f`.
|
Логи Caddy: `journalctl -u caddy -f`.
|
||||||
|
|
||||||
## 8. Приём бэкапов с Pi (оффсайт-копии)
|
## 8. Приём бэкапов с Pi (оффсайт-копии)
|
||||||
Pi ежедневно шлёт полный архив (`scripts/backup.sh`) на VPS отдельным пользователем `backup`
|
Контейнер `backup` на Pi каждую ночь отправляет зашифрованный снимок (restic) на VPS по SFTP.
|
||||||
(не путать с `tunnel`). Каталог — **вне** веб-корней Caddy, права 700.
|
Для этого на VPS нужен отдельный пользователь `fsbackup` **только для SFTP**: без shell,
|
||||||
```bash
|
без туннелей, без пароля. Репозиторий лежит в `/srv/fs-backups/restic`, вне веб-корней
|
||||||
useradd -m -s /bin/bash backup
|
Caddy, так что бэкапы не публичны.
|
||||||
install -d -m 700 -o backup -g backup /home/backup/.ssh
|
|
||||||
install -m 600 -o backup -g backup /dev/null /home/backup/.ssh/authorized_keys
|
> Имя не `backup`: в Debian/Ubuntu системный пользователь `backup` уже существует.
|
||||||
install -d -m 700 -o backup -g backup /srv/fs-backups
|
|
||||||
# Публичный ключ бэкапа с Pi (deploy/backup/id_backup.pub) добавь в authorized_keys:
|
Пошаговая настройка: создание пользователя, ключ, `sshd_config.d/60-fs-backup.conf`,
|
||||||
# echo '<содержимое id_backup.pub>' >> /home/backup/.ssh/authorized_keys
|
проверки — [`deploy/backup/README.md`, шаг 3](../backup/README.md#шаг-3-vps-пользователь-только-для-sftp).
|
||||||
```
|
|
||||||
> Ротацию на VPS делает сам скрипт с Pi (`BACKUP_KEEP_REMOTE`, по умолч. 30). Каталог
|
Проверка приёма после первого бэкапа: `ls -la /srv/fs-backups/restic` (там `config`, `data`,
|
||||||
> `/srv/fs-backups` не отдаётся Caddy (нет `root`/`file_server` на него) → бэкапы не публичны.
|
`index`, `keys`, `snapshots`). Старые снимки удаляет сам контейнер с Pi по политике хранения.
|
||||||
> Проверка приёма: после `scripts/backup.sh` на Pi → `ls -1 /srv/fs-backups/` на VPS.
|
|
||||||
|
|||||||
@@ -56,7 +56,31 @@ services:
|
|||||||
- ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro
|
- ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro
|
||||||
mem_limit: 64m
|
mem_limit: 64m
|
||||||
|
|
||||||
|
# Бэкапы тест-клона: тот же образ, что у прода (сборка локально), но ТОЛЬКО локальный
|
||||||
|
# репозиторий и без расписания. BACKUP_VPS_HOST принудительно пуст — тестовые данные
|
||||||
|
# никогда не попадут в прод-репозиторий на VPS. Учебное восстановление:
|
||||||
|
# .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar
|
||||||
|
backup:
|
||||||
|
build: ./deploy/backup
|
||||||
|
image: forbidden-stars-backup:test
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-test-contour-only}
|
||||||
|
BACKUP_HOSTNAME: fs-test
|
||||||
|
BACKUP_SCHEDULE: ""
|
||||||
|
BACKUP_VERIFY_SCHEDULE: ""
|
||||||
|
BACKUP_VPS_HOST: ""
|
||||||
|
BACKUP_COMPRESSION: ${BACKUP_COMPRESSION:-max}
|
||||||
|
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
||||||
|
volumes:
|
||||||
|
- db-data-test:/fs-db
|
||||||
|
- uploads-data-test:/fs/uploads
|
||||||
|
- achievements-data-test:/fs/achievements
|
||||||
|
- backup-data-test:/backup
|
||||||
|
mem_limit: 384m
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data-test:
|
db-data-test:
|
||||||
uploads-data-test:
|
uploads-data-test:
|
||||||
achievements-data-test:
|
achievements-data-test:
|
||||||
|
backup-data-test:
|
||||||
|
|||||||
+46
-2
@@ -6,9 +6,13 @@
|
|||||||
# (scripts/build-push.*) — на Pi он не используется.
|
# (scripts/build-push.*) — на Pi он не используется.
|
||||||
#
|
#
|
||||||
# Запуск/обновление: docker compose up -d (сам тянет свежие образы)
|
# Запуск/обновление: docker compose up -d (сам тянет свежие образы)
|
||||||
# Логи: docker compose logs -f app (или: ... tunnel)
|
# Логи: docker compose logs -f app (или: ... tunnel / backup)
|
||||||
# Остановка: docker compose down (данные в томах сохраняются)
|
# Остановка: docker compose down (данные в томах сохраняются)
|
||||||
# Бэкап/restore: scripts/backup.sh / scripts/restore.sh
|
# Бэкапы: docker compose exec backup fs-backup status (help — все команды)
|
||||||
|
# инструкция: deploy/backup/README.md
|
||||||
|
#
|
||||||
|
# НИКОГДА не выполняйте на проде `docker compose down -v`: флаг -v удаляет тома — и данные
|
||||||
|
# приложения, и локальный репозиторий бэкапов (останется только копия на VPS).
|
||||||
#
|
#
|
||||||
# Контейнер ВСЕГДА production: APP_ENV форсится здесь и игнорирует значение из .env.
|
# Контейнер ВСЕГДА production: APP_ENV форсится здесь и игнорирует значение из .env.
|
||||||
# Порты на хост НЕ публикуются — наружу приложение выставляет только сервис tunnel
|
# Порты на хост НЕ публикуются — наружу приложение выставляет только сервис tunnel
|
||||||
@@ -69,7 +73,47 @@ services:
|
|||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
|
|
||||||
|
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
||||||
|
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
||||||
|
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
|
||||||
|
# восстановление — deploy/backup/README.md.
|
||||||
|
backup:
|
||||||
|
build: ./deploy/backup
|
||||||
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены
|
||||||
|
BACKUP_HOSTNAME: fs-prod # имя хоста в снимках (у тест-клона — fs-test)
|
||||||
|
BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *}
|
||||||
|
BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0}
|
||||||
|
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
|
||||||
|
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
|
||||||
|
BACKUP_KEEP_MONTHLY: ${BACKUP_KEEP_MONTHLY:-12}
|
||||||
|
BACKUP_COMPRESSION: ${BACKUP_COMPRESSION:-max}
|
||||||
|
BACKUP_MAX_AGE_HOURS: ${BACKUP_MAX_AGE_HOURS:-30}
|
||||||
|
BACKUP_VPS_HOST: ${BACKUP_VPS_HOST:-}
|
||||||
|
BACKUP_VPS_USER: ${BACKUP_VPS_USER:-fsbackup}
|
||||||
|
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
|
||||||
|
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
|
||||||
|
BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-}
|
||||||
|
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
||||||
|
volumes:
|
||||||
|
- db-data:/fs-db # живая БД (снимается консистентно)
|
||||||
|
- uploads-data:/fs/uploads
|
||||||
|
- achievements-data:/fs/achievements
|
||||||
|
- backup-data:/backup # локальный репозиторий restic + состояние
|
||||||
|
mem_limit: ${BACKUP_MEM_LIMIT:-384m}
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db-data:
|
db-data:
|
||||||
uploads-data:
|
uploads-data:
|
||||||
achievements-data:
|
achievements-data:
|
||||||
|
backup-data:
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Полный консистентный онлайн-бэкап прода (БД + uploads + achievements) с работающего
|
|
||||||
# контейнера + оффсайт-копия на VPS. Запуск по cron на хосте Pi, например ежедневно в 04:00:
|
|
||||||
# 0 4 * * * /home/pi/ForbidenStarsApp/scripts/backup.sh >> /var/log/fs-backup.log 2>&1
|
|
||||||
#
|
|
||||||
# Настройки берутся из корневого .env (BACKUP_*), их можно переопределить переменными
|
|
||||||
# окружения. Если BACKUP_VPS_HOST пуст — делается только локальная копия в backups/.
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml"
|
|
||||||
SERVICE="app"
|
|
||||||
TS="$(date +%Y%m%d_%H%M%S)"
|
|
||||||
|
|
||||||
# Безопасно прочитать ОДИН ключ из .env (НЕ source: в .env есть спецсимволы # & и т.п.).
|
|
||||||
read_env() { # $1=ключ $2=дефолт
|
|
||||||
local val
|
|
||||||
val="$(grep -E "^$1=" "$PROJECT_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2- | tr -d '\r')"
|
|
||||||
val="${val%\"}"; val="${val#\"}" # снять обрамляющие кавычки, если есть
|
|
||||||
printf '%s' "${val:-$2}"
|
|
||||||
}
|
|
||||||
|
|
||||||
BACKUP_DIR="${BACKUP_DIR:-$PROJECT_DIR/backups}"
|
|
||||||
KEEP_LOCAL="${BACKUP_KEEP_LOCAL:-$(read_env BACKUP_KEEP_LOCAL 14)}"
|
|
||||||
KEEP_REMOTE="${BACKUP_KEEP_REMOTE:-$(read_env BACKUP_KEEP_REMOTE 30)}"
|
|
||||||
VPS_HOST="${BACKUP_VPS_HOST:-$(read_env BACKUP_VPS_HOST "")}"
|
|
||||||
VPS_USER="${BACKUP_VPS_USER:-$(read_env BACKUP_VPS_USER backup)}"
|
|
||||||
VPS_DIR="${BACKUP_VPS_DIR:-$(read_env BACKUP_VPS_DIR /srv/fs-backups)}"
|
|
||||||
VPS_KEY="${BACKUP_VPS_KEY:-$(read_env BACKUP_VPS_KEY "$PROJECT_DIR/deploy/backup/id_backup")}"
|
|
||||||
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
STAGE="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$STAGE"' EXIT
|
|
||||||
|
|
||||||
CID="$($COMPOSE ps -q "$SERVICE")"
|
|
||||||
if [ -z "$CID" ]; then
|
|
||||||
echo "[backup] ОШИБКА: контейнер '$SERVICE' не запущен." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[backup] Консистентный снимок БД…"
|
|
||||||
$COMPOSE exec -T "$SERVICE" python -c "import sqlite3; s=sqlite3.connect('/data/forbidden_stars.db'); d=sqlite3.connect('/data/backup_tmp.db'); s.backup(d); d.close(); s.close()"
|
|
||||||
docker cp "$CID:/data/backup_tmp.db" "$STAGE/forbidden_stars.db"
|
|
||||||
$COMPOSE exec -T "$SERVICE" rm -f /data/backup_tmp.db
|
|
||||||
|
|
||||||
echo "[backup] Копирование медиа и титулов…"
|
|
||||||
docker cp "$CID:/data/uploads" "$STAGE/uploads"
|
|
||||||
docker cp "$CID:/data/achievements" "$STAGE/achievements"
|
|
||||||
|
|
||||||
ARCHIVE="$BACKUP_DIR/fs_$TS.tar.gz"
|
|
||||||
tar -czf "$ARCHIVE" -C "$STAGE" forbidden_stars.db uploads achievements
|
|
||||||
echo "[backup] Локальный архив: $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
|
|
||||||
|
|
||||||
# Локальная ротация: оставить последние $KEEP_LOCAL (архив только что создан → ≥1 файл).
|
|
||||||
ls -1t "$BACKUP_DIR"/fs_*.tar.gz 2>/dev/null | tail -n +"$((KEEP_LOCAL + 1))" | xargs -r rm -f
|
|
||||||
|
|
||||||
# Оффсайт-копия на VPS (push: Pi за CGNAT, входящих нет).
|
|
||||||
if [ -n "$VPS_HOST" ]; then
|
|
||||||
if [ ! -f "$VPS_KEY" ]; then
|
|
||||||
echo "[backup] ОШИБКА: ключ $VPS_KEY не найден — оффсайт-копия пропущена." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[backup] Отправка на VPS $VPS_USER@$VPS_HOST:$VPS_DIR …"
|
|
||||||
SSH_OPTS=(-i "$VPS_KEY" -o StrictHostKeyChecking=accept-new -o BatchMode=yes)
|
|
||||||
scp "${SSH_OPTS[@]}" "$ARCHIVE" "$VPS_USER@$VPS_HOST:$VPS_DIR/"
|
|
||||||
# Удалённая ротация (sh на VPS без pipefail → пустой ls не уронит команду).
|
|
||||||
ssh "${SSH_OPTS[@]}" "$VPS_USER@$VPS_HOST" \
|
|
||||||
"ls -1t '$VPS_DIR'/fs_*.tar.gz 2>/dev/null | tail -n +$((KEEP_REMOTE + 1)) | xargs -r rm -f"
|
|
||||||
echo "[backup] Оффсайт-копия отправлена."
|
|
||||||
else
|
|
||||||
echo "[backup] BACKUP_VPS_HOST не задан — только локальная копия."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[backup] Готово."
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Build prod images (app + tunnel) for linux/arm64 (Raspberry Pi) and push to the registry.
|
# Build prod images (app + tunnel + backup) for linux/arm64 (Raspberry Pi) and push to the registry.
|
||||||
# Run on the DEV machine with Docker Desktop / buildx. Registry and tag come from the root
|
# Run on the DEV machine with Docker Desktop / buildx. Registry and tag come from the root
|
||||||
# .env (IMAGE_REGISTRY / IMAGE_TAG) - the same values docker-compose.yml uses.
|
# .env (IMAGE_REGISTRY / IMAGE_TAG) - the same values docker-compose.yml uses.
|
||||||
# Log in to the registry once before running: docker login gitea.arseniev.info
|
# Log in to the registry once before running: docker login gitea.arseniev.info
|
||||||
@@ -16,7 +16,7 @@ if ($LASTEXITCODE -ne 0) {
|
|||||||
# arm64 emulation on an x86 host (usually already present in Docker Desktop; idempotent).
|
# arm64 emulation on an x86 host (usually already present in Docker Desktop; idempotent).
|
||||||
docker run --privileged --rm tonistiigi/binfmt --install arm64 | Out-Null
|
docker run --privileged --rm tonistiigi/binfmt --install arm64 | Out-Null
|
||||||
|
|
||||||
Write-Host "Building app+tunnel for linux/arm64 and pushing to the registry..." -ForegroundColor Green
|
Write-Host "Building app+tunnel+backup for linux/arm64 and pushing to the registry..." -ForegroundColor Green
|
||||||
docker buildx bake -f docker-compose.yml --builder fsbuilder --set "*.platform=linux/arm64" --push
|
docker buildx bake -f docker-compose.yml --builder fsbuilder --set "*.platform=linux/arm64" --push
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Host "Build/push FAILED (exit $LASTEXITCODE)." -ForegroundColor Red
|
Write-Host "Build/push FAILED (exit $LASTEXITCODE)." -ForegroundColor Red
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Сборка прод-образов (app + tunnel) под linux/arm64 для Raspberry Pi и пуш в реестр.
|
# Сборка прод-образов (app + tunnel + backup) под linux/arm64 для Raspberry Pi и пуш в реестр.
|
||||||
# Запускается на машине РАЗРАБОТКИ с Docker Desktop/buildx (не на Pi).
|
# Запускается на машине РАЗРАБОТКИ с Docker Desktop/buildx (не на Pi).
|
||||||
# Реестр и тег берутся из корневого .env (IMAGE_REGISTRY / IMAGE_TAG) — те же, что в
|
# Реестр и тег берутся из корневого .env (IMAGE_REGISTRY / IMAGE_TAG) — те же, что в
|
||||||
# docker-compose.yml. Перед запуском один раз залогиниться в реестр:
|
# docker-compose.yml. Перед запуском один раз залогиниться в реестр:
|
||||||
@@ -17,7 +17,7 @@ fi
|
|||||||
# Эмуляция arm64 на x86-хосте (в Docker Desktop обычно уже стоит; команда идемпотентна).
|
# Эмуляция arm64 на x86-хосте (в Docker Desktop обычно уже стоит; команда идемпотентна).
|
||||||
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null
|
docker run --privileged --rm tonistiigi/binfmt --install arm64 >/dev/null
|
||||||
|
|
||||||
echo "[build-push] Сборка app+tunnel под linux/arm64 и пуш в реестр…"
|
echo "[build-push] Сборка app+tunnel+backup под linux/arm64 и пуш в реестр…"
|
||||||
# bake читает docker-compose.yml: контексты build: и теги image: (с подстановкой из .env).
|
# bake читает docker-compose.yml: контексты build: и теги image: (с подстановкой из .env).
|
||||||
docker buildx bake -f docker-compose.yml --builder fsbuilder --set "*.platform=linux/arm64" --push
|
docker buildx bake -f docker-compose.yml --builder fsbuilder --set "*.platform=linux/arm64" --push
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# Forbidden Stars backups from the PC. Talks to the `backup` container of the prod on the Pi
|
||||||
|
# over SSH, or (with -Target test) to the local test clone (docker-compose.test.yml).
|
||||||
|
# Step-by-step guide: deploy/backup/README.md
|
||||||
|
#
|
||||||
|
# .\scripts\fs-backup.ps1 status backup state on the Pi
|
||||||
|
# .\scripts\fs-backup.ps1 list [-Repo vps] snapshot history
|
||||||
|
# .\scripts\fs-backup.ps1 now [-Tag before-update] make a snapshot right now
|
||||||
|
# .\scripts\fs-backup.ps1 verify check data integrity in the repositories
|
||||||
|
# .\scripts\fs-backup.ps1 pull [-Snapshot <id>] [-Repo vps]
|
||||||
|
# download a snapshot to backups\ (sha256 checked)
|
||||||
|
# .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar
|
||||||
|
# practice restore into the local test clone
|
||||||
|
# add -Target test to run status/list/now/verify/pull against the local test clone
|
||||||
|
#
|
||||||
|
# Settings come from the root .env (an environment variable with the same name wins):
|
||||||
|
# BACKUP_PI_SSH how to reach the Pi over SSH, e.g. pi@192.168.1.10 (or a Host alias)
|
||||||
|
# BACKUP_PI_DIR folder on the Pi with docker-compose.yml and .env (default ~/forbidden-stars)
|
||||||
|
#
|
||||||
|
# Keep this file ASCII-only: Windows PowerShell 5.1 breaks on non-ASCII without a BOM.
|
||||||
|
param(
|
||||||
|
[Parameter(Position = 0)]
|
||||||
|
[ValidateSet("status", "list", "now", "verify", "pull", "restore-test", "help")]
|
||||||
|
[string]$Command = "help",
|
||||||
|
[string]$Snapshot = "latest",
|
||||||
|
[ValidateSet("local", "vps")]
|
||||||
|
[string]$Repo = "local",
|
||||||
|
[string]$Tag = "",
|
||||||
|
[string]$File = "",
|
||||||
|
[ValidateSet("pi", "test")]
|
||||||
|
[string]$Target = "pi"
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
# Native tools (ssh, scp, docker) write progress and warnings to stderr. Under "Stop" with a
|
||||||
|
# redirected stderr, PowerShell 5.1 turns those lines into terminating errors - so native calls
|
||||||
|
# run under "Continue" and success is judged by $LASTEXITCODE only.
|
||||||
|
$root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$envFile = Join-Path $root ".env"
|
||||||
|
$testCompose = Join-Path $root "docker-compose.test.yml"
|
||||||
|
|
||||||
|
# Read a key: environment variable first, then the root .env (last assignment wins).
|
||||||
|
function Get-Setting([string]$name, [string]$default) {
|
||||||
|
$fromEnv = [Environment]::GetEnvironmentVariable($name)
|
||||||
|
if ($fromEnv) { return $fromEnv }
|
||||||
|
$val = $default
|
||||||
|
if (Test-Path $envFile) {
|
||||||
|
foreach ($line in Get-Content $envFile) {
|
||||||
|
if ($line -match "^\s*$name\s*=\s*([^#\s]+)") { $val = $matches[1].Trim().Trim('"') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $val
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fail([string]$msg) {
|
||||||
|
Write-Host $msg -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function Show-Help {
|
||||||
|
Get-Content $PSCommandPath -TotalCount 19 | ForEach-Object { $_ -replace '^# ?', '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
$piSsh = Get-Setting "BACKUP_PI_SSH" ""
|
||||||
|
$piDir = Get-Setting "BACKUP_PI_DIR" "~/forbidden-stars"
|
||||||
|
|
||||||
|
# Run a shell command on the Pi inside the prod compose folder. Output goes to the console
|
||||||
|
# unless the caller captures it.
|
||||||
|
function Invoke-Pi([string]$shellCmd) {
|
||||||
|
if (-not $piSsh) {
|
||||||
|
Fail "Set BACKUP_PI_SSH in .env (how you ssh to the Pi, e.g. pi@192.168.1.10). See deploy/backup/README.md, step 6."
|
||||||
|
}
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& ssh -o ConnectTimeout=15 $piSsh "cd $piDir && $shellCmd"
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-Scp([string]$from, [string]$to) {
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& scp -o ConnectTimeout=15 $from $to
|
||||||
|
}
|
||||||
|
|
||||||
|
function Invoke-TestCompose([string[]]$composeArgs) {
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& docker compose -f $testCompose @composeArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make sure the backup container of the test clone is running (build it if needed).
|
||||||
|
function Start-TestBackup {
|
||||||
|
$id = (Invoke-TestCompose @("ps", "-q", "backup")) | Select-Object -First 1
|
||||||
|
if (-not $id) {
|
||||||
|
Write-Host "Starting the backup container of the test clone..." -ForegroundColor Cyan
|
||||||
|
Invoke-TestCompose @("up", "-d", "--build", "backup") | Out-Host
|
||||||
|
if ($LASTEXITCODE -ne 0) { Fail "Could not start the test clone backup container." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run fs-backup with arguments on the chosen target; output goes to the console.
|
||||||
|
function Invoke-FsBackup([string[]]$fsArgs) {
|
||||||
|
if ($Target -eq "test") {
|
||||||
|
Start-TestBackup
|
||||||
|
Invoke-TestCompose (@("exec", "-T", "backup", "fs-backup") + $fsArgs)
|
||||||
|
} else {
|
||||||
|
Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Assert-LastExit([string]$what) {
|
||||||
|
if ($LASTEXITCODE -ne 0) { Fail "$what failed (exit code $LASTEXITCODE)." }
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- pull
|
||||||
|
function Invoke-Pull {
|
||||||
|
$backupsDir = Join-Path $root "backups"
|
||||||
|
New-Item -ItemType Directory -Force $backupsDir | Out-Null
|
||||||
|
|
||||||
|
# Resolve the snapshot: short id + time -> file name fs_<yyyyMMdd_HHmm>_<id>.tar
|
||||||
|
$info = Invoke-FsBackup @("info", $Snapshot, "--repo", $Repo) | Select-Object -Last 1
|
||||||
|
Assert-LastExit "Snapshot lookup"
|
||||||
|
$parts = "$info".Trim() -split "\s+"
|
||||||
|
if ($parts.Count -lt 2) { Fail "Unexpected answer from fs-backup info: '$info'" }
|
||||||
|
$id = $parts[0]
|
||||||
|
$name = "fs_$($parts[1])_$id.tar"
|
||||||
|
$local = Join-Path $backupsDir $name
|
||||||
|
if (Test-Path $local) {
|
||||||
|
Write-Host "Already downloaded: $local" -ForegroundColor Yellow
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
$partial = "$local.part"
|
||||||
|
if ($Target -eq "test") {
|
||||||
|
Start-TestBackup
|
||||||
|
$tmp = "/tmp/fs-backup/export-$id.tar"
|
||||||
|
$hashLine = Invoke-TestCompose @("exec", "-T", "backup", "sh", "-c",
|
||||||
|
"fs-backup export $id --repo $Repo > $tmp && sha256sum $tmp") | Select-Object -Last 1
|
||||||
|
Assert-LastExit "Export"
|
||||||
|
try {
|
||||||
|
Invoke-TestCompose @("cp", "backup:$tmp", $partial)
|
||||||
|
Assert-LastExit "Copy from the container"
|
||||||
|
} finally {
|
||||||
|
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $tmp) | Out-Null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
# Export into a file in the Pi user's home (binary data never passes through
|
||||||
|
# PowerShell pipes - they would corrupt it), then scp it and compare sha256.
|
||||||
|
$remote = "fs-export-$id.tar"
|
||||||
|
$hashLine = Invoke-Pi "docker compose exec -T backup fs-backup export $id --repo $Repo > ~/$remote && sha256sum ~/$remote" |
|
||||||
|
Select-Object -Last 1
|
||||||
|
Assert-LastExit "Export on the Pi"
|
||||||
|
try {
|
||||||
|
Invoke-Scp "${piSsh}:$remote" $partial
|
||||||
|
Assert-LastExit "scp"
|
||||||
|
} finally {
|
||||||
|
Invoke-Pi "rm -f ~/$remote"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = ("$hashLine".Trim() -split "\s+")[0].ToLower()
|
||||||
|
$actual = (Get-FileHash -Algorithm SHA256 $partial).Hash.ToLower()
|
||||||
|
if ($expected -ne $actual) {
|
||||||
|
Remove-Item $partial -Force
|
||||||
|
Fail "Checksum mismatch (expected $expected, got $actual) - the download is removed, run pull again."
|
||||||
|
}
|
||||||
|
Move-Item $partial $local
|
||||||
|
|
||||||
|
$entries = & {
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
& "$env:SystemRoot\System32\tar.exe" -tf $local
|
||||||
|
}
|
||||||
|
Assert-LastExit "tar listing"
|
||||||
|
if (-not ($entries -contains "forbidden_stars.db")) { Fail "The archive has no forbidden_stars.db: $local" }
|
||||||
|
$files = @($entries | Where-Object { $_ -notmatch '/$' }).Count
|
||||||
|
$sizeMb = [math]::Round((Get-Item $local).Length / 1MB, 1)
|
||||||
|
Write-Host "OK: $local ($sizeMb MB, $files files, sha256 verified)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- restore-test
|
||||||
|
function Invoke-RestoreTest {
|
||||||
|
if (-not $File) { Fail "Specify the archive: -File backups\fs_....tar" }
|
||||||
|
if (-not (Test-Path $File -PathType Leaf)) { Fail "File not found: $File" }
|
||||||
|
$full = (Resolve-Path $File).Path
|
||||||
|
$inContainer = "/import/restore-test.archive" # tar or tar.gz: fs-backup detects the format itself
|
||||||
|
|
||||||
|
Write-Host "Practice restore of $full into the LOCAL TEST CLONE (prod is not touched)." -ForegroundColor Cyan
|
||||||
|
Start-TestBackup
|
||||||
|
Invoke-TestCompose @("stop", "app")
|
||||||
|
Assert-LastExit "Stopping the test app"
|
||||||
|
Invoke-TestCompose @("cp", $full, "backup:$inContainer")
|
||||||
|
Assert-LastExit "Copy into the container"
|
||||||
|
try {
|
||||||
|
Invoke-TestCompose @("exec", "-T", "backup", "fs-backup", "import", $inContainer, "--yes")
|
||||||
|
$importExit = $LASTEXITCODE
|
||||||
|
} finally {
|
||||||
|
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $inContainer) | Out-Null
|
||||||
|
}
|
||||||
|
if ($importExit -ne 0) { Fail "Import failed (exit code $importExit). The test clone data was not changed." }
|
||||||
|
|
||||||
|
Invoke-TestCompose @("up", "-d", "app")
|
||||||
|
Assert-LastExit "Starting the test app"
|
||||||
|
Write-Host "Done. The test clone now runs on the restored data." -ForegroundColor Green
|
||||||
|
Write-Host " See it on https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (or .\run.ps1 with APP_ENV=test)"
|
||||||
|
Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f app"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- main
|
||||||
|
$prevEncoding = $null
|
||||||
|
try {
|
||||||
|
$prevEncoding = [Console]::OutputEncoding
|
||||||
|
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # container messages are UTF-8
|
||||||
|
} catch { }
|
||||||
|
try {
|
||||||
|
switch ($Command) {
|
||||||
|
"status" { Invoke-FsBackup @("status"); Assert-LastExit "status" }
|
||||||
|
"list" { Invoke-FsBackup @("list", $Repo); Assert-LastExit "list" }
|
||||||
|
"now" {
|
||||||
|
$runArgs = @("run")
|
||||||
|
if ($Tag) { $runArgs += @("--tag", $Tag) }
|
||||||
|
Invoke-FsBackup $runArgs
|
||||||
|
Assert-LastExit "Backup"
|
||||||
|
}
|
||||||
|
"verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" }
|
||||||
|
"pull" { Invoke-Pull }
|
||||||
|
"restore-test" { Invoke-RestoreTest }
|
||||||
|
default { Show-Help }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if ($prevEncoding) { try { [Console]::OutputEncoding = $prevEncoding } catch { } }
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup
|
||||||
|
# прода на Pi по SSH или (с --test) в локальный тест-клон (docker-compose.test.yml).
|
||||||
|
# На Windows удобнее scripts/fs-backup.ps1 — поведение то же. Инструкция: deploy/backup/README.md
|
||||||
|
#
|
||||||
|
# scripts/fs-backup.sh status состояние бэкапов на Pi
|
||||||
|
# scripts/fs-backup.sh list [vps] хронология снимков
|
||||||
|
# scripts/fs-backup.sh now [--tag before-update] сделать снимок сейчас
|
||||||
|
# scripts/fs-backup.sh verify проверить целостность данных
|
||||||
|
# scripts/fs-backup.sh pull [<id>|latest] [--repo vps]
|
||||||
|
# скачать снимок в backups/ (сверка sha256)
|
||||||
|
# scripts/fs-backup.sh restore-test <файл.tar|.tar.gz>
|
||||||
|
# учебное восстановление в локальный тест-клон
|
||||||
|
# --test первым аргументом — status/list/now/verify/pull для локального тест-клона
|
||||||
|
#
|
||||||
|
# Настройки — из корневого .env (переменная окружения с тем же именем важнее):
|
||||||
|
# BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config)
|
||||||
|
# BACKUP_PI_DIR папка на Pi с docker-compose.yml и .env (по умолчанию ~/forbidden-stars)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
TEST_COMPOSE="$PROJECT_DIR/docker-compose.test.yml"
|
||||||
|
export MSYS_NO_PATHCONV=1 # Git Bash: не переписывать /import/... в аргументах docker
|
||||||
|
|
||||||
|
die() { echo "ОШИБКА: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
setting() { # setting <ключ> <по умолчанию>: окружение, затем .env (последнее присваивание)
|
||||||
|
local val="${!1:-}"
|
||||||
|
if [ -z "$val" ] && [ -f "$PROJECT_DIR/.env" ]; then
|
||||||
|
val="$(grep -E "^\s*$1\s*=" "$PROJECT_DIR/.env" | tail -n 1 | cut -d= -f2- | sed -e 's/\s*#.*$//' -e 's/^\s*//' -e 's/\s*$//' | tr -d '\r"')"
|
||||||
|
fi
|
||||||
|
printf '%s' "${val:-$2}"
|
||||||
|
}
|
||||||
|
|
||||||
|
PI_SSH="$(setting BACKUP_PI_SSH "")"
|
||||||
|
# shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi
|
||||||
|
PI_DIR="$(setting BACKUP_PI_DIR "~/forbidden-stars")"
|
||||||
|
TARGET="pi"
|
||||||
|
if [ "${1:-}" = "--test" ]; then TARGET="test"; shift; fi
|
||||||
|
|
||||||
|
native_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi; }
|
||||||
|
sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; }
|
||||||
|
|
||||||
|
pi() { # pi <shell-команда>: выполнить на Pi в папке прода
|
||||||
|
[ -n "$PI_SSH" ] || die "задайте BACKUP_PI_SSH в .env (как вы заходите на Pi, например pi@192.168.1.10) — README, шаг 6."
|
||||||
|
ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
tc() { docker compose -f "$(native_path "$TEST_COMPOSE")" "$@"; }
|
||||||
|
|
||||||
|
start_test_backup() {
|
||||||
|
if [ -z "$(tc ps -q backup 2>/dev/null)" ]; then
|
||||||
|
echo "Запускаю контейнер backup тест-клона…"
|
||||||
|
tc up -d --build backup
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
fs() { # fs <аргументы fs-backup…>
|
||||||
|
if [ "$TARGET" = test ]; then
|
||||||
|
start_test_backup
|
||||||
|
tc exec -T backup fs-backup "$@"
|
||||||
|
else
|
||||||
|
pi "docker compose exec -T backup fs-backup $*"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_pull() {
|
||||||
|
local snap=latest repo=local
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--repo) repo="${2:?--repo требует значение}"; shift ;;
|
||||||
|
*) snap="$1" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
mkdir -p "$PROJECT_DIR/backups"
|
||||||
|
local info id stamp local_file partial expected actual
|
||||||
|
info="$(fs info "$snap" --repo "$repo" | tail -n 1)"
|
||||||
|
id="$(printf '%s' "$info" | awk '{ print $1 }')"
|
||||||
|
stamp="$(printf '%s' "$info" | awk '{ print $2 }')"
|
||||||
|
[ -n "$id" ] && [ -n "$stamp" ] || die "неожиданный ответ fs-backup info: '$info'"
|
||||||
|
local_file="$PROJECT_DIR/backups/fs_${stamp}_${id}.tar"
|
||||||
|
if [ -f "$local_file" ]; then echo "Уже скачан: $local_file"; return 0; fi
|
||||||
|
partial="$local_file.part"
|
||||||
|
echo "Снимок $id ($repo) -> $local_file"
|
||||||
|
|
||||||
|
if [ "$TARGET" = test ]; then
|
||||||
|
start_test_backup
|
||||||
|
local tmp="/tmp/fs-backup/export-$id.tar"
|
||||||
|
expected="$(tc exec -T backup sh -c "fs-backup export $id --repo $repo > $tmp && sha256sum $tmp" | tail -n 1 | cut -d' ' -f1)"
|
||||||
|
tc cp "backup:$tmp" "$(native_path "$partial")" || { tc exec -T backup rm -f "$tmp"; die "копирование из контейнера не удалось"; }
|
||||||
|
tc exec -T backup rm -f "$tmp"
|
||||||
|
else
|
||||||
|
local remote="fs-export-$id.tar"
|
||||||
|
expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)"
|
||||||
|
scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; }
|
||||||
|
pi "rm -f ~/$remote"
|
||||||
|
fi
|
||||||
|
|
||||||
|
actual="$(sha256 "$partial")"
|
||||||
|
if [ "$expected" != "$actual" ]; then
|
||||||
|
rm -f "$partial"
|
||||||
|
die "контрольная сумма не совпала (ожидалась $expected, получена $actual) — файл удалён, повторите pull."
|
||||||
|
fi
|
||||||
|
mv "$partial" "$local_file"
|
||||||
|
tar -tf "$local_file" | grep -qx 'forbidden_stars.db' || die "в архиве нет forbidden_stars.db: $local_file"
|
||||||
|
echo "OK: $local_file ($(du -h "$local_file" | cut -f1), $(tar -tf "$local_file" | grep -vc '/$') файлов, sha256 сверена)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_restore_test() {
|
||||||
|
local file="${1:-}"
|
||||||
|
[ -n "$file" ] || die "укажите архив: scripts/fs-backup.sh restore-test backups/fs_....tar"
|
||||||
|
[ -f "$file" ] || die "файл не найден: $file"
|
||||||
|
local in_container=/import/restore-test.archive # tar или tar.gz — формат fs-backup определит сам
|
||||||
|
echo "Учебное восстановление $file в ЛОКАЛЬНЫЙ ТЕСТ-КЛОН (прод не затрагивается)."
|
||||||
|
start_test_backup
|
||||||
|
tc stop app
|
||||||
|
tc cp "$(native_path "$file")" "backup:$in_container"
|
||||||
|
local rc=0
|
||||||
|
tc exec -T backup fs-backup import "$in_container" --yes || rc=$?
|
||||||
|
tc exec -T backup rm -f "$in_container"
|
||||||
|
[ "$rc" -eq 0 ] || die "импорт не удался (код $rc) — данные тест-клона не изменены."
|
||||||
|
tc up -d app
|
||||||
|
echo "Готово: тест-клон работает на восстановленных данных."
|
||||||
|
echo " На https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (или ./run.sh при APP_ENV=test)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd="${1:-help}"
|
||||||
|
[ $# -eq 0 ] || shift
|
||||||
|
case "$cmd" in
|
||||||
|
status) fs status ;;
|
||||||
|
list) fs list "${1:-local}" ;;
|
||||||
|
now) fs run "$@" ;;
|
||||||
|
verify) fs verify ;;
|
||||||
|
pull) cmd_pull "$@" ;;
|
||||||
|
restore-test) cmd_restore_test "$@" ;;
|
||||||
|
*) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' ;;
|
||||||
|
esac
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Восстановление прода из архива бэкапа (fs_<TS>.tar.gz): БД + uploads + achievements.
|
|
||||||
# ДЕСТРУКТИВНО — заменяет текущие данные тома. Сначала проверьте на тест-контуре!
|
|
||||||
# Использование:
|
|
||||||
# scripts/restore.sh backups/fs_YYYYmmdd_HHMMSS.tar.gz [--yes]
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
||||||
COMPOSE="docker compose -f $PROJECT_DIR/docker-compose.yml"
|
|
||||||
SERVICE="app"
|
|
||||||
|
|
||||||
ARCHIVE="${1:-}"
|
|
||||||
CONFIRM="${2:-}"
|
|
||||||
if [ -z "$ARCHIVE" ] || [ ! -f "$ARCHIVE" ]; then
|
|
||||||
echo "Использование: $0 <путь к fs_*.tar.gz> [--yes]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if [ "$CONFIRM" != "--yes" ]; then
|
|
||||||
printf "ВНИМАНИЕ: текущие БД/uploads/achievements будут заменены из %s\nПродолжить? [y/N] " "$ARCHIVE"
|
|
||||||
read -r ans
|
|
||||||
case "$ans" in y|Y|yes|да|Да) ;; *) echo "Отменено."; exit 1 ;; esac
|
|
||||||
fi
|
|
||||||
|
|
||||||
STAGE="$(mktemp -d)"
|
|
||||||
trap 'rm -rf "$STAGE"' EXIT
|
|
||||||
tar -xzf "$ARCHIVE" -C "$STAGE"
|
|
||||||
if [ ! -f "$STAGE/forbidden_stars.db" ]; then
|
|
||||||
echo "ОШИБКА: в архиве нет forbidden_stars.db." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[restore] Остановка приложения…"
|
|
||||||
$COMPOSE stop "$SERVICE"
|
|
||||||
|
|
||||||
echo "[restore] Очистка тома (старая БД, WAL/SHM, медиа, титулы)…"
|
|
||||||
$COMPOSE run --rm --no-deps -T --entrypoint sh "$SERVICE" -c \
|
|
||||||
"rm -f /data/forbidden_stars.db /data/forbidden_stars.db-wal /data/forbidden_stars.db-shm; rm -rf /data/uploads/* /data/achievements/*"
|
|
||||||
|
|
||||||
CID="$($COMPOSE ps -aq "$SERVICE")"
|
|
||||||
echo "[restore] Запись данных в контейнер…"
|
|
||||||
docker cp "$STAGE/forbidden_stars.db" "$CID:/data/forbidden_stars.db"
|
|
||||||
[ -d "$STAGE/uploads" ] && docker cp "$STAGE/uploads/." "$CID:/data/uploads"
|
|
||||||
[ -d "$STAGE/achievements" ] && docker cp "$STAGE/achievements/." "$CID:/data/achievements"
|
|
||||||
|
|
||||||
echo "[restore] Запуск приложения…"
|
|
||||||
$COMPOSE start "$SERVICE"
|
|
||||||
echo "[restore] Готово. Проверьте https://forbiddenstars.ru и логи: $COMPOSE logs -f app"
|
|
||||||
Reference in New Issue
Block a user