[P0] Off-box бэкап tradein-postgres + restore-тест + hardening backup-скрипта #2203

Closed
opened 2026-07-02 19:17:23 +00:00 by lekss361 · 9 comments
Owner

Эпик: #2200

Находка (Ops, CRITICAL, verified): все бэкапы локальные — /opt/gendesign/backups/tradein/ на том же /dev/vda1, что и /var/lib/docker. backup-tradein-db.sh:16-42 не имеет remote-upload; S3-путь в ops/backup.sh мёртв (нет /etc/default/gendesign-backup). Один сбой диска/компрометация/rm = БД (4.3ГБ корпуса за месяцы) + 7 дампов + Forgejo одновременно. Плюс backup-tradein-db.sh:27 pg_dump 2>/dev/null глотает stderr (тихий partial-дамп проходит), логи в /tmp (стёрты на reboot).

Acceptance:

  • Ежедневный off-box аплоад дампа (S3/rclone/rsync на другой хост) с ретеншеном.
  • Реальный restore-тест: развернуть последний дамп в чистую БД, сверить row-counts ключевых таблиц.
  • Hardening: MIN_DUMP_BYTES floor, stderr не глотать, алерт если cron не отработал.
  • Off-box копия .env.runtime (иначе pgp_sym_encrypted CIAN-cookie в дампах недекодируемы).

Scope: tradein-mvp/deploy/backup-tradein-db.sh, ops/. needs-human: внешнее хранилище/креды.

Эпик: #2200 **Находка (Ops, CRITICAL, verified):** все бэкапы локальные — `/opt/gendesign/backups/tradein/` на том же `/dev/vda1`, что и `/var/lib/docker`. `backup-tradein-db.sh:16-42` не имеет remote-upload; S3-путь в `ops/backup.sh` мёртв (нет `/etc/default/gendesign-backup`). Один сбой диска/компрометация/`rm` = БД (4.3ГБ корпуса за месяцы) + 7 дампов + Forgejo одновременно. Плюс `backup-tradein-db.sh:27` `pg_dump 2>/dev/null` глотает stderr (тихий partial-дамп проходит), логи в `/tmp` (стёрты на reboot). **Acceptance:** - [x] Ежедневный off-box аплоад дампа (S3/rclone/rsync на другой хост) с ретеншеном. - [x] Реальный restore-тест: развернуть последний дамп в чистую БД, сверить row-counts ключевых таблиц. - [x] Hardening: `MIN_DUMP_BYTES` floor, stderr не глотать, алерт если cron не отработал. - [x] Off-box копия `.env.runtime` (иначе pgp_sym_encrypted CIAN-cookie в дампах недекодируемы). Scope: `tradein-mvp/deploy/backup-tradein-db.sh`, `ops/`. _needs-human: внешнее хранилище/креды._
lekss361 added the
needs-human
observability
priority/p0
scope/devops
tradein
labels 2026-07-02 19:20:15 +00:00
Author
Owner

needs-human (night shift, 2026-07-03): off-box бэкап tradein-postgres требует внешнего хранилища и кредов вне коробки — провижининг за человеком. Что нужно: (1) выбрать хранилище (S3-совместимое / другой VPS / rclone-таргет), (2) создать креды и положить их на VPS вне git, (3) сказать боту — скрипт pg_dump+ротация+restore-тест в PR сделаем по готовому таргету (код-часть bot-doable, отмечено в issue). Без off-box копии единственный бэкап живёт на той же коробке, что и БД (single point of loss).

needs-human (night shift, 2026-07-03): off-box бэкап tradein-postgres требует внешнего хранилища и кредов вне коробки — провижининг за человеком. Что нужно: (1) выбрать хранилище (S3-совместимое / другой VPS / rclone-таргет), (2) создать креды и положить их на VPS вне git, (3) сказать боту — скрипт pg_dump+ротация+restore-тест в PR сделаем по готовому таргету (код-часть bot-doable, отмечено в issue). Без off-box копии единственный бэкап живёт на той же коробке, что и БД (single point of loss).
Author
Owner

По решению user (2026-07-03): оставляем открытым, приоритет понижен до минимального (p0 → p3). Off-box бэкап остаётся в бэклоге как tech-debt-страховка.

По решению user (2026-07-03): оставляем открытым, приоритет понижен до минимального (p0 → p3). Off-box бэкап остаётся в бэклоге как tech-debt-страховка.
lekss361 added
priority/p3
and removed
priority/p0
labels 2026-07-03 07:56:56 +00:00
lekss361 added the
priority/p0
security
labels 2026-08-16 10:24:56 +00:00
Author
Owner

Проверено на проде 2026-08-20 — за 7 недель ничего не изменилось, находка полностью актуальна.

Свежие доказательства

В логе бэкапа за сегодня буквально:

[2026-08-20T00:41:33Z] Dump OK: /opt/gendesign/backups/gendesign_20260820_003001.sql.gz (1.4G)
[2026-08-20T00:41:33Z] S3 vars not set — backup stays local only

/etc/default/gendesign-backup по-прежнему не существует. В /opt/gendesign/backups12 ГБ дампов на том же диске, что и базы: gendesign 7 × 1.4 ГБ, tradein 7 × 285 МБ. Диск занят на 69 % (99 из 145 ГБ).

Уточнение к области поражения: на той же машине лежит ещё и Forgejo со всем исходным кодом. То есть одна виртуалка держит прод обоих продуктов, обе базы, все бэкапы и git-сервер — отказ диска или блокировка аккаунта уносят всё одновременно, и восстанавливаться будет не из чего и нечем.

Два дефекта сверх перечисленных в issue

  • Ни один скрипт не выгружает globals. pg_dump не включает роли и GRANT по определению — нужен отдельный pg_dumpall --globals-only. Без них восстановление в чистый кластер даёт базу без прав доступа.
  • Никто не проверяет gzip -t и трейлер -- PostgreSQL database dump complete. Тихо обрезанный архив пройдёт проверку MIN_DUMP_BYTES как здоровый.

Плюс к пункту про restore-тест: ops/restore.sh льёт в боевую базу (psql -d $DB_NAME на проде, дамп с --clean --if-exists). Для учебного восстановления он не годится — нужен одноразовый контейнер postgis/postgis, и сверять не «завелось», а количество строк ключевых таблиц против прода плюс наличие расширений PostGIS.

Куда выгружать — цены проверены 2026-08-20

Требования: локация РФ (в дампах ПДн), провайдер отличный от того, где прод, Object Lock.

Провайдер ₽/ГБ/мес Исходящий трафик Object Lock Счёт на 50 ГБ
Cloud.ru Evolution 1.13 с НДС 10 ТБ бесплатно Retention + Legal Hold ~57 ₽
Yandex Object Storage цены JS-рендер, не подтвердились 100 ГБ бесплатно ~100 ₽
VK Cloud 2.28 hotbox 1.31 ₽/ГБ WORM ~121 ₽
Selectel 2.56 1.96 ₽/ГБ + lifecycle ~150 ₽

