Тема
ТЗ — Резервное копирование (cms/backup)
Слой: 🔵 инфра-модуль (обязательный в managed-парке) · Зрелость доноров: ★★ · Донор: — (spatie/laravel-backup + инфра студии) Статус: ТЗ к разработке
Назначение и возможности
Резервное копирование БД и файлов по расписанию, с принудительным снятием бэкапа перед cms:upgrade. Ротация, выгрузка в S3, проверка восстановимости и алерты при сбое.
- Плановые бэкапы БД + файлов по cron-расписанию
- Принудительный бэкап перед стартом
cms:upgrade(гейт наравне сcms/health) - Ретеншн-сетка GFS (дневные/недельные/месячные копии с разным сроком хранения), а не только плоское «N последних копий»
- Выгрузка в S3-совместимое хранилище вне сервера приложения (используется
cms/storage-s3) - Шифрование дампа at-rest перед выгрузкой (клиентское шифрование, ключ — только в
.env) - Тест-рестор во временную БД по собственному расписанию для проверки восстановимости
- Алерт при сбое бэкапа или провале тест-рестора
- Ручной бэкап «по кнопке» из Filament перед рискованной операцией
Зависимости и выключение
requires: ядро · suggests: cms/storage-s3
Поведение при выключении: плановые бэкапы не выполняются, cms:upgrade пропускает гейт принудительного бэкапа с явным предупреждением в лог — риск потери данных при сбое апгрейда возрастает, но сам апгрейд не блокируется техническим сбоем модуля.
При отсутствии cms/storage-s3 (suggests не установлен) бэкапы уходят на disk: local с явным предупреждением в лог: локальный диск сервера приложения не эквивалентен внешнему хранилищу по отказоустойчивости — один и тот же физический сбой способен уничтожить и сайт, и его резервные копии. Это деградация, не блокировка: модуль обязан отработать без cms/storage-s3, но обязан сделать риск видимым (health-чек degraded, не ok).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_backup_log | id, type, disk, size_bytes, status, started_at, finished_at, error | журнал плановых и ручных бэкапов |
cms_backup_restore_tests | id, backup_log_id, status, checked_at | результаты тест-рестора |
Заметки: status в обеих таблицах — PHP Enum; FK cms_backup_restore_tests.backup_log_id — constrained() + index(); cms_backup_log — журнальная таблица, кандидат на BRIN по started_at при долгом хранении истории. Модуль работает на уровне инсталляции целиком (вся БД + файлы сайта), а не по измерениям site_id/locale/city_id — при cms/multisite с общей БД бэкап один на все сайты; раздельные бэкапы по сайту вне рамок этого ТЗ (см. «Крайние случаи»).
Матрица «что бэкапится» (обязательный пункт ТЗ, матрица v2.2):
| Данные | Входит в бэкап | Восстанавливается | Примечание |
|---|---|---|---|
| БД (все таблицы всех модулей) | ✅ (тип db/full) | сразу, штатным pg_restore/аналог | включает настройки (settings-store живёт в БД) и журналы других модулей |
| Медиафайлы | ✅ (тип files/full) | сразу, копированием на диск/в cms/storage-s3 | иммутабельны по content-hash, безопасно копируются отдельным проходом от БД |
Настройки (SettingsStore) | ✅ (часть дампа БД) | сразу вместе с БД | отдельного бэкапа не требует — таблица настроек как любая другая |
.env (секреты, конфигурация окружения) | ✅ (тип full, отдельный файл вне дампа БД) | сразу, копированием файла на сервер | обязателен по ранбуку ядра; шифруется наравне с дампом БД (encrypt_at_rest) |
Поисковый индекс (cms/search) | ❌ не входит | пересоздаётся cms:search:reindex после рестора | внешнее хранилище движка (Meilisearch/ES), не часть БД CMS — см. ТЗ поиска |
| Денормализованные агрегаты/кеш-таблицы модулей | ❌ не входит отдельно (следуют за БД) | пересоздаются командой модуля-владельца при необходимости | владелец декларирует свою восстановительную команду в собственном ранбуке §15 |
Рестор без запуска восстановительных команд из правой колонки — включая cms:postupgrade и переиндексацию поиска — не считается завершённым, см. ранбук ядра (§15 стандарта).
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
| Filament, кнопка «Создать бэкап сейчас» | type (db|files|full) | whitelist-enum в FormRequest |
API POST /api/v1/admin/backup/run | type, опционально disk (local|s3) | FormRequest, оба поля — whitelist-enum |
Cron (ScheduleRegistrar) | триггер планового бэкапа, без пользовательских данных | настройки backup.schedule_cron, backup.scheduled_type |
cms:upgrade (внутренний вызов) | запрос статуса гейта «бэкап свежий и валиден» | без пользовательского ввода — вызов публичного сервиса модуля |
cms/storage-s3 (ответ сервис-вызова) | результат выгрузки: успех/ошибка, ключ объекта | доверенный ответ модуля по suggests, не пользовательский вход |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Filament, журнал бэкапов | строки cms_backup_log + cms_backup_restore_tests | таблица UI, keyset-пагинация |
API GET /api/v1/admin/backup/log | список записей журнала | JSON {data, meta} |
API GET /api/v1/admin/backup/{id}/download | presigned URL на файл бэкапа | JSON {data: {url, expires_at}} |
cms:upgrade (гейт) | булев статус «бэкап свежий и валиден» | возврат сервиса / cms:backup:gate --json |
cms/health | статус последнего бэкапа и последнего тест-рестора | агрегат health-чека |
| Подписчики событий | BackupCompleted/BackupFailed/BackupRestoreTestFailed | payload события (EventBus) |
cms/storage-s3 / локальный диск | файл дампа БД и/или архив медиа | бинарный объект по ключу |
Whitelist-принцип: всё, что не перечислено во входной таблице (произвольные пути на диске, чужие диск-адаптеры, версии дампа, размер/тип за пределами enum), отвергается на границе FormRequest — модуль не принимает свободный текст там, где ожидается перечисление.
Настройки (группа backup)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
backup.enabled | bool | true | нет | Включить плановые бэкапы (обязателен в managed-парке) |
backup.schedule_cron | string | 0 3 * * * | нет | Расписание плановых бэкапов |
backup.scheduled_type | string | full | нет | Тип планового бэкапа (db|files|full) |
backup.retention_copies | int | 14 | нет | Общий потолок копий вне сетки ниже (защита от неограниченного роста при некорректной сетке) |
backup.retention_days | int | 90 | нет | Абсолютный потолок хранения — копии старше N дней удаляются вне зависимости от сетки |
backup.retention_daily | int | 7 | нет | Ретеншн-сетка (GFS): сколько ежедневных копий хранить |
backup.retention_weekly | int | 4 | нет | Ретеншн-сетка (GFS): сколько еженедельных копий хранить (последняя копия недели) |
backup.retention_monthly | int | 12 | нет | Ретеншн-сетка (GFS): сколько ежемесячных копий хранить (последняя копия месяца) |
backup.disk | string | s3 | нет | Диск назначения (local/s3) — s3 обязателен для соответствия принципу «вне сервера» |
backup.max_duration_minutes | int | 120 | нет | Таймаут джобы бэкапа, после которого фиксируется сбой |
backup.upgrade_gate_enabled | bool | true | нет | Принудительный бэкап перед cms:upgrade |
backup.restore_test_enabled | bool | true | нет | Kill-switch тест-рестора (матрица v2.2): выключает только рискованную операцию поднятия временной БД, не бэкап и не модуль целиком |
backup.restore_test_cron | string | 0 5 * * 0 | нет | Собственное расписание тест-рестора — реже, чем плановый бэкап (по умолчанию раз в неделю) |
backup.encrypt_at_rest | bool | true | нет | Клиентское шифрование дампа перед выгрузкой на диск/S3 |
backup.encryption_key_ref | string | — | нет | Ссылка на ключ шифрования в .env (сам ключ в настройках не хранится, §6 стандарта) |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| POST | /api/v1/admin/backup/run | admin (backup.manage) | Ручной запуск бэкапа |
| GET | /api/v1/admin/backup/log | admin (backup.view) | Журнал бэкапов и тест-рестора |
| GET | /api/v1/admin/backup/{id}/download | admin (backup.manage) | Presigned URL на скачивание файла бэкапа |
Журнал — keyset-пагинация (не OFFSET). Скачивание не проксируется через приложение (не грузит сервер дампом в десятки ГБ) — выдаётся временная подписанная ссылка на объект в хранилище.
Компоненты
Filament: страница журнала бэкапов, кнопка «Создать бэкап сейчас», ссылка «Скачать» на завершённых записях. Команды: cms:backup:run --json, cms:backup:restore-test --json, cms:backup:gate --json (используется cms:upgrade), cms:backup:clone-to-staging --json (ревизия ядра №2, п. 6) — рестор свежего бэкапа в staging-окружение с обязательными трансформациями: подмена домена, robots noindex, включение kill-switch всех модулей с внешними эффектами (почта, вебхуки, пиксели, платежи — стандарт §6); без завершённых трансформаций команда падает, а не оставляет «полуживой» staging, способный рассылать письма. Демо-контент (матрица v2.2): не применимо — у модуля нет публичных блоков/виджетов, галерея/playground его не показывают; журнал наполняется собственными плановыми бэкапами сразу после установки.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
BackupCompleted | бэкап успешно завершён | type, disk, size_bytes, duration_ms |
BackupFailed | бэкап завершился ошибкой | type, error |
BackupRestoreTestFailed | тест-рестор не прошёл | backup_log_id, error |
FilterBus и provides-контракты не используются.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/storage-s3 | сервис-вызов (по suggests) | out | Выгрузка файла бэкапа на S3-совместимое хранилище |
cms:upgrade (ядро) | прямой вызов публичного сервиса модуля | in | Запрос гейта «бэкап свежий и валиден» перед стартом апгрейда |
cms/health | сервис-вызов / health-чек ядра | out | Отдаёт статус последнего бэкапа и тест-рестора в агрегат /api/v1/system/health |
ScheduleRegistrar (ядро) | прямой вызов при boot | out | Регистрация cron-расписания планового бэкапа |
Подписчики (cms/notifications-bus, если включён) | шина событий | out | BackupFailed/BackupRestoreTestFailed → алерт админам |
| БД приложения (все таблицы всех модулей) | нет прямого канала — снимок средствами СУБД | out | Дамп читает данные не через сервисы модулей, а через штатный механизм консистентного снапшота СУБД (см. «Крайние случаи») — единственное осознанное исключение из правила «чужие таблицы только через сервисы владельца», так как задача бэкапа — снять физическую копию всей БД целиком |
Фоновая работа
Именованная очередь backup для плановых и ручных бэкапов, тест-рестора во временную БД и выгрузки на S3; расписание планового бэкапа — backup.schedule_cron, расписание тест-рестора — отдельное backup.restore_test_cron (реже, чем сам бэкап — не каждый снимок гоняется через полный рестор во временную БД, это дороже по ресурсам), оба — через ScheduleRegistrar ядра, без прямого Schedule:: в boot. Все внешние вызовы (запись в S3, восстановление во временную БД) — только из очереди.
Запуск бэкапа защищён мьютексом на тип операции (WithoutOverlapping по ключу backup:{type}): повторный запуск того же типа, пока предыдущий выполняется, не ставит вторую джобу поверх первой — либо игнорируется с понятным ответом, либо становится в очередь после завершения текущей (см. «Крайние случаи»).
Ретеншн-сетка (GFS): ротация не «N последних копий» плоским списком, а три независимых счётчика — retention_daily/retention_weekly/retention_monthly. После каждого успешного бэкапа ротация помечает копию как daily-кандидат; при смене календарной недели/месяца последняя копия периода дополнительно помечается weekly/monthly и не удаляется, пока не выбывает по своему счётчику. retention_copies/retention_days остаются абсолютным потолком поверх сетки — защита от неверно сконфигурированной сетки, не основной механизм. Ротация — идемпотентная джоба после BackupCompleted, не отдельное расписание.
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Команда |
|---|---|---|
| Плановый бэкап не запустился | статус ScheduleRegistrar, свободное место на диске/S3 | cms:backup:run --json --type=full (ручной прогон), df -h |
cms:upgrade заблокирован гейтом | свежесть последнего успешного бэкапа и тест-рестора | cms:backup:gate --json |
Тест-рестор стабильно падает (schema_mismatch) | версия схемы дампа vs миграции целевой БД | cms:backup:restore-test --json, сверка cms:map --json |
| Health-чек модуля красный, бэкапы формально успешны | наличие свежего успешного тест-рестора (см. «Крайние случаи») | cms:backup:restore-test --json |
| Диск/S3 заполнен, дампы падают | ретеншн-сетка отрабатывает штатно, retention_* не занижены | cms:backup:run --json (лог ошибки), проверка квоты S3 |
Производительность и кеш
Ожидаемые объёмы (managed-парк студии, десятки сайтов): БД проекта — от единиц МБ до нескольких ГБ, медиатека — от сотен МБ до десятков ГБ на активный проект. При retention_copies=14 и одном плановом бэкапе в сутки cms_backup_log растёт на ~14 строк на сайт в устойчивом состоянии (старые ротируются) — таблица журнала сама по себе не тяжёлая; тяжесть — в объёме файлов на диске/S3, не в БД.
Горячие пути и бюджет: список журнала в Filament/API — 1 keyset-запрос по индексу (status, started_at); health-чек — 1 индексированный запрос «последняя успешная запись» на сайт. Сам бэкап и тест-рестор выполняются вне HTTP-бюджета (только в очереди) — их стоимость измеряется не в количестве SQL-запросов, а в времени дампа, полосе сети до S3 и месте на диске; это отдельный бюджет, контролируемый backup.max_duration_minutes.
Индексы: составной (status, started_at) на cms_backup_log под фильтр «последние неудачные»/health- агрегат; BRIN по started_at вместо B-tree — журнал растёт монотонно по времени, BRIN даёт компактный индекс почти без обслуживания при кратно меньшем размере; FK-индекс backup_log_id на cms_backup_restore_tests — уже объявлен в модели данных.
Кеш: собственных тегов кеша модуль не объявляет — не участвует в page-cache и не инвалидирует чужие теги. Статус последнего бэкапа для health-агрегата не кешируется тегами (данные меняются редко и чтение уже дешёвое по индексу); специальный кеш-слой избыточен.
Безопасность
Границы входа: ручной запуск бэкапа и доступ к журналу — только под явными permissions, без формы пользовательского ввода (кроме выбора диска/типа из whitelist-enum). Креды S3-хранилища — только .env через cms/storage-s3, не в БД. Тест-рестор выполняется в изолированную временную БД, никогда не затрагивает рабочую — явная проверка имени целевой БД перед восстановлением (совпадение с боевым именем БД — фатальная ошибка, операция прерывается до первого DDL).
Специфичные векторы атаки:
- Подмена диска/типа бэкапа —
disk/typeпринимаются только как enum из FormRequest, не как произвольная строка пути: нельзя указать путь вне зарегистрированных дисков Laravel filesystem. - Публичный доступ к дампу — бакет S3 обязан быть приватным; скачивание только через presigned URL с ограниченным TTL (
GET /backup/{id}/download), не постоянная публичная ссылка. - Утечка через
error-поле журнала — сообщение об ошибке перед записью вcms_backup_logи выдачей через API очищается от абсолютных путей ФС, кредов подключения и стектрейсов; в журнал и в ответ API уходит человеко-читаемая причина, детали — только в лог-канал модуля (аудитория «разработчик», см. §12 стандарта). - Двойной клик / гонка ручного и планового запуска — без мьютекса на тип операции возможны два параллельных дампа, конкурирующих за ввод-вывод и потенциально дающих несогласованный второй файл; закрывается блокировкой очереди (см. «Фоновая работа»).
- Данные с ПДн внутри дампа — сам дамп БД по определению содержит все ПДн проекта; хранение на диске/S3 обязано использовать шифрование at-rest средствами хранилища (SSE на S3), доступ — только под
backup.manage/backup.view, не более широкий, чем у прод-БД.
ПДн-паспорт (матрица v2.2). Собственные таблицы модуля (cms_backup_log, cms_backup_restore_tests) ПДн не хранят — только метаданные операций. Сам файл дампа — по определению максимально широкий контейнер ПДн инсталляции (все таблицы всех модулей разом), поэтому: backup.encrypt_at_rest=true шифрует дамп до выгрузки ключом из backup.encryption_key_ref (.env, не БД); срок жизни копии ограничен ретеншн-сеткой (retention_daily/weekly/monthly + абсолютный потолок retention_days), после чего файл физически удаляется с диска/S3, не просто снимается с учёта в журнале. Модуль не регистрирует обработчик в PrivacyRegistry и не участвует в «выгрузить всё по субъекту»/«забыть по запросу» напрямую (152-ФЗ) — эти операции применяются к рабочей БД модулями-владельцами данных; удалённый субъект продолжит существовать в уже снятых копиях до истечения их ретеншна — это осознанный компромисс архивного хранения, а не пробел (задокументировано, не «забыто» негласно).
Матрица ролей:
| Роль | Просмотр журнала | Ручной запуск/скачивание |
|---|---|---|
| studio | ✅ | ✅ |
| админ | ✅ | ✅ |
| менеджер | ✅ | ❌ |
| редактор | ❌ | ❌ |
cms/backup — модуль обязательного managed-парка (§3 стандарта): его disable недоступен клиентским ролям вообще, только studio с подтверждением и записью в аудит — таблица выше описывает права внутри включённого модуля, не решение о его выключении.
Права: backup.view, backup.manage.
UX-требования
Админ. Пустое состояние журнала (бэкапов ещё не было) — подсказка «Бэкапов пока нет, нажмите «Создать бэкап сейчас», чтобы снять первую копию». Кнопка «Создать бэкап сейчас» блокируется/показывает статус «выполняется» пока джоба не завершится — предотвращает случайный повторный клик (гонка гасится и на сервере мьютексом, UI — первая линия защиты от лишней нагрузки). Массовых операций над записями журнала нет — ротация автоматическая по ретеншн-сетке (retention_daily/weekly/monthly + потолок retention_copies/retention_days), вручную бэкапы из UI не удаляются. Ошибки — на человеческом языке: «Не удалось создать бэкап: недостаточно места на диске» вместо голого исключения. Восстановление рабочей БД из бэкапа не выполняется через Filament — это осознанно вынесенная за UI DevOps-операция (см. «Крайние случаи»), обязательно завершаемая cms:postupgrade и переиндексацией по ранбуку ядра, чтобы исключить случайный клик поверх продакшена; единственная доступная в UI «рестор»-операция — безопасный тест-рестор во временную БД, необратимых операций с данными пользователя UI модуля не предлагает.
Посетитель. Посетитель не взаимодействует с модулем напрямую.
Крайние случаи и типовые баги
- Двойной клик «Создать бэкап сейчас» / параллельный ручной и плановый запуск → мьютекс на
backup:{type}в очереди: второй запуск отклоняется с понятным сообщением «бэкап уже выполняется» вместо параллельного дампа. - Бэкап во время активной записи в БД (консистентность снапшота) → дамп БД снимается механизмом СУБД, дающим консистентный снапшот на момент старта (
pg_dumpс транзакционным снапшотом /--single-transaction), а не файловым копированием «вслепую»; файловый бэкап медиатеки — отдельный проход, возможен небольшой временной разрыв между снятием БД и файлов — не критично, так как файлы медиатеки иммутабельны по content-hash и не переписываются задним числом. - Диск/S3 кончилось место посреди бэкапа → джоба падает с
BackupFailed, частично записанный файл удаляется вfinally(не остаётся битый файл, который выглядит как валидная копия), алерт уходит немедленно, ротация не запускается, пока нет ни одной валидной свежей копии. - Восстановление бэкапа на другой (более новой/старой) версии CMS → дамп содержит схему своей версии на момент снятия; рестор на другую версию не применяется молча поверх чужой схемы — тест-рестор ядра сверяет версию/хеш схемы дампа с текущими миграциями целевой БД и явно помечает несовместимость (
status: schema_mismatch), требуя прогона миграций до принятия дампа как валидного. - Проверка восстановимости — обязательный принцип: ⚠️ бэкап без успешного рестор-теста не считается бэкапом. Health-чек модуля обязан деградировать до
degraded, даже если сам дамп формально завершился успехом (BackupCompleted), если последнийcms_backup_restore_testsдля него не прошёл или устарел дольше окна, заданногоbackup.restore_test_enabled-циклом. В исходном ТЗ health-чек был описан как «отражает статус» без явного порога деградации — уточнение: отсутствие свежего успешного тест-рестора обязано понижать статус агрегатаcms/health, а не оставаться незамеченным до реальной аварии. - Отсутствие suggests-модуля
cms/storage-s3→ фактический диск —localвместо настроенногоs3, с предупреждением в лог и понижением health-статуса модуля (см. «Зависимости и выключение»). - Выключение модуля посреди выполняющейся джобы → уже запущенная джоба доигрывается (воркер не проверяет состояние модуля на каждый тик), новые плановые и ручные запуски не создаются;
cms:upgradeвидит модуль выключенным и пропускает гейт с явным предупреждением. - Пустой бэкап (например, свежий сайт до первой значимой миграции/загрузки медиа) → не считается ошибкой, но помечается в журнале отдельным флагом (например,
size_bytes≈ 0 + заметка), чтобы не создавать ложное чувство защищённости у администратора. - Огромный бэкап (медиатека на десятки ГБ) → выгрузка потоковая/многочастная (multipart upload) в S3, таймаут джобы — по
backup.max_duration_minutesдля типаfiles/full, прогресс виден в Filament, а не тишина до самого завершения или падения. - Противоречивая комбинация настроек:
backup.enabled=falseприbackup.upgrade_gate_enabled=true→ гейт не блокирует апгрейд флагом настройки, а проверяет фактическое состояние (наличие свежего валидного бэкапа); при выключенном модуле бэкапов нет физически, поэтому апгрейд идёт по ветке «модуль выключен, предупреждение в лог», как и в общем случае выключения, а не зависает на недостижимом условии. - Multisite с общей БД → модуль не различает сайты внутри одного бэкапа (снимается вся БД инсталляции целиком); при необходимости пер-сайтовых копий это отдельная задача вне текущего ТЗ — явно зафиксировано как ограничение, не молчаливый пробел.
- Потерян/недоступен
backup.encryption_key_ref→ уже зашифрованные копии физически невосстановимы (это следствие самого шифрования, не баг) — модуль обязан явно предупреждать в UI при смене ключа: «Старые копии останутся нечитаемыми без прежнего ключа»; ключ не хранится нигде, кроме.env, ротация ключа — ручная DevOps-операция вне UI модуля. - Тест-рестор реже, чем сам бэкап (
restore_test_cronрежеschedule_cron) → между снятием копии и подтверждением её восстановимости возможен разрыв в днях; health-чек ориентируется на «последний пройденный тест-рестор», не на «последний бэкап», поэтому окно временно повышенного риска видно администратору явно, а не скрыто зелёным статусом.
Донорский код
Донор: — (используется пакет spatie/laravel-backup поверх инфраструктуры студии; собственного кода-донора нет). Миграция legacy-данных (§16 стандарта, матрица v2.2): не применимо — модуль работает только с данными текущей инсталляции, донорских бэкапов для переноса нет.
Тесты и приёмка
- [ ] Контрактный тест:
cms:upgradeне стартует без успешного принудительного бэкапа при включённом гейте - [ ] Health-чек модуля отражает время последнего успешного бэкапа и статус тест-рестора
- [ ] Health-чек деградирует до
degradedпри отсутствии свежего успешного тест-рестора, даже если последний бэкап формально успешен (принцип «бэкап без рестор-теста не считается бэкапом») - [ ] При выключении модуля
cms:upgradeпродолжает работать с явным предупреждением в лог - [ ] Ретеншн-сетка держит заданное число дневных/недельных/месячных копий, лишние удаляются, не свежие
- [ ] Копии старше
backup.retention_days/сверхbackup.retention_copiesудаляются даже при живой сетке - [ ] Зашифрованный (
encrypt_at_rest=true) дамп не читается без ключаencryption_key_ref; расшифровка тест-рестором подтверждена тестом - [ ] Тест-рестор идёт по собственному расписанию
restore_test_cron, независимому отschedule_cron - [ ] Алерт уходит при сбое бэкапа или провале тест-рестора
- [ ] Параллельный ручной запуск при уже выполняющемся бэкапе того же типа блокируется мьютексом, не создаёт двух одновременных дампов
- [ ] Дамп БД использует консистентный транзакционный снапшот — тест на параллельную запись во время дампа
- [ ] Сбой посреди выгрузки (недостаточно места) фиксирует
BackupFailedи не оставляет частично записанный файл в хранилище - [ ] Восстановление на несовместимую версию схемы CMS явно помечается
schema_mismatch, не применяется молча - [ ] Права
backup.view/backup.manageразграничивают просмотр и ручной запуск/скачивание - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
backup_test,migrate:freshзапрещён