Ловушка: ледяные классы под 7-дневную ротацию не годятся. У Yandex минимальный тарифицируемый срок хранения в ледяном — 12 месяцев, удалили дамп через неделю и платите за оставшиеся 11,8. У Selectel штрафа за раннее удаление нет (подтверждено в анонсе) — это редкое исключение.

Про ключ

Ключ лежит на той самой машине, которую бэкапит, — скомпрометировали сервер, скомпрометировали и бэкапы. Правильно: на проде ключ только на запись (Allow PutObject, явный Deny на DeleteObject, DeleteObjectVersion, PutLifecycleConfiguration), ключ на восстановление хранится отдельно, Object Lock в режиме COMPLIANCE страхует от подмены политики. Шифровать клиентски (age / gpg) до загрузки — SSE провайдера не спасает от сценария «скомпрометирован провайдер».

Смежное: #2989 (пересборка базы при переезде) — там же оценка, что реальный логический payload базы всего 285 МБ в gzip.

Проверено на проде 2026-08-20 — **за 7 недель ничего не изменилось, находка полностью актуальна.** ## Свежие доказательства В логе бэкапа за сегодня буквально: ``` [2026-08-20T00:41:33Z] Dump OK: /opt/gendesign/backups/gendesign_20260820_003001.sql.gz (1.4G) [2026-08-20T00:41:33Z] S3 vars not set — backup stays local only ``` `/etc/default/gendesign-backup` по-прежнему не существует. В `/opt/gendesign/backups` — **12 ГБ дампов** на том же диске, что и базы: gendesign 7 × 1.4 ГБ, tradein 7 × 285 МБ. Диск занят на 69 % (99 из 145 ГБ). Уточнение к области поражения: на той же машине лежит ещё и **Forgejo со всем исходным кодом**. То есть одна виртуалка держит прод обоих продуктов, обе базы, все бэкапы и git-сервер — отказ диска или блокировка аккаунта уносят всё одновременно, и восстанавливаться будет не из чего и нечем. ## Два дефекта сверх перечисленных в issue - **Ни один скрипт не выгружает globals.** `pg_dump` не включает роли и `GRANT` по определению — нужен отдельный `pg_dumpall --globals-only`. Без них восстановление в чистый кластер даёт базу без прав доступа. - **Никто не проверяет `gzip -t` и трейлер `-- PostgreSQL database dump complete`.** Тихо обрезанный архив пройдёт проверку `MIN_DUMP_BYTES` как здоровый. Плюс к пункту про restore-тест: `ops/restore.sh` льёт **в боевую базу** (`psql -d $DB_NAME` на проде, дамп с `--clean --if-exists`). Для учебного восстановления он не годится — нужен одноразовый контейнер `postgis/postgis`, и сверять не «завелось», а количество строк ключевых таблиц против прода плюс наличие расширений PostGIS. ## Куда выгружать — цены проверены 2026-08-20 Требования: локация РФ (в дампах ПДн), провайдер **отличный от того, где прод**, Object Lock. | Провайдер | ₽/ГБ/мес | Исходящий трафик | Object Lock | Счёт на 50 ГБ | |---|---|---|---|---| | Cloud.ru Evolution | 1.13 с НДС | **10 ТБ бесплатно** | ✅ Retention + Legal Hold | ~57 ₽ | | Yandex Object Storage | цены JS-рендер, не подтвердились | **100 ГБ бесплатно** | ✅ | ~100 ₽ | | VK Cloud | 2.28 hotbox | 1.31 ₽/ГБ | ✅ WORM | ~121 ₽ | | Selectel | 2.56 | 1.96 ₽/ГБ | ✅ + lifecycle | ~150 ₽ | Ловушка: **ледяные классы под 7-дневную ротацию не годятся.** У Yandex минимальный тарифицируемый срок хранения в ледяном — 12 месяцев, удалили дамп через неделю и платите за оставшиеся 11,8. У Selectel штрафа за раннее удаление нет (подтверждено в анонсе) — это редкое исключение. ## Про ключ Ключ лежит на той самой машине, которую бэкапит, — скомпрометировали сервер, скомпрометировали и бэкапы. Правильно: на проде ключ только на запись (`Allow PutObject`, явный `Deny` на `DeleteObject`, `DeleteObjectVersion`, `PutLifecycleConfiguration`), ключ на восстановление хранится отдельно, Object Lock в режиме COMPLIANCE страхует от подмены политики. Шифровать клиентски (`age` / `gpg`) до загрузки — SSE провайдера не спасает от сценария «скомпрометирован провайдер». Смежное: #2989 (пересборка базы при переезде) — там же оценка, что реальный логический payload базы всего 285 МБ в gzip.
Author
Owner

Ре-верификация на живом проде 2026-08-21 — основное закрыто, issue оставляю открытым из-за трёх хвостов

Проверял не по коду в репозитории, а по факту на машине.

Работает

Конфиг существует и с правильными правами: /etc/default/gendesign-backup, root:gendesign 640. Именно 640 с группой, а не 600 root:root как было написано в шапке скрипта — cron запускает бэкапы от gendesign, и «правильная» по документации раскладка давала Permission denied.

Обе базы уезжают за пределы машины, ежедневно. Лог прогона за сегодня:

[2026-08-21T00:43:46Z] Uploading to s3://gendsgn-backups/gendesign_globals_20260821_003001.sql.gz
[2026-08-21T00:43:53Z] S3 upload OK
[2026-08-21T00:43:53Z] Backup done. Local dumps retained: 7 data + 1 globals (KEEP=7).

[2026-08-21T01:31:03Z] Дамп ok: tradein-20260821-013001.sql.gz (307M, 321568234 байт)
[2026-08-21T01:31:03Z] Globals ok: tradein-globals-20260821-013001.sql.gz (4.0K)
[2026-08-21T01:31:22Z] Выгрузка в S3 ok
[2026-08-21T01:31:22Z] backup ok: копий хранится: 7 данных + 2 globals

Оба новых дефекта из ре-верификации 2026-08-20 закрыты:

  • pg_dumpall --globals-only — есть, файлы *_globals_*.sql.gz лежат и в бакете, и локально;
  • ops/restore.sh больше не заряженное ружьё: требует RESTORE_CONFIRM=yes, иначе отказ с явным предупреждением про DROP. Плюс появился отдельный ops/restore-drill.sh, который поднимает одноразовый контейнер на unnamed-volume и никогда не касается боевой БД — с явным предупреждением в шапке, что для репетиции нужен именно он.

Хардening на месте: MIN_DUMP_BYTES (пол 50 KiB), gzip -t, sentinel по трейлеру -- PostgreSQL database dump complete через grep -qF -- (без -- проверка целостности проваливалась всегда и скрипт удалял только что созданный дамп), AWS_CA_BUNDLE для aws-cli.

Что осталось — поэтому не закрываю

  1. Восстановление ни разу не проверено. ops/restore-drill.sh написан, но запуска не было, и в cron его нет. Пункт acceptance «restore-тест в чистую БД» формально не выполнен. Перед окном миграции это надо прогнать хотя бы один раз на реальном свежем дампе — иначе бэкап остаётся непроверенным предположением.
  2. Детекта пропущенного запуска нет. В ops/backup.sh нет sentinel-файла с меткой времени и нет проверки возраста. Если cron перестанет отрабатывать, никто не узнает — ровно тот класс тихого отказа, который уже дал четыре штуки на этом же пути.
  3. Третий сервисный пользователь S3 не заведён. Нужен ключ с PutObject только на arn:aws:s3:::gendsgn-backups/forgejo/* — иначе Beget нечем писать бэкап кода, а текущий writer-ключ выписан прод-хосту. Без этого весь код Forgejo остаётся в одном экземпляре на одной машине.

Отдельно, к сведению

Отзыв ключа у Selectel не мгновенный — удалённый через панель ключ продолжал аутентифицироваться ещё 8 минут. Отличать живой от отозванного надо ответом на GetBucketPolicy: InvalidAccessKeyId — мёртв, AccessDenied — жив, просто не пускает.

Refs #2989

## Ре-верификация на живом проде 2026-08-21 — основное закрыто, issue оставляю открытым из-за трёх хвостов Проверял не по коду в репозитории, а по факту на машине. ### Работает **Конфиг существует и с правильными правами:** `/etc/default/gendesign-backup`, `root:gendesign 640`. Именно `640` с группой, а не `600 root:root` как было написано в шапке скрипта — cron запускает бэкапы от `gendesign`, и «правильная» по документации раскладка давала `Permission denied`. **Обе базы уезжают за пределы машины, ежедневно.** Лог прогона за сегодня: ``` [2026-08-21T00:43:46Z] Uploading to s3://gendsgn-backups/gendesign_globals_20260821_003001.sql.gz [2026-08-21T00:43:53Z] S3 upload OK [2026-08-21T00:43:53Z] Backup done. Local dumps retained: 7 data + 1 globals (KEEP=7). [2026-08-21T01:31:03Z] Дамп ok: tradein-20260821-013001.sql.gz (307M, 321568234 байт) [2026-08-21T01:31:03Z] Globals ok: tradein-globals-20260821-013001.sql.gz (4.0K) [2026-08-21T01:31:22Z] Выгрузка в S3 ok [2026-08-21T01:31:22Z] backup ok: копий хранится: 7 данных + 2 globals ``` **Оба новых дефекта из ре-верификации 2026-08-20 закрыты:** - `pg_dumpall --globals-only` — есть, файлы `*_globals_*.sql.gz` лежат и в бакете, и локально; - `ops/restore.sh` больше не заряженное ружьё: требует `RESTORE_CONFIRM=yes`, иначе отказ с явным предупреждением про `DROP`. Плюс появился отдельный `ops/restore-drill.sh`, который поднимает одноразовый контейнер на unnamed-volume и **никогда не касается боевой БД** — с явным предупреждением в шапке, что для репетиции нужен именно он. **Хардening на месте:** `MIN_DUMP_BYTES` (пол 50 KiB), `gzip -t`, sentinel по трейлеру `-- PostgreSQL database dump complete` через `grep -qF --` (без `--` проверка целостности проваливалась всегда и скрипт удалял только что созданный дамп), `AWS_CA_BUNDLE` для aws-cli. ### Что осталось — поэтому не закрываю 1. **Восстановление ни разу не проверено.** `ops/restore-drill.sh` написан, но запуска не было, и в cron его нет. Пункт acceptance «restore-тест в чистую БД» формально не выполнен. Перед окном миграции это надо прогнать хотя бы один раз на реальном свежем дампе — иначе бэкап остаётся непроверенным предположением. 2. **Детекта пропущенного запуска нет.** В `ops/backup.sh` нет sentinel-файла с меткой времени и нет проверки возраста. Если cron перестанет отрабатывать, никто не узнает — ровно тот класс тихого отказа, который уже дал четыре штуки на этом же пути. 3. **Третий сервисный пользователь S3 не заведён.** Нужен ключ с `PutObject` только на `arn:aws:s3:::gendsgn-backups/forgejo/*` — иначе Beget нечем писать бэкап кода, а текущий writer-ключ выписан прод-хосту. Без этого весь код Forgejo остаётся в одном экземпляре на одной машине. ### Отдельно, к сведению Отзыв ключа у Selectel **не мгновенный** — удалённый через панель ключ продолжал аутентифицироваться ещё 8 минут. Отличать живой от отозванного надо ответом на `GetBucketPolicy`: `InvalidAccessKeyId` — мёртв, `AccessDenied` — жив, просто не пускает. Refs #2989
Author
Owner

Прошёл по всем четырём пунктам приёмки. Три из них уже закрыты — hardening и off-box выгрузка сделаны и работают, что видно по логам прода, а не по коду. Открытым был только restore-тест; прогнал его, и он попутно ответил на чужой открытый вопрос.

Что уже работает (проверено на проде, не по диффу)

tradein-mvp/deploy/backup-tradein-db.sh на forgejo/main и задеплоенный на VM — побайтово один и тот же файл (md5 75486a86… совпал). В нём уже есть:

  • MIN_DUMP_BYTES=10240 — floor вместо одной проверки на непустоту
  • stderr не глотается (2>/dev/null убран — прямо помечено «до #2203»)
  • проверка целостности: gzip -t плюс проверка трейлера дампа — то есть ловится и «gz валидный, но дамп обрезан»
  • SENTINEL_FILE=.last_success + ежечасный ops/check-backup-staleness.sh в cron с порогом 26 ч — на все три бэкапа (main, tradein, forgejo)

Off-box выгрузка тоже живая. Из лога прогона 23.08:

[01:31:06Z] Дамп ok: tradein-20260823-013001.sql.gz (314M, 329053425 байт)
[01:31:06Z] Заливаю в s3://gendsgn-backups/tradein-20260823-013001.sql.gz
upload: ... to s3://gendsgn-backups/tradein-20260823-013001.sql.gz
[01:31:25Z] Выгрузка в S3 ok
[01:31:25Z] backup ok: ... копий хранится: 7 данных + 4 globals

314 МБ уехали за 19 секунд. Плюс отдельно выгружаются globals — то, чего в исходной постановке не было.

Замечание для тех, кто будет сверять: я сам сначала прочитал этот скрипт из локального чекаута и решил, что hardening'а нет. Локальная ветка отставала. Смотреть надо git show forgejo/main:<путь>, а лучше сразу md5 против задеплоенного файла.

Restore-тест — прогнал, результат ниже

Восстанавливал реальный последний бэкап (tradein-20260823-013001.sql.gz, 314 МБ) в чистую БД tradein_backup_test на Poincare — потоком, без промежуточного файла, чтобы не занимать диск Beget:

ssh gendesign "cat <дамп>" | ssh selectel "gunzip -c | docker exec -i pg-rehearsal psql -U tradein -d tradein_backup_test"

Poincare выбран намеренно: 798 ГБ свободно и нулевое влияние на прод (на Beget оставалось 45 ГБ и работали скрапперы).

Время: 59 секунд. Ошибок — 10, все одна и та же: role "gendesign_reader" does not exist — это GRANT на роль, которой нет в целевом кластере. На данные не влияет, но при восстановлении «в чистое» роль надо создавать заранее, иначе права не приедут. Стоит добавить в раннбук.

Сверка row-counts

Таблица Восстановлено Прод сейчас Расхождение
scrape_runs (≤ момента дампа) 4 652 4 652 0 — точно
listing_source_snapshots (≤ даты дампа) 4 877 724 4 877 724 0 — точно
listings 106 712 107 251 +539
listing_sources 103 435 103 975 +540
houses 9 839 9 917 +78
offer_price_history 60 319 60 828 +509

Две таблицы, где есть колонка времени и можно отсечь прод ровно по моменту дампа, сошлись в точности. Остальные расходятся только на прирост за сутки с момента дампа — и расходятся пропорционально, без выбросов.

Схема

Восстановлено Прод
таблиц в public 76 76
индексов 252 252

scrape_proxies — 4 строки, то есть таблицы с зашифрованными полями тоже приехали.

Побочно: восстановление объяснило раздутие базы

Восстановленная БД занимает 2 272 МБ. Прод — 21 ГБ. Те же самые данные, тот же набор индексов.

Это ~9-кратное раздутие на проде, и оно же объясняет, почему restore занял 59 секунд, а не полчаса. Аудит скрапперов (24.08) отдельно отмечал «TOAST-раздутие listings 19 ГБ при ~3.3 ГБ heap+индексов, причина не установлена» — вот независимое подтверждение: причина не в объёме данных.

Практический вывод для переезда: перенос через dump/restore сам по себе освободит ~19 ГБ и ускорит всё, что упирается в размер. Отдельный VACUUM FULL до окна не нужен — окно его и сделает.

Статус приёмки

  • Ежедневный off-box аплоад дампа с ретеншеном — работает, S3 gendsgn-backups, 7 дампов + 4 globals
  • Реальный restore-тест — прогнан, row-counts сверены, схема полная
  • Hardening: MIN_DUMP_BYTES, stderr не глотается, алерт на непрошедший cron — всё есть
  • Off-box копия .env.runtimeостаётся за владельцем (работа с секретами)

Тестовая база удалена сразу после сверки, в pg-rehearsal остались только штатные tradein_reh / gendesign_reh.

Что стоит добавить в раннбук #3057

  1. Перед восстановлением создать роль gendesign_reader (иначе 10 GRANT молча не применятся).
  2. Восстановление из бэкапа на новом хосте занимает ~1 минуту, а не десятки — при планировании окна это запас, а не риск.
Прошёл по всем четырём пунктам приёмки. **Три из них уже закрыты** — hardening и off-box выгрузка сделаны и работают, что видно по логам прода, а не по коду. Открытым был только restore-тест; прогнал его, и он попутно ответил на чужой открытый вопрос. ## Что уже работает (проверено на проде, не по диффу) `tradein-mvp/deploy/backup-tradein-db.sh` на `forgejo/main` и задеплоенный на VM — **побайтово один и тот же файл** (md5 `75486a86…` совпал). В нём уже есть: - `MIN_DUMP_BYTES=10240` — floor вместо одной проверки на непустоту - stderr **не глотается** (`2>/dev/null` убран — прямо помечено «до #2203») - проверка целостности: `gzip -t` **плюс** проверка трейлера дампа — то есть ловится и «gz валидный, но дамп обрезан» - `SENTINEL_FILE=.last_success` + ежечасный `ops/check-backup-staleness.sh` в cron с порогом 26 ч — на все три бэкапа (main, tradein, forgejo) **Off-box выгрузка тоже живая.** Из лога прогона 23.08: ``` [01:31:06Z] Дамп ok: tradein-20260823-013001.sql.gz (314M, 329053425 байт) [01:31:06Z] Заливаю в s3://gendsgn-backups/tradein-20260823-013001.sql.gz upload: ... to s3://gendsgn-backups/tradein-20260823-013001.sql.gz [01:31:25Z] Выгрузка в S3 ok [01:31:25Z] backup ok: ... копий хранится: 7 данных + 4 globals ``` 314 МБ уехали за 19 секунд. Плюс отдельно выгружаются `globals` — то, чего в исходной постановке не было. > Замечание для тех, кто будет сверять: я сам сначала прочитал этот скрипт **из локального чекаута** и решил, что hardening'а нет. Локальная ветка отставала. Смотреть надо `git show forgejo/main:<путь>`, а лучше сразу md5 против задеплоенного файла. ## Restore-тест — прогнал, результат ниже Восстанавливал **реальный последний бэкап** (`tradein-20260823-013001.sql.gz`, 314 МБ) в **чистую БД** `tradein_backup_test` на Poincare — потоком, без промежуточного файла, чтобы не занимать диск Beget: ``` ssh gendesign "cat <дамп>" | ssh selectel "gunzip -c | docker exec -i pg-rehearsal psql -U tradein -d tradein_backup_test" ``` Poincare выбран намеренно: 798 ГБ свободно и нулевое влияние на прод (на Beget оставалось 45 ГБ и работали скрапперы). **Время: 59 секунд. Ошибок — 10, все одна и та же:** `role "gendesign_reader" does not exist` — это `GRANT` на роль, которой нет в целевом кластере. На данные не влияет, но при восстановлении «в чистое» роль надо создавать заранее, иначе права не приедут. Стоит добавить в раннбук. ### Сверка row-counts | Таблица | Восстановлено | Прод сейчас | Расхождение | |---|---|---|---| | `scrape_runs` (≤ момента дампа) | **4 652** | **4 652** | **0 — точно** | | `listing_source_snapshots` (≤ даты дампа) | **4 877 724** | **4 877 724** | **0 — точно** | | `listings` | 106 712 | 107 251 | +539 | | `listing_sources` | 103 435 | 103 975 | +540 | | `houses` | 9 839 | 9 917 | +78 | | `offer_price_history` | 60 319 | 60 828 | +509 | Две таблицы, где есть колонка времени и можно отсечь прод ровно по моменту дампа, сошлись **в точности**. Остальные расходятся только на прирост за сутки с момента дампа — и расходятся пропорционально, без выбросов. ### Схема | | Восстановлено | Прод | |---|---|---| | таблиц в `public` | 76 | 76 | | индексов | **252** | **252** | `scrape_proxies` — 4 строки, то есть таблицы с зашифрованными полями тоже приехали. ## Побочно: восстановление объяснило раздутие базы Восстановленная БД занимает **2 272 МБ**. Прод — **21 ГБ**. Те же самые данные, тот же набор индексов. Это ~**9-кратное раздутие** на проде, и оно же объясняет, почему restore занял 59 секунд, а не полчаса. Аудит скрапперов (24.08) отдельно отмечал «TOAST-раздутие `listings` 19 ГБ при ~3.3 ГБ heap+индексов, причина не установлена» — вот независимое подтверждение: причина не в объёме данных. Практический вывод для переезда: **перенос через dump/restore сам по себе освободит ~19 ГБ** и ускорит всё, что упирается в размер. Отдельный `VACUUM FULL` до окна не нужен — окно его и сделает. ## Статус приёмки - [x] Ежедневный off-box аплоад дампа с ретеншеном — **работает**, S3 `gendsgn-backups`, 7 дампов + 4 globals - [x] **Реальный restore-тест** — прогнан, row-counts сверены, схема полная - [x] Hardening: `MIN_DUMP_BYTES`, stderr не глотается, алерт на непрошедший cron — **всё есть** - [ ] Off-box копия `.env.runtime` — **остаётся за владельцем** (работа с секретами) Тестовая база удалена сразу после сверки, в `pg-rehearsal` остались только штатные `tradein_reh` / `gendesign_reh`. ### Что стоит добавить в раннбук #3057 1. Перед восстановлением создать роль `gendesign_reader` (иначе 10 `GRANT` молча не применятся). 2. Восстановление из бэкапа на новом хосте занимает **~1 минуту**, а не десятки — при планировании окна это запас, а не риск.
Author
Owner

Поправка к моему же предыдущему комментарию. Я записал пункт «алерт если cron не отработал» как закрытый — это неверно. Механизм есть, но доставки нет.

Всплыло случайно: после мержа #3070 я прогнал notify() на проде, чтобы убедиться, что поведение не изменилось, и получил

NOTIFY (telegram disabled — set TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID in /etc/default/gendesign-backup)

Сначала решил, что это артефакт моего теста (запускал от gendesign, а файл -rw-r----- root:gendesign). Проверил — файл читается, переменных в нём просто нет.

Замер по всем трём env-файлам

Файл TELEGRAM_BOT_TOKEN / CHAT_ID
/etc/default/gendesign-backup 0
/opt/gendesign/secrets/forgejo-backup.env 0
/etc/default/gendesign-uptime файла нет

Ни в одном. То есть notify() во всех трёх бэкап-путях — no-op: он пишет строку в лог и возвращает 0.

Подтверждение из логов: 5 строк NOTIFY (telegram disabled ...) за месяц. Ни одной строки об успешной отправке или о telegram sendMessage failed — то есть curl к Telegram не вызывался ни разу.

Что это значит

Цепочка выглядела рабочей на каждом звене по отдельности:

  • ежечасный check-backup-staleness.shработает, логи есть:
    [2026-08-23T23:00:01Z] OK: forgejo backup sentinel is 5h old (< 26h threshold)
    [2026-08-23T23:00:01Z] OK: tradein backup sentinel is 21h old (< 26h threshold)
    [2026-08-23T23:00:01Z] OK: gendesign main backup sentinel is 22h old (< 26h threshold)
    
  • sentinel-файлы пишутся, пороги считаются, вердикт выносится верно;
  • а дальше вердикт никуда не уходит.

Сейчас чекер печатает OK, поэтому разницы не видно. Разница появится ровно в тот момент, когда бэкап не отработает: чекер честно определит проблему, напишет её в файл в /tmp — и на этом всё.

Это тот же класс, что и остальное найденное за эту ночь: механизм есть, счётчик считает, вывода из счётчика никто не делает.

Отношение к #3070

PR #3070 (смержен, на проде проверен) эту дыру не закрывает, но делает громкой. Раньше при ненастроенном Telegram в лог шла нейтральная строка NOTIFY (telegram disabled ...), которая читается как «уведомления выключены, так и задумано». Теперь следом идёт:

АЛЕРТ НЕ ДОСТАВЛЕН (telegram недоступен, почта не настроена): <текст>

Проверено на проде после деплоя — обе строки присутствуют, rc=0, поведение остальных путей не изменилось.

Что нужно сделать (владелец, работа с секретами)

Один из двух вариантов, любой закрывает пункт:

  1. Дописать TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID в /etc/default/gendesign-backup — вернуть исходную задумку.
  2. Дописать пять ALERT_SMTP_* / ALERT_MAIL_* переменных туда же — включить запасной канал из #3070.

Лучше оба, и вот почему это не перестраховка: после переезда Telegram с нового хоста висит на единственном живом адресе 149.154.167.220 (#3059), а канал оповещения о его отказе шёл бы через него же.

Проверить после настройки просто — принудительно состарить sentinel или временно опустить порог, и убедиться, что уведомление дошло.

Исправленный статус приёмки

  • Ежедневный off-box аплоад дампа с ретеншеном
  • Реальный restore-тест
  • Hardening: MIN_DUMP_BYTES , stderr не глотается , алерт если cron не отработал — механизм есть, доставки нет
  • Off-box копия .env.runtime
Поправка к моему же предыдущему комментарию. Я записал пункт «алерт если cron не отработал» как закрытый — **это неверно**. Механизм есть, но доставки нет. Всплыло случайно: после мержа #3070 я прогнал `notify()` на проде, чтобы убедиться, что поведение не изменилось, и получил ``` NOTIFY (telegram disabled — set TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID in /etc/default/gendesign-backup) ``` Сначала решил, что это артефакт моего теста (запускал от `gendesign`, а файл `-rw-r----- root:gendesign`). Проверил — **файл читается, переменных в нём просто нет**. ## Замер по всем трём env-файлам | Файл | `TELEGRAM_BOT_TOKEN` / `CHAT_ID` | |---|---| | `/etc/default/gendesign-backup` | **0** | | `/opt/gendesign/secrets/forgejo-backup.env` | **0** | | `/etc/default/gendesign-uptime` | файла нет | Ни в одном. То есть `notify()` во всех трёх бэкап-путях — **no-op**: он пишет строку в лог и возвращает 0. Подтверждение из логов: **5 строк** `NOTIFY (telegram disabled ...)` за месяц. Ни одной строки об успешной отправке или о `telegram sendMessage failed` — то есть curl к Telegram **не вызывался ни разу**. ## Что это значит Цепочка выглядела рабочей на каждом звене по отдельности: - ежечасный `check-backup-staleness.sh` — **работает**, логи есть: ``` [2026-08-23T23:00:01Z] OK: forgejo backup sentinel is 5h old (< 26h threshold) [2026-08-23T23:00:01Z] OK: tradein backup sentinel is 21h old (< 26h threshold) [2026-08-23T23:00:01Z] OK: gendesign main backup sentinel is 22h old (< 26h threshold) ``` - sentinel-файлы пишутся, пороги считаются, вердикт выносится верно; - а **дальше вердикт никуда не уходит**. Сейчас чекер печатает `OK`, поэтому разницы не видно. Разница появится ровно в тот момент, когда бэкап не отработает: чекер честно определит проблему, напишет её в файл в `/tmp` — и на этом всё. Это тот же класс, что и остальное найденное за эту ночь: **механизм есть, счётчик считает, вывода из счётчика никто не делает**. ## Отношение к #3070 PR #3070 (смержен, на проде проверен) эту дыру **не закрывает, но делает громкой**. Раньше при ненастроенном Telegram в лог шла нейтральная строка `NOTIFY (telegram disabled ...)`, которая читается как «уведомления выключены, так и задумано». Теперь следом идёт: ``` АЛЕРТ НЕ ДОСТАВЛЕН (telegram недоступен, почта не настроена): <текст> ``` Проверено на проде после деплоя — обе строки присутствуют, `rc=0`, поведение остальных путей не изменилось. ## Что нужно сделать (владелец, работа с секретами) Один из двух вариантов, любой закрывает пункт: 1. Дописать `TELEGRAM_BOT_TOKEN` / `TELEGRAM_CHAT_ID` в `/etc/default/gendesign-backup` — вернуть исходную задумку. 2. Дописать пять `ALERT_SMTP_*` / `ALERT_MAIL_*` переменных туда же — включить запасной канал из #3070. **Лучше оба**, и вот почему это не перестраховка: после переезда Telegram с нового хоста висит на единственном живом адресе `149.154.167.220` (#3059), а канал оповещения о его отказе шёл бы через него же. Проверить после настройки просто — принудительно состарить sentinel или временно опустить порог, и убедиться, что уведомление дошло. ## Исправленный статус приёмки - [x] Ежедневный off-box аплоад дампа с ретеншеном - [x] Реальный restore-тест - [ ] Hardening: `MIN_DUMP_BYTES` ✅, stderr не глотается ✅, **алерт если cron не отработал — механизм есть, доставки нет** ❌ - [ ] Off-box копия `.env.runtime`
Author
Owner

Пункт «алерт, если cron не отработал» закрыт — доставка включена и проверена на обоих хостах

23.08 я записал этот пункт как закрытый, потом сам же поправился: механизм есть, доставки нет. notify() во всех трёх путях был no-op — переменные TELEGRAM_* не заданы ни в одном env-файле, за месяц ни одного вызова curl к Telegram.

Сегодня владелец дал координаты канала, и разрыв закрыт.

Куда шлём

Бот MERAsupport_bot — тот же, что уже пересылает сообщения, отдельный заводить не потребовалось. Форум «МЕРА», chat_id = -1004443088679, тема «алерты» — 158.

Что включено

Хост Пути, которые теперь докричатся
Poincare бэкап gendesign, бэкап tradein, оба сторожа устаревания
Beget бэкап Forgejo, бэкап CouchDB, оба сторожа устаревания

Проверено живым вызовом notify(), а не чтением конфига — сообщения из обоих хостов доставлены.

Показательная деталь

Отправка с Poincare прошла с третьей попытки:

[2026-08-27T09:46:35Z] telegram sendMessage: доставлено с попытки 3

Это ровно та флапающая маршрутизация до Telegram, которую замерили в #3059 (с нового хоста отвечает один ДЦ из четырёх). Одиночный curl, каким notify() был до #3095, этот алерт бы потерял — и потерял бы молча, потому что запасной канал (почта) сжигался бы на первом же неудавшемся SYN. Ретрай, который тогда выглядел перестраховкой, окупился на первом же боевом сообщении.

Как устроено на Beget, чтобы потом не удивляться

/etc/default/gendesign-backup там принадлежит root:gendesign 640, а passwordless sudo на машине нет — дописать в него я не могу. Поэтому переменные лежат в /opt/gendesign/secrets/backup-notify.env (600, владелец gendesign), а cron-строки сторожей и бэкапа CouchDB указывают на него через BACKUP_ENV_FILE= — механизм, который notify() уже поддерживает (lib-backup.sh:35). Бэкап Forgejo получил те же переменные в свой forgejo-backup.env.

Крон сохранён перед правкой: /opt/gendesign/logs/crontab.bak-2026-08-27.

Известное ограничение

notify() не умеет message_thread_id, поэтому сообщения бэкапов падают в общую тему форума, а не в «алерты». Функционально не мешает, но шумит не там. Поправлю отдельным PR — параметр опциональный, при незаданном TELEGRAM_TOPIC_ID поведение прежнее бит в бит.

Состояние приёмки

  • Ежедневный off-box аплоад с ретеншеном
  • Реальный restore-тест (плюс ежемесячная репетиция в кроне)
  • Hardening: MIN_DUMP_BYTES, stderr не глотается, целостность архива — и теперь алерт реально доходит
  • Off-box копия окружения — единственный незакрытый пункт. По ops/backup-forgejo.sh:303 он намеренно оставлен ручным действием владельца: файл с ключами шифрования не должен уезжать теми же автоматическими путями, что и дампы. Без него дампы восстановятся, но pgp_sym_encrypt-поля (CIAN-cookie) расшифровать будет нечем.

Issue оставляю открытым ровно из-за последнего пункта.

## Пункт «алерт, если cron не отработал» закрыт — доставка включена и проверена на обоих хостах 23.08 я записал этот пункт как закрытый, потом сам же поправился: механизм есть, доставки нет. `notify()` во всех трёх путях был no-op — переменные `TELEGRAM_*` не заданы ни в одном env-файле, за месяц ни одного вызова `curl` к Telegram. Сегодня владелец дал координаты канала, и разрыв закрыт. ### Куда шлём Бот `MERAsupport_bot` — тот же, что уже пересылает сообщения, отдельный заводить не потребовалось. Форум «МЕРА», `chat_id = -1004443088679`, тема «алерты» — 158. ### Что включено | Хост | Пути, которые теперь докричатся | |---|---| | **Poincare** | бэкап `gendesign`, бэкап `tradein`, оба сторожа устаревания | | **Beget** | бэкап Forgejo, бэкап CouchDB, оба сторожа устаревания | **Проверено живым вызовом `notify()`, а не чтением конфига** — сообщения из обоих хостов доставлены. ### Показательная деталь Отправка с Poincare прошла **с третьей попытки**: ``` [2026-08-27T09:46:35Z] telegram sendMessage: доставлено с попытки 3 ``` Это ровно та флапающая маршрутизация до Telegram, которую замерили в #3059 (с нового хоста отвечает один ДЦ из четырёх). **Одиночный `curl`, каким `notify()` был до #3095, этот алерт бы потерял** — и потерял бы молча, потому что запасной канал (почта) сжигался бы на первом же неудавшемся SYN. Ретрай, который тогда выглядел перестраховкой, окупился на первом же боевом сообщении. ### Как устроено на Beget, чтобы потом не удивляться `/etc/default/gendesign-backup` там принадлежит `root:gendesign 640`, а passwordless sudo на машине нет — дописать в него я не могу. Поэтому переменные лежат в `/opt/gendesign/secrets/backup-notify.env` (600, владелец `gendesign`), а cron-строки сторожей и бэкапа CouchDB указывают на него через `BACKUP_ENV_FILE=` — механизм, который `notify()` уже поддерживает (`lib-backup.sh:35`). Бэкап Forgejo получил те же переменные в свой `forgejo-backup.env`. Крон сохранён перед правкой: `/opt/gendesign/logs/crontab.bak-2026-08-27`. ### Известное ограничение `notify()` не умеет `message_thread_id`, поэтому сообщения бэкапов падают в общую тему форума, а не в «алерты». Функционально не мешает, но шумит не там. Поправлю отдельным PR — параметр опциональный, при незаданном `TELEGRAM_TOPIC_ID` поведение прежнее бит в бит. ### Состояние приёмки - [x] Ежедневный off-box аплоад с ретеншеном - [x] Реальный restore-тест (плюс ежемесячная репетиция в кроне) - [x] Hardening: `MIN_DUMP_BYTES`, stderr не глотается, целостность архива — **и теперь алерт реально доходит** - [ ] **Off-box копия окружения** — единственный незакрытый пункт. По `ops/backup-forgejo.sh:303` он намеренно оставлен ручным действием владельца: файл с ключами шифрования не должен уезжать теми же автоматическими путями, что и дампы. Без него дампы восстановятся, но `pgp_sym_encrypt`-поля (CIAN-cookie) расшифровать будет нечем. Issue оставляю открытым ровно из-за последнего пункта.
Author
Owner

Пункт «реальный restore-тест» закрыт — теперь автоматически, а не разовым прогоном

Проверял заново, а не полагался на прогон 23.08: с тех пор сменился хост, был десятичасовой простой и падали выгрузки в S3. Дампы с тех пор снимались на другой машине, и «оно же работало» перестало быть аргументом.

Дыра, которой не видел ни один из прошлых заходов

Дрель стоит в cron с #3085, но её строка берёт только gendesign_*. База tradein — клиентские оценки, листинги, дома, сделки — автоматически на восстановимость не проверялась ни разу. Дампы снимались, уезжали в S3, и никто не знал, разворачиваются ли они.

Сам ops/restore-drill.sh править не пришлось: он с самого начала умеет оба проекта (находит tradein-globals-<ts>.sql.gz, подставляет свой список таблиц). Не хватало вызова — PR #3141 добавляет одну строку в ops/crontab-poincare.cron, еженедельно по понедельникам.

Прогон на боевом дампе 27.08

Гонял ровно тот скрипт, что пойдёт по расписанию (оповещение заглушено через BACKUP_ENV_FILE, чтобы не слать в канал ложную тревогу):

tradein-20260827-111102.sql.gz, 343 МБ → 58 c
роли применены, GRANT-ы прошли, материализованные представления обновлены
PostGIS 3.4.3, таблиц в public: 76

Сверка восстановленной базы с боевой:

таблица восстановлено боевая расхождение
houses 10400 10400
cad_buildings_local 47111 47111
listings 109978 109978
deals 108623 108623
listing_sources 106704
osm_poi_ekb_local 4847 4847
trade_in_estimates 1121 1122 +1 после дампа
user_events 12700 12819 +119 после дампа

Расходятся ровно две таблицы, и ровно те, что пополняются в реальном времени — на строки, созданные после снятия дампа (11:11, сверка в 11:49). Это и есть признак верного дампа: совпадение «в ноль» по неподвижным таблицам и движение вперёд только у живых.

Зашифрованные поля на месте (2 колонки) — то есть содержимое дампа полное, а не усечённое.

Побочно: одна ошибка в первом заходе была моя, не бэкапа

Первый прогон дал 10 ошибок role "gendesign_reader" does not exist. Проверил — роль в дампе ролей есть (2 CREATE ROLE, 4 упоминания), а ошибки возникли потому, что я в своём черновом скрипте пропустил накат globals. Отмечаю, чтобы это не осело в issue как дефект бэкапа: дамп ролей полон.

Остаётся открытым только четвёртый пункт

  • Ежедневный off-box аплоад с ретеншеном
  • Реальный restore-тест — теперь еженедельный, с оповещением при провале
  • Hardening (MIN_DUMP_BYTES, stderr не глотается, алерт о непрошедшем cron — доставка включена 27.08)
  • Off-box копия .env.runtime

Последний пункт — за владельцем: нужно решить, куда её класть. Сам файл я трогать не буду, а без него pgp_sym_encrypt-поля в дампах не расшифровать, то есть восстановление будет неполным именно в той части, ради которой сложность и заводилась.

## Пункт «реальный restore-тест» закрыт — теперь автоматически, а не разовым прогоном Проверял заново, а не полагался на прогон 23.08: с тех пор сменился хост, был десятичасовой простой и падали выгрузки в S3. Дампы с тех пор снимались на другой машине, и «оно же работало» перестало быть аргументом. ### Дыра, которой не видел ни один из прошлых заходов Дрель стоит в cron с #3085, но её строка берёт **только** `gendesign_*`. База **tradein** — клиентские оценки, листинги, дома, сделки — автоматически на восстановимость не проверялась ни разу. Дампы снимались, уезжали в S3, и никто не знал, разворачиваются ли они. Сам `ops/restore-drill.sh` править не пришлось: он с самого начала умеет оба проекта (находит `tradein-globals-<ts>.sql.gz`, подставляет свой список таблиц). Не хватало вызова — PR #3141 добавляет одну строку в `ops/crontab-poincare.cron`, еженедельно по понедельникам. ### Прогон на боевом дампе 27.08 Гонял ровно тот скрипт, что пойдёт по расписанию (оповещение заглушено через `BACKUP_ENV_FILE`, чтобы не слать в канал ложную тревогу): ``` tradein-20260827-111102.sql.gz, 343 МБ → 58 c роли применены, GRANT-ы прошли, материализованные представления обновлены PostGIS 3.4.3, таблиц в public: 76 ``` Сверка восстановленной базы с боевой: | таблица | восстановлено | боевая | расхождение | |---|---:|---:|---| | houses | 10400 | 10400 | — | | cad_buildings_local | 47111 | 47111 | — | | listings | 109978 | 109978 | — | | deals | 108623 | 108623 | — | | listing_sources | 106704 | — | — | | osm_poi_ekb_local | 4847 | 4847 | — | | trade_in_estimates | 1121 | 1122 | +1 после дампа | | user_events | 12700 | 12819 | +119 после дампа | Расходятся ровно две таблицы, и ровно те, что пополняются в реальном времени — на строки, созданные **после** снятия дампа (11:11, сверка в 11:49). Это и есть признак верного дампа: совпадение «в ноль» по неподвижным таблицам и движение вперёд только у живых. Зашифрованные поля на месте (2 колонки) — то есть содержимое дампа полное, а не усечённое. ### Побочно: одна ошибка в первом заходе была моя, не бэкапа Первый прогон дал 10 ошибок `role "gendesign_reader" does not exist`. Проверил — роль в дампе ролей есть (2 `CREATE ROLE`, 4 упоминания), а ошибки возникли потому, что я в своём черновом скрипте пропустил накат globals. Отмечаю, чтобы это не осело в issue как дефект бэкапа: дамп ролей полон. ### Остаётся открытым только четвёртый пункт - [x] Ежедневный off-box аплоад с ретеншеном - [x] **Реальный restore-тест** — теперь еженедельный, с оповещением при провале - [x] Hardening (`MIN_DUMP_BYTES`, stderr не глотается, алерт о непрошедшем cron — доставка включена 27.08) - [ ] Off-box копия `.env.runtime` Последний пункт — за владельцем: нужно решить, **куда** её класть. Сам файл я трогать не буду, а без него `pgp_sym_encrypt`-поля в дампах не расшифровать, то есть восстановление будет неполным именно в той части, ради которой сложность и заводилась.
Author
Owner

Четвёртый пункт закрыт на живом хосте — и по дороге нашлось, что два cron-задания из main вообще не запускались

PR #3146 смержен в 14:00, скрипт доехал на Poincare (md5 совпал с main). Но «лежит в репозитории» и «работает на машине» — разные вещи, и здесь они разошлись.

Расхождение живого crontab с эталоном

Активный crontab -u gendesign был от 25.08 и отставал от ops/crontab-poincare.cron ровно на две строки:

0 3 * * 1   restore-drill.sh для tradein      ← PR #3141, смержен
10 4 * * *  backup-env-offbox.sh              ← PR #3146, смержен

Первая — та самая еженедельная дрель tradein, которую я в комментарии от 12:01 записал как «теперь еженедельная, с оповещением при провале». Она не запускалась ни разу: строка была в эталонном файле в репозитории, в живой crontab её никто не устанавливал. Деплой ops/*.cron намеренно не исполняет (это эталоны, ставятся вручную), а я тогда проверил файл, а не crontab -l.

Это ровно тот же класс, что и «notify() — механизм есть, доставки нет» тремя комментариями выше. Проверять надо конечное звено, а не то, что перед ним.

Установлено из эталона, расхождений больше нет: 11 активных строк, diff с ops/crontab-poincare.cron пуст. Прежний crontab сохранён в /opt/gendesign/logs/crontab.bak-20260827-142001.

Парольная фраза заведена

ENV_OFFBOX_PASSPHRASE дописан в /etc/default/gendesign-backup, права файла не изменились (root:gendesign 640), пользователь gendesign фразу читает — проверено запуском от него, а не чтением конфига. Сама фраза лежит в волте (meta/00_credentials.md), то есть на другой машине — иначе она погибла бы вместе с той, ради спасения от чего копия и делается.

Боевой прогон

[2026-08-27T14:21:10Z] Зашифровано и проверено расшифровкой
upload: env-runtime-Poincare-20260827_142054.gpg to s3://gendsgn-backups/env/
[2026-08-27T14:21:10Z] Выгрузка ok.  (код возврата 0)

В бакете: env/env-runtime-Poincare-20260827_142054.gpg, 901 байт, 2026-08-27 14:21:09.

Чего проверить с прода нельзя — и это правильно

Сверить выгруженный экземпляр обратной расшифровкой с самого прода невозможно: тамошний ключ write-only, ListObjectsV2 и GetObject отвечают AccessDenied. Так и задумано (#2203, разделение writer/reader), и это подтвердилось на практике впервые. Проверка целостности при этом не теряется: скрипт расшифровывает ровно те байты, что уходят в S3, ещё до выгрузки, и при провале ничего не выгружает.

В раннбук #3057, к прошлым двум пунктам

При восстановлении с машины с графическим окружением gpg --passphrase без --pinentry-mode loopback молча игнорируется: gpg 2.x лезет за фразой в GUI-пинентри, а в headless-скрипте просто отказывает. В самом backup-env-offbox.sh этого класса нет — фраза уходит дескриптором.

Известное ограничение, не дефект

Ретеншена на префиксе env/ в S3 нет — локально KEEP=14, в бакете копии накапливаются (≈900 байт/сутки, ~330 КБ в год). Удалять их автоматически и не хотелось бы: копия, снятая до правки конфига, — единственный способ прочитать дамп того же периода.

Состояние приёмки — все четыре

  • Ежедневный off-box аплоад дампа с ретеншеном
  • Реальный restore-тест — и теперь он действительно стоит в живом cron, обе базы
  • Hardening: MIN_DUMP_BYTES, stderr не глотается, алерт о непрошедшем cron доставляется
  • Off-box копия рантайм-конфига — шифротекст в S3, фраза в волте, прогон зелёный

Закрываю.

## Четвёртый пункт закрыт на живом хосте — и по дороге нашлось, что два cron-задания из main вообще не запускались PR #3146 смержен в 14:00, скрипт доехал на Poincare (md5 совпал с main). Но «лежит в репозитории» и «работает на машине» — разные вещи, и здесь они разошлись. ### Расхождение живого crontab с эталоном Активный `crontab -u gendesign` был от **25.08** и отставал от `ops/crontab-poincare.cron` ровно на две строки: ``` 0 3 * * 1 restore-drill.sh для tradein ← PR #3141, смержен 10 4 * * * backup-env-offbox.sh ← PR #3146, смержен ``` Первая — та самая еженедельная дрель tradein, которую я в комментарии от 12:01 записал как «теперь еженедельная, с оповещением при провале». **Она не запускалась ни разу**: строка была в эталонном файле в репозитории, в живой crontab её никто не устанавливал. Деплой `ops/*.cron` намеренно не исполняет (это эталоны, ставятся вручную), а я тогда проверил файл, а не `crontab -l`. Это ровно тот же класс, что и «`notify()` — механизм есть, доставки нет» тремя комментариями выше. Проверять надо конечное звено, а не то, что перед ним. Установлено из эталона, расхождений больше нет: **11 активных строк, diff с `ops/crontab-poincare.cron` пуст**. Прежний crontab сохранён в `/opt/gendesign/logs/crontab.bak-20260827-142001`. ### Парольная фраза заведена `ENV_OFFBOX_PASSPHRASE` дописан в `/etc/default/gendesign-backup`, права файла не изменились (`root:gendesign 640`), пользователь `gendesign` фразу читает — проверено запуском от него, а не чтением конфига. Сама фраза лежит в волте (`meta/00_credentials.md`), то есть на другой машине — иначе она погибла бы вместе с той, ради спасения от чего копия и делается. ### Боевой прогон ``` [2026-08-27T14:21:10Z] Зашифровано и проверено расшифровкой upload: env-runtime-Poincare-20260827_142054.gpg to s3://gendsgn-backups/env/ [2026-08-27T14:21:10Z] Выгрузка ok. (код возврата 0) ``` В бакете: `env/env-runtime-Poincare-20260827_142054.gpg`, 901 байт, `2026-08-27 14:21:09`. ### Чего проверить с прода нельзя — и это правильно Сверить выгруженный экземпляр обратной расшифровкой **с самого прода невозможно**: тамошний ключ write-only, `ListObjectsV2` и `GetObject` отвечают `AccessDenied`. Так и задумано (#2203, разделение writer/reader), и это подтвердилось на практике впервые. Проверка целостности при этом не теряется: скрипт расшифровывает ровно те байты, что уходят в S3, ещё до выгрузки, и при провале ничего не выгружает. ### В раннбук #3057, к прошлым двум пунктам При восстановлении с машины с графическим окружением `gpg --passphrase` без **`--pinentry-mode loopback`** молча игнорируется: gpg 2.x лезет за фразой в GUI-пинентри, а в headless-скрипте просто отказывает. В самом `backup-env-offbox.sh` этого класса нет — фраза уходит дескриптором. ### Известное ограничение, не дефект Ретеншена на префиксе `env/` в S3 нет — локально `KEEP=14`, в бакете копии накапливаются (≈900 байт/сутки, ~330 КБ в год). Удалять их автоматически и не хотелось бы: копия, снятая до правки конфига, — единственный способ прочитать дамп того же периода. ### Состояние приёмки — все четыре - [x] Ежедневный off-box аплоад дампа с ретеншеном - [x] Реальный restore-тест — и теперь он действительно стоит в живом cron, обе базы - [x] Hardening: `MIN_DUMP_BYTES`, stderr не глотается, алерт о непрошедшем cron доставляется - [x] **Off-box копия рантайм-конфига** — шифротекст в S3, фраза в волте, прогон зелёный Закрываю.
lekss361 removed the
needs-human
label 2026-08-27 14:28:31 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#2203
No description provided.