Compare commits
20 Commits
313b1820be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
2930842e3f
|
|||
|
0f80e66b66
|
|||
|
2b22fde718
|
|||
|
c39de421e0
|
|||
|
a50b399a85
|
|||
|
94b09be53c
|
|||
|
b637fea882
|
|||
|
933a0b9570
|
|||
|
96710360d9
|
|||
|
d9f0d94e1f
|
|||
|
9b853d351c
|
|||
|
11744f776a
|
|||
|
0df5f358d0
|
|||
|
62e2a72e52
|
|||
|
7c91f4f355
|
|||
|
68d8bf6a68
|
|||
|
e585bfdca2
|
|||
|
41822e04e8
|
|||
|
21ccc7ac8c
|
|||
|
81478c2323
|
@@ -0,0 +1,50 @@
|
||||
# Authelia вместо Keycloak для SSO
|
||||
|
||||
- Дата: 2025-05-07
|
||||
|
||||
## Контекст
|
||||
|
||||
Для SSO/OIDC на сервере стоял Keycloak (заведён годом ранее,
|
||||
2024-05-25). Проблема — ресурсы: Keycloak съедал больше 500 МБ RAM, что
|
||||
тяжело для личного сервера с ограниченной оперативной памятью. При этом вся его мощь
|
||||
избыточна: пользователей меньше десяти, realms / federation / тяжёлый
|
||||
корпоративный стек не нужны. Изначально взял Keycloak, потому что нужен был
|
||||
OIDC-сервер для настройки Outline; на тот момент было понятное
|
||||
руководство по связке OIDC и Keycloak.
|
||||
|
||||
Требовался лёгкий по памяти SSO-провайдер с хорошей документацией,
|
||||
желательно на Go/Rust.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Оставить Keycloak.** Отвергнуто: > 500 МБ RAM ради < 10
|
||||
пользователей, функционал избыточен для личного сервера.
|
||||
- **Authelia** (выбран). Лёгкая (Go), малое потребление памяти, хорошая
|
||||
документация. Умеет и OIDC, и forward-auth.
|
||||
|
||||
Критерии отбора замены: минимальный расход RAM, хорошая документация,
|
||||
стек Go/Rust.
|
||||
|
||||
## Решение
|
||||
|
||||
Заменили Keycloak на Authelia как провайдер аутентификации
|
||||
(коммиты `a77fefc`, `d1500ea`, `3a23c08`). Authelia используется в трёх
|
||||
режимах:
|
||||
|
||||
- **OIDC** для приложений, которым он нужен (например, Outline).
|
||||
- **Forward-auth** агент в Caddy — удобно там, где полноценный OIDC
|
||||
избыточен.
|
||||
- **Закрытие чувствительных приложений** за единым логином. Раньше для
|
||||
этого использовался basic auth в Caddy.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Резко меньше потребление RAM — критично для сервера с дефицитом
|
||||
памяти.
|
||||
- `+` Forward-auth закрывает приложения без OIDC проще, чем поднимать
|
||||
отдельный OIDC-клиент под каждое.
|
||||
- `+` Единая точка аутентификации вместо разрозненного basic auth в
|
||||
Caddy.
|
||||
- `-` Authelia беднее Keycloak по возможностям (нет полноценного интерфейса
|
||||
управления пользователями, realms, federation) — но для < 10
|
||||
пользователей это не нужно.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Данные приложений на отдельном диске
|
||||
|
||||
- Дата: 2025-12-07
|
||||
|
||||
## Контекст
|
||||
|
||||
Исторически данные приложений лежали прямо в домашних директориях их
|
||||
системных пользователей (`/home/<app-user>/…`), то есть на системном
|
||||
диске рядом с ОС. В конце 2025 встал вопрос обновления ОС (Ubuntu 22.04
|
||||
уже устарела), и стало ясно: пока данные привязаны к системному диску,
|
||||
любое обновление или пересборка системы рискует этими данными и тяжело
|
||||
откатывается.
|
||||
|
||||
Возникла мысль развязать данные приложений и жизненный цикл ОС.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Данные на системном диске** (как было). Просто, но данные связаны с
|
||||
ОС: обновление/пересборка системы затрагивает и их.
|
||||
- **Отдельный диск под данные** (выбран). Данные переживают пересборку
|
||||
ОС, диск можно отцепить от одного сервера и прицепить к другому.
|
||||
|
||||
## Решение
|
||||
|
||||
Вынесли все данные приложений на отдельный диск, смонтированный в
|
||||
`/mnt/applications`; каждое приложение держит там свои `data` / `config`
|
||||
/ `backups`, а `base_dir`/`data_dir` указывают на этот путь
|
||||
(коммиты `47a6320`, `7e67409`, `ae7c20a`, `8dfd061`).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Данные развязаны с жизненным циклом ОС — систему можно обновлять
|
||||
и пересобирать, не трогая данные.
|
||||
- `+` Диск можно отцепить от старого сервера и прицепить к новому. Это
|
||||
легло в основу метода обновления ОС
|
||||
([ADR-2025-12-13](ADR-2025-12-13-os-upgrade-via-server-rebuild.md)).
|
||||
- `-` Появилась зависимость от монтирования внешнего диска (UUID,
|
||||
mount-конфигурация): если диск не смонтирован, приложения не
|
||||
поднимутся. Позже, при переезде в Timeweb, монтирование пришлось
|
||||
сделать опциональным
|
||||
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md), фаза 1 — один
|
||||
диск, фаза 2 — отдельный «холодный» диск под крупные данные).
|
||||
@@ -0,0 +1,53 @@
|
||||
# Обновление ОС пересборкой на свежем сервере
|
||||
|
||||
- Дата: 2025-12-13
|
||||
|
||||
## Контекст
|
||||
|
||||
На сервере стояла Ubuntu 22.04, и к концу 2025 пора было обновляться.
|
||||
Обновлять живую боевую систему «на месте» (`do-release-upgrade`) не
|
||||
хотелось — это рискованно и тяжело откатывается, если что-то пойдёт не
|
||||
так на работающем сервере.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Обновление «на месте»** (`do-release-upgrade` на живой системе).
|
||||
Отвергнуто: риск сломать рабочий сервер, нет простого отката.
|
||||
- **Пересборка на свежем сервере** (выбран). Поднять новый сервер с
|
||||
целевой ОС, накатать ansible, прицепить диск с данными, развернуть
|
||||
приложения — старый сервер остаётся нетронутым как точка отката.
|
||||
Заодно — почистить мусор, накопившийся за время работы прошлого сервера.
|
||||
|
||||
## Решение
|
||||
|
||||
Обновляем ОС через пересборку на свежем сервере. Метод опирается на три
|
||||
предпосылки:
|
||||
|
||||
- **Деплой без запуска контейнеров.** Сводные плейбуки
|
||||
(`playbook-all-setup`, `playbook-all-applications`) и тег `run-app`
|
||||
позволяют раскатать пользователей, каталоги и конфиги, но НЕ запускать
|
||||
приложения (`--skip-tags run-app`) — данные переносятся в «тихую»
|
||||
систему (коммиты `5b53cb3`, `48bb8c9`, `67df03e`).
|
||||
- **Данные на отдельном диске**
|
||||
([ADR-2025-12-07](ADR-2025-12-07-app-data-on-separate-disk.md)) — диск
|
||||
с данными прицепляется к новому серверу.
|
||||
- **Фиксированные uid/gid.** Заранее закрепили uid/gid всех
|
||||
пользователей приложений (роль `owner`, коммит `c2ea2cd`). Это
|
||||
критично: иначе при пересоздании пользователей на новом сервере
|
||||
uid/gid могли бы сдвинуться, и данные приложений на отдельном диске
|
||||
оказались бы с чужим владельцем.
|
||||
|
||||
Порядок: сначала вся подготовка (отдельный диск, перенос данных на него,
|
||||
фиксация uid/gid), затем пересборка на новом обновлённом сервере. Перенос
|
||||
прошёл без проблем.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Обновление ОС без риска для живой системы; откат = вернуться на
|
||||
старый сервер.
|
||||
- `+` Получился воспроизводимый процесс миграции — позже переиспользован
|
||||
при переезде в Timeweb как «холодное переключение» (cold cutover)
|
||||
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
|
||||
- `+` Фиксация uid/gid стала постоянным инвариантом проекта.
|
||||
- `-` Метод требует заранее подготовленных предпосылок (фикс uid/gid +
|
||||
данные на отдельном диске); без них он не работает.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Apprise как шлюз уведомлений
|
||||
|
||||
- Дата: 2026-04-04
|
||||
|
||||
## Контекст
|
||||
|
||||
В первую очередь нужны были уведомления о бэкапах — знать, что ночной
|
||||
прогон отработал и не сломался. Уведомления слались напрямую в конкретный
|
||||
канал, привязка была зашита в каждом источнике. Хотелось единый слой,
|
||||
который абстрагирует каналы доставки — чтобы добавлять или менять канал в
|
||||
одном месте, а не править каждый источник.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Прямая интеграция с каждым каналом** (как было). Каждый источник знает про
|
||||
конкретный канал; смена канала — правки во многих местах.
|
||||
- **Apprise** (выбран). Смотрел разные self-hosted шлюзы уведомлений;
|
||||
apprise выиграл зрелостью и числом готовых интеграций (десятки каналов
|
||||
из коробки).
|
||||
|
||||
## Решение
|
||||
|
||||
Подняли apprise отдельным сервисом-шлюзом: источники шлют уведомление по
|
||||
HTTP в apprise, а он разводит его по настроенным каналам (коммиты
|
||||
`a0543e1`, `5f619ea`, `6bfb362`). Под ограниченную память сервера apprise
|
||||
запущен в один воркер (`5e6df11`).
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Каналы доставки абстрагированы за единым шлюзом — добавить или
|
||||
сменить канал можно в одном месте, не трогая источники.
|
||||
- `+` Доступ к десяткам интеграций apprise без отдельного кода под
|
||||
каждую.
|
||||
- `-` Ещё один сервис в обслуживании (контейнер, память).
|
||||
- Окупилось при переезде в Timeweb: провайдер заблокировал Telegram, и
|
||||
переключение уведомлений (сейчас почта, в планах Matrix) локализовано в
|
||||
шлюзе ([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## Контекст
|
||||
|
||||
`rivendell-v2` жил на VM в Yandex Cloud. Одновременно копились три
|
||||
`rivendell-v2` жил на виртуальной машине в Yandex Cloud. Одновременно копились три
|
||||
проблемы:
|
||||
|
||||
- **Цена.** ≈ 2 887 ₽/мес за конфигурацию, которую другие провайдеры
|
||||
@@ -17,7 +17,7 @@
|
||||
HDD вместо SSD/NVMe — страдала отзывчивость (Gitea, Outline, тёплый
|
||||
старт контейнеров, restic check/forget).
|
||||
|
||||
Это личный сервер — допустим мягкий даунтайм.
|
||||
Это личный сервер — допустимы небольшие простои.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
@@ -37,16 +37,18 @@
|
||||
|
||||
Рамки решения:
|
||||
|
||||
- Переезжает **только compute** (VM с приложениями). S3 (restic, бекапы),
|
||||
- Переезжает **только сам сервер с приложениями** (compute). S3 (restic, бэкапы),
|
||||
Container Registry, Postbox SMTP и DNS-зона `vakhrushev.me` остаются
|
||||
в Yandex и используются с новой машины.
|
||||
- Стратегия — **cold cutover**: погасить сервисы на источнике, раскатать
|
||||
- Стратегия — **холодное переключение** (cold cutover): погасить сервисы
|
||||
на источнике, раскатать
|
||||
ansible на новом сервере без запуска приложений (сохраняя uid/gid), перенести
|
||||
данные `rsync`'ом, запустить, переключить DNS.
|
||||
- Диск: фаза 1 — один 80 ГБ NVMe (всего 22 ГБ данных, влезает с
|
||||
- Диск: фаза 1 — один 80 ГБ NVMe (всего 22 ГБ данных + 17 ГБ системных, влезает с
|
||||
запасом). «Холодный» второй диск под крупные данные — отдельная
|
||||
фаза 2, не на критическом пути.
|
||||
- Источник не удаляется сразу после cutover: держим «холодным запасным»
|
||||
- Источник не удаляется сразу после переключения: держим «холодным
|
||||
запасным»
|
||||
пару недель ради отката.
|
||||
|
||||
Детальный план — [`../drafts/timeweb.md`](../drafts/timeweb.md),
|
||||
@@ -59,7 +61,7 @@
|
||||
закрыты все три исходные проблемы.
|
||||
- `+` Запас по RAM убирает OOM-риск при всплесках нагрузки.
|
||||
- `+` Диверсификация по облакам: раньше сервер и данные были в одном
|
||||
аккаунте Yandex Cloud, теперь compute в Timeweb, а бэкапы (S3) — в
|
||||
аккаунте Yandex Cloud, теперь сам сервер в Timeweb, а бэкапы (S3) — в
|
||||
Yandex. Если заблокируют или потеряем доступ к одному провайдеру,
|
||||
данные остаются доступны через другой.
|
||||
- `-` Диск меньше (80 ГБ NVMe против 120 ГБ HDD), но сейчас занят
|
||||
@@ -67,13 +69,14 @@
|
||||
- `-` Сохраняется зависимость от Yandex Cloud (S3, Container Registry,
|
||||
Postbox SMTP, DNS) — переезд её не устраняет.
|
||||
- `-` Timeweb активно блокирует Telegram (в отличие от YC) — интеграция
|
||||
отвалилась. Затронуты `transcriber`, `remembos` и нотификации о
|
||||
бэкапах. Ожидаемо; нотификации остались через почту, второй канал
|
||||
отвалилась. Затронуты `transcriber`, `remembos` и уведомления о
|
||||
бэкапах. Ожидаемо; уведомления остались через почту, второй канал
|
||||
рассматривается через Matrix.
|
||||
- `-` Из-за тех же блокировок Timeweb перестали обновляться некоторые
|
||||
RSS-фиды в `miniflux`.
|
||||
- `-` Для доступа к `cr.yandex` вне YC появился долгоживущий OAuth-токен
|
||||
Яндекса в vault (`yc_oauth_token`) с широким blast radius. При желании
|
||||
сузить — IAM-ключ сервисного аккаунта отдельной итерацией.
|
||||
Яндекса в vault (`yc_oauth_token`): при утечке он открывает доступ ко
|
||||
всему аккаунту Яндекса. Сузить можно IAM-ключом сервисного аккаунта —
|
||||
отдельной итерацией.
|
||||
- Инвентарь временно раздвоен (`production.yml` + `timeweb.yml`); после
|
||||
стабилизации источник удаляется, `timeweb.yml` → `production.yml`.
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# Разнесение restic-операций на фазы под Intelligent Tiering
|
||||
|
||||
- Дата: 2026-06-22
|
||||
|
||||
## Контекст
|
||||
|
||||
Бэкапы restic уже лежат в Yandex Object Storage на стандартном классе
|
||||
хранения (STANDARD). Yandex выпустил класс «Умное хранилище» (Intelligent
|
||||
Tiering, IT): объекты автоматически охлаждаются до архивного уровня
|
||||
(примерно 0,63 ₽/ГБ против 2,38 ₽/ГБ у STANDARD) с сохранением мгновенного
|
||||
доступа на всех уровнях (анонс:
|
||||
<https://yandex.cloud/ru/blog/s3-intelligent-tiering>). Данные restic — это
|
||||
профиль «записал один раз, читаю редко», то есть идеальный кандидат на
|
||||
охлаждение. Цель — перевести уже лежащие бэкапы со STANDARD на IT и
|
||||
сэкономить на хранении.
|
||||
|
||||
Проблема в том, что любой repack/recompress объекта создаёт *новый*
|
||||
объект, который входит в IT как «Частый доступ» и заново стартует таймер
|
||||
охлаждения (30 дней → «Нечастый», ещё 90 → «Архивный»). А наш оркестратор
|
||||
`backup-all.py` гнал каждую ночь связку `backup → check → forget --prune →
|
||||
check`. Ночной `prune` перепаковывает data-паки → постоянно сбрасывает
|
||||
охлаждение → отменяет экономию IT. Нужно было перестроить обслуживание
|
||||
так, чтобы не мешать охлаждению.
|
||||
|
||||
Дополнительное ограничение по миграции существующих данных: по докам
|
||||
Yandex изменение класса бакета **по умолчанию** не трогает уже загруженные
|
||||
объекты — они остаются в STANDARD, новый класс применяется только к новым
|
||||
загрузкам. Перевести уже лежащие бэкапы в IT можно lifecycle-правилом или
|
||||
copy-in-place (`aws s3 cp --storage-class INTELLIGENT_TIERING`); оба
|
||||
тарифицируются как операция `TRANSITION`. Перезаливка объектов заново для
|
||||
restic не годится — это churn, эквивалентный репаку. Управления бакетом
|
||||
(terraform/aws-cli) в проекте нет, так что миграцию пришлось бы делать
|
||||
вручную или заводить такое управление.
|
||||
|
||||
Идентификатор класса подтверждён доками — `INTELLIGENT_TIERING` (Yandex
|
||||
поддерживает STANDARD, COLD, ICE, INTELLIGENT_TIERING). Явный min-retention
|
||||
(12 месяцев со штрафом за раннее удаление) документирован **только для
|
||||
класса ICE**; для архивного уровня внутри IT такого минимума в доках нет —
|
||||
значит transition и последующий `prune` для IT низкорисковы. Финальная
|
||||
проверка — по биллингу после первой реальной миграции.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
- **Отдельные скрипты на репозиторий** (`backup.sh`/`check.sh`/`prune.sh`/
|
||||
`verify.sh` + свои cron-записи, как в исходном гайде). Ближе к гайду
|
||||
дословно, но теряем оркестратор: авто-дискавери приложений, мультистор
|
||||
и единые apprise-уведомления. Плюс 4 независимые cron-записи провоцируют
|
||||
наложение операций (долгий `prune` налезает на ночной `backup` →
|
||||
конфликт restic-локов). Отвергнут.
|
||||
- **Адаптировать `backup-all.py`** — добавить фазы и расписание внутрь
|
||||
оркестратора, один ночной триггер. Сохраняет всю существующую
|
||||
инфраструктуру, фазы идут последовательно в одном процессе → локи не
|
||||
конфликтуют. **Выбран.**
|
||||
- **Расписание: простые knobs** (день недели/число/месяцы как поля
|
||||
конфига) **vs cron-выражения через `croniter`**. Knobs — без
|
||||
зависимости, но негибко (новая ось → правка кода). Выбран `croniter`:
|
||||
пакет ставится из apt (`python3-croniter`) тем же механизмом, что и
|
||||
остальное, а гибкость реальная — поменять «раз в квартал» на «раз в
|
||||
месяц» = правка одной строки конфига.
|
||||
- **Перевод существующих данных в IT: copy-in-place vs lifecycle vs
|
||||
отложить.** Lifecycle в Yandex переводит только «на более холодный»
|
||||
(STANDARD→COLD→ICE), переход именно в IT им не заявлен — отпал.
|
||||
Перезаливка объектов для restic не годится (churn ≈ репак). Остаётся
|
||||
**copy-in-place** (`aws s3 cp --recursive --storage-class
|
||||
INTELLIGENT_TIERING`, серверная копия «на себя»). **Выбран copy-in-place**
|
||||
— после того как доки сняли блокер по min-retention. Для будущих записей
|
||||
отдельно — флип класса бакета по умолчанию на IT (вариант A с
|
||||
`-o s3.storage-class` в коде не понадобился).
|
||||
|
||||
## Решение
|
||||
|
||||
Операции restic в `files/backups/backup-all.py` разнесены на фазы с
|
||||
разной частотой, потому что у них принципиально разная цена для IT:
|
||||
|
||||
- `backup` + `forget` — **каждый прогон**. `forget` теперь **без
|
||||
`--prune`**: удаляет только метаданные снапшотов (операция DELETE не
|
||||
тарифицируется), не репакует data-паки и не сбивает охлаждение.
|
||||
- `check` (структурный) — еженедельно; `prune` — квартально; `verify`
|
||||
(`check --read-data-subset`) — помесячно. Расписание задано
|
||||
cron-выражениями в секции `[schedule]` конфига и вычисляется через
|
||||
`croniter`. Триггер один ночной, фазы одного прогона идут
|
||||
последовательно в одном процессе → restic-локи между ними не
|
||||
конфликтуют. Наложение соседних прогонов гасится `flock -n` в cron.
|
||||
|
||||
`prune` тюнингован под IT (`--max-unused 20%`, `--max-repack-size 5G`):
|
||||
чем меньше холодных паков переписываем, тем дольше держится охлаждение.
|
||||
|
||||
Перевод бакетов в IT идёт двумя действиями на каждый бакет: смена класса
|
||||
**по умолчанию** на IT в консоли (будущие записи restic) + разовая
|
||||
**copy-in-place** существующих объектов (`aws s3 cp s3://<bucket>/
|
||||
s3://<bucket>/ --recursive --storage-class INTELLIGENT_TIERING
|
||||
--metadata-directive COPY`). Класс отдаётся прямо в листинге
|
||||
(`list-objects-v2 --query 'Contents[].[StorageClass,Key]'`) — им и
|
||||
проверяем. Грабли: для проверки нельзя `--max-items 1` (клиентская
|
||||
пагинация aws-cli дописывает в вывод токен `None`) — нужен серверный
|
||||
`--max-keys`.
|
||||
|
||||
Статус миграции: **`rivendell` переведён 2026-06-23** (дефолт бакета = IT
|
||||
со скриншота, все объекты `config`/`data/` показывают
|
||||
`INTELLIGENT_TIERING`). `eos` (основная экономия) и `buckland` — следующими,
|
||||
после нескольких дней наблюдения за биллингом `rivendell`.
|
||||
|
||||
Retention оставлен прежним (`--keep-daily 90 --keep-monthly 36`) — это
|
||||
решение про охлаждение и частоту операций, а не про глубину истории.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Ночной prune больше не сбрасывает охлаждение — IT реально экономит
|
||||
на архивном уровне.
|
||||
- `+` Нет наложения restic-операций: последовательные фазы + `flock`.
|
||||
- `+` Расписание обслуживания меняется правкой конфига, без релиза кода.
|
||||
- `-` Новая зависимость на сервере: `python3-croniter` (и явно
|
||||
зафиксированный `python3-requests`).
|
||||
- `-` Структурный `check` теперь еженедельный, а не каждую ночь: битый
|
||||
бэкап может остаться незамеченным до недели. Для хобби-сервера приемлемо.
|
||||
- `-` Подвох croniter: при суточном триггере поля минут/часов в
|
||||
выражениях декоративны (держим `* *`) — фаза идёт в момент ночного
|
||||
прогона, а не во время из выражения.
|
||||
- `+` Миграция существующих объектов — разовая copy-in-place, без репака
|
||||
restic: содержимое и ключи паков не меняются, restic остаётся рабочим.
|
||||
- `-` После перевода объекты стартуют на уровне FREQUENT и охлаждаются
|
||||
~120 дней — полка экономии устанавливается не сразу.
|
||||
- Осталось сделать: несколько дней последить за биллингом и бэкапами
|
||||
`rivendell` (убедиться, что за transition нет штрафа), затем повторить
|
||||
пару «флип дефолта + copy-in-place» для `eos` и `buckland`.
|
||||
+6
-1
@@ -62,6 +62,11 @@
|
||||
Новые сверху.
|
||||
|
||||
| Дата | Запись | Статус |
|
||||
| ---------- | ------------------------------------------------------------------------------------- | ------ |
|
||||
| ---------- | ---------------------------------------------------------------------------------------------- | ------ |
|
||||
| 2026-06-22 | [Разнесение restic-операций на фазы под Intelligent Tiering](ADR-2026-06-22-restic-intelligent-tiering-phases.md) | — |
|
||||
| 2026-05-23 | [Переезд сервера с Yandex Cloud на Timeweb VPS](ADR-2026-05-23-migrate-to-timeweb.md) | — |
|
||||
| 2026-04-04 | [Apprise как шлюз уведомлений](ADR-2026-04-04-apprise-notifications.md) | — |
|
||||
| 2025-12-13 | [Обновление ОС пересборкой на свежем сервере](ADR-2025-12-13-os-upgrade-via-server-rebuild.md) | — |
|
||||
| 2025-12-07 | [Данные приложений на отдельном диске](ADR-2025-12-07-app-data-on-separate-disk.md) | — |
|
||||
| 2025-05-07 | [Authelia вместо Keycloak для SSO](ADR-2025-05-07-authelia-sso.md) | — |
|
||||
| 0000-00-00 | [Вести историю решений в виде ADR](ADR-0000-00-00-record-architecture-decisions.md) | — |
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
# Ревью плейбуков: best practices и конвенции Ansible
|
||||
|
||||
Дата: 2026-05-25. Статус: черновик (заметки по итогам ревью, не план работ).
|
||||
|
||||
Проанализированы инвентарь, `ansible.cfg`, роли (`owner`, `eget`, `secrets`) и
|
||||
репрезентативная выборка плейбуков: `gitea`, `memos`, `wanderer`, `backups`,
|
||||
`system`, `caddyproxy`, `authelia`, `netdata`, `docker`, `eget`, `all-*`.
|
||||
Находки отсортированы по влиянию.
|
||||
|
||||
## Договорённость по структуре (важно для контекста)
|
||||
|
||||
Изначальная рекомендация «вынести общий деплой в одну generic-роль `docker_app`»
|
||||
**отклонена осознанно** и не должна предлагаться снова:
|
||||
|
||||
- приложения реально разные, мелкие отличия больно загонять в единую абстракцию;
|
||||
- catch-all роль обрастает флагами `when:` и читается хуже, чем N честных плейбуков;
|
||||
- per-playbook дублирование даёт locality of behavior и возможность обкатать новый
|
||||
подход на одном сервисе, затем раскатать на остальные.
|
||||
|
||||
Правильное направление — **набор маленьких composable-ролей на инвариантных швах**
|
||||
(как уже сделано с `owner`), а не одна роль на всё. Per-app конфиг остаётся локально
|
||||
в плейбуке сервиса.
|
||||
|
||||
## 1. Extraction только на чистых швах (не мега-роль)
|
||||
|
||||
Per-app конфиг (каталоги, шаблоны, env, порты, особенности compose) — оставляем в
|
||||
плейбуке сервиса. Выносим лишь то, что реально инвариантно и повторилось многократно:
|
||||
|
||||
- **Бэкап** — самый чистый шов: `gobackup.yml` + `backup.sh` + `backup-targets` +
|
||||
интеграция с restic. Механизм одинаков у всех, различается только список целей.
|
||||
Роль `backup` с параметром «список targets» не трогает индивидуальность сервиса.
|
||||
- `owner` уже сделан как отдельная composable-роль — это правильный размер абстракции.
|
||||
|
||||
## 2. `vars_files` в каждом плейбуке → `group_vars/all/`
|
||||
|
||||
В каждом плейбуке повторяется:
|
||||
|
||||
```yaml
|
||||
vars_files:
|
||||
- vars/secrets.yml
|
||||
- vars/vars.yml
|
||||
```
|
||||
|
||||
Ansible автоматически подхватывает `group_vars/all.yml` и `group_vars/all/secrets.yml`
|
||||
(vault) для группы `all`. Перенос `vars/vars.yml` → `group_vars/all/main.yml` и
|
||||
`vars/secrets.yml` → `group_vars/all/vault.yml` убирает boilerplate из всех плейбуков.
|
||||
Адаптируется по одному плейбуку за раз.
|
||||
|
||||
## 3. Нет handlers — `state: restarted` безусловный
|
||||
|
||||
Ни в одном плейбуке нет `handlers:`. Вместо этого:
|
||||
|
||||
- `playbook-caddyproxy.yml:106`, `playbook-netdata.yml:143`, `playbook-authelia.yml:92` —
|
||||
задача `state: restarted` выполняется **всегда**, рестартит контейнер на каждом
|
||||
прогоне даже без изменений (не идемпотентно, лишний downtime);
|
||||
- `playbook-gitea.yml` — рестарта нет вовсе (несогласованность).
|
||||
|
||||
Канонический паттерн: шаблон конфига `notify`-ит handler, который рестартит только при
|
||||
реальном изменении.
|
||||
|
||||
```yaml
|
||||
- name: "Copy docker compose file"
|
||||
ansible.builtin.template: { ... }
|
||||
notify: Restart app
|
||||
|
||||
handlers:
|
||||
- name: Restart app
|
||||
community.docker.docker_compose_v2:
|
||||
project_src: "{{ base_dir }}"
|
||||
state: restarted
|
||||
```
|
||||
|
||||
Связанное: в `playbook-memos.yml:76` результат шаблона регистрируется в
|
||||
`docker_compose_file_result`, но нигде не используется — задумывалось под `when`/`notify`,
|
||||
не доведено.
|
||||
|
||||
Внедряется инкрементально, по одному сервису.
|
||||
|
||||
## 4. Идемпотентность и `changed_when`
|
||||
|
||||
- **`playbook-netdata.yml:118-125`** — `changed_when: netdata_docker_group_output.rc != 0`
|
||||
для read-only запроса лишено смысла (помечает «changed» только при ошибке). Должно быть
|
||||
`changed_when: false`. Лучше заменить `shell: grep docker /etc/group | cut ...` на модуль:
|
||||
|
||||
```yaml
|
||||
- ansible.builtin.getent:
|
||||
database: group
|
||||
key: docker
|
||||
# далее: getent_group['docker'][1]
|
||||
```
|
||||
|
||||
Уйдёт и `set -o pipefail`, и хрупкий парсинг.
|
||||
|
||||
- **`playbook-eget.yml:23-78`** — восемь `command` помечены `changed_when: false`, хотя
|
||||
реально ставят/обновляют бинарники. Прогон всегда рапортует «ok» — теряется честность
|
||||
`--diff`. Сама роль `eget` делает корректную проверку версии; те же инсталляции через
|
||||
неё или через проверку версии были бы идемпотентны по-настоящему.
|
||||
|
||||
- **`playbook-memos.yml:57-67`** (и аналоги) — сборка `backup-targets` через `lineinfile`
|
||||
в цикле не удаляет устаревшие строки при изменении списка, а `mode: "0750"` на
|
||||
файле-списке выглядит как copy-paste. Чище — `template`/`copy: content` со всем списком.
|
||||
|
||||
## 5. Роль `owner` — несогласованность с ролью `eget`
|
||||
|
||||
- **`roles/owner/tasks/main.yml:2-10`** — валидация аргументов через `fail` + `when`,
|
||||
причём две задачи с **идентичным именем**. Роль `eget` для того же делает `assert`
|
||||
(`roles/eget/tasks/main.yml:15`). Привести к одному стилю — `assert` либо современный
|
||||
`meta/argument_specs.yml` (декларативная валидация).
|
||||
- **`roles/owner/tasks/main.yml:32,53`** — `with_items`/`with_dict` устарели; конвенция —
|
||||
`loop`: `loop: "{{ owner_ssh_keys }}"`, `loop: "{{ owner_env_dict | dict2items }}"`.
|
||||
- У `owner` нет `meta/main.yml` и README, тогда как у `eget` и `secrets` они есть.
|
||||
- Имена задач в `owner` с точкой на конце (`"Prepare env variables."`), в остальных без —
|
||||
ansible-lint в строгом профиле это ловит.
|
||||
|
||||
## 6. Инвентарь и `become`
|
||||
|
||||
- **`production.yml` и `timeweb.yml`** оба объявляют хост с именем `server` под ключом
|
||||
`ungrouped:`. Хост-специфичные данные (`application_dir`, `mount_external_storage`,
|
||||
`ansible_host`, `ansible_user`) вписаны инлайн. Конвенциональнее — `host_vars/server.yml`,
|
||||
хосты в именованной группе. Два инвентаря с одинаковым именем хоста + `hosts: all` =
|
||||
ошибка `-i` молча уедет не туда.
|
||||
- `ansible_become: true` глобально в инвентаре — всё бежит под root. Для личного сервера
|
||||
прагматично; точечный `become`/`become_user` ближе к наименьшим привилегиям. Низкий приоритет.
|
||||
|
||||
## 7. Конкретный баг
|
||||
|
||||
- **`playbook-wanderer.yml:2`** — `name: "Configure gramps application"`, хотя
|
||||
`app_name: "wanderer"`. Копипаст из gramps, поправить имя play.
|
||||
|
||||
## 8. Мелочи стиля и конфигурации
|
||||
|
||||
- **sudoers**: `playbook-backups.yml:52-59` правит `/etc/sudoers` через `lineinfile`.
|
||||
Конвенция — отдельный файл в `/etc/sudoers.d/` (через `copy`/`template` с
|
||||
`validate: visudo -cf %s`), а не модификация центрального файла.
|
||||
- **`.ansible-lint.yml`** содержит только `exclude_paths` — профиль не задан явно.
|
||||
AGENTS.md утверждает «профиль production»; либо прописать `profile: production`, либо
|
||||
поправить документацию.
|
||||
- **`ansible.cfg`** минимален. Стоит добавить `stdout_callback = yaml`,
|
||||
`interpreter_python = auto_silent`, `force_handlers = true`.
|
||||
- Несогласованные кавычки и пути: `'directory'` vs `"directory"`, `src: "./files/..."` vs
|
||||
`src: "files/..."`, одинарные кавычки в `playbook-all-setup.yml` против двойных в остальных.
|
||||
- `playbook-system.yml:24` — `apt` без `cache_valid_time`, обновляет кэш каждый прогон.
|
||||
|
||||
## Приоритеты
|
||||
|
||||
1. **#3 handlers** — убирает безусловный рестарт; внедряется по одному сервису.
|
||||
2. **#1 роль `backup`** — самый чистый шов для extraction; обкатать на одном сервисе.
|
||||
3. **#4, #7** — быстрые точечные фиксы без структурных изменений.
|
||||
4. **#2 group_vars** — убирает boilerplate; низкий риск.
|
||||
5. **#5, #6, #8** — фоновая зачистка стиля и структуры.
|
||||
@@ -1,10 +1,9 @@
|
||||
services:
|
||||
|
||||
authelia_app:
|
||||
container_name: 'authelia_app'
|
||||
image: 'docker.io/authelia/authelia:4.39.19'
|
||||
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
||||
restart: 'unless-stopped'
|
||||
container_name: "authelia_app"
|
||||
image: "docker.io/authelia/authelia:4.39.20"
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
restart: "unless-stopped"
|
||||
networks:
|
||||
- "web_proxy_network"
|
||||
- "monitoring_network"
|
||||
|
||||
+288
-70
@@ -3,21 +3,33 @@
|
||||
Backup script for all applications
|
||||
Automatically discovers and runs backup scripts for all users,
|
||||
then creates restic backups and sends notifications.
|
||||
|
||||
restic-операции разнесены на фазы с разной частотой (см. секцию [schedule] в config):
|
||||
- backup, forget -- каждый прогон (forget БЕЗ --prune: только метаданные снапшотов);
|
||||
- check -- структурная проверка, обычно еженедельно;
|
||||
- prune -- репак/освобождение места, редко (квартально);
|
||||
- verify -- check --read-data-subset, помесячно (полное покрытие за год).
|
||||
Один прогон выполняет фазы строго последовательно, поэтому restic-локи между фазами
|
||||
не конфликтуют. Наложение соседних прогонов предотвращается flock в cron-задаче.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import logging
|
||||
import os
|
||||
import pwd
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import requests
|
||||
import tomllib
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from croniter import croniter
|
||||
|
||||
# Default config path
|
||||
CONFIG_PATH = Path("/etc/backup/config.toml")
|
||||
@@ -29,6 +41,22 @@ BACKUP_TARGETS_FILE = "backup-targets"
|
||||
# Used when backup-targets file not exists
|
||||
BACKUP_DEFAULT_DIR = "backups"
|
||||
|
||||
# Retention policy applied by the `forget` phase on every run.
|
||||
KEEP_DAILY = "90"
|
||||
KEEP_MONTHLY = "36"
|
||||
|
||||
# Фазы в порядке выполнения. backup и forget идут каждый прогон,
|
||||
# остальные — по расписанию из config.
|
||||
PHASE_BACKUP = "backup"
|
||||
PHASE_FORGET = "forget"
|
||||
PHASE_CHECK = "check"
|
||||
PHASE_PRUNE = "prune"
|
||||
PHASE_VERIFY = "verify"
|
||||
|
||||
ALWAYS_PHASES = [PHASE_BACKUP, PHASE_FORGET]
|
||||
SCHEDULED_PHASES = [PHASE_CHECK, PHASE_PRUNE, PHASE_VERIFY]
|
||||
PHASE_ORDER = ALWAYS_PHASES + SCHEDULED_PHASES
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -46,6 +74,42 @@ class Config:
|
||||
host_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintenanceOptions:
|
||||
"""Параметры обслуживающих фаз (см. секцию [maintenance] в config)."""
|
||||
|
||||
verify_subset: str = "1/12"
|
||||
prune_max_unused: str = "20%"
|
||||
prune_max_repack: str = "5G"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Schedule:
|
||||
"""Расписание обслуживающих фаз: фаза -> cron-выражение."""
|
||||
|
||||
cron: Dict[str, str] = field(default_factory=dict)
|
||||
|
||||
def due_phases(self, now: datetime) -> List[str]:
|
||||
"""Фазы, которые нужно выполнить в этот прогон, в порядке PHASE_ORDER."""
|
||||
phases = list(ALWAYS_PHASES)
|
||||
for phase in SCHEDULED_PHASES:
|
||||
expr = self.cron.get(phase)
|
||||
if expr and self._due_today(expr, now):
|
||||
phases.append(phase)
|
||||
return phases
|
||||
|
||||
@staticmethod
|
||||
def _due_today(expr: str, now: datetime) -> bool:
|
||||
"""True, если cron-выражение срабатывает где-то в течение сегодняшних суток.
|
||||
|
||||
Мы не сравниваем с текущей минутой (триггер один на сутки в фиксированное
|
||||
время), а проверяем, попадает ли ближайшее срабатывание выражения на сегодня.
|
||||
"""
|
||||
start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
nxt = croniter(expr, start - timedelta(minutes=1)).get_next(datetime)
|
||||
return nxt.date() == now.date()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Application:
|
||||
path: Path
|
||||
@@ -54,11 +118,18 @@ class Application:
|
||||
backup_targets: List[Path]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BackupResult:
|
||||
success: bool
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageRunResult:
|
||||
name: str
|
||||
success: bool
|
||||
duration: float
|
||||
phases: List[str]
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
@@ -76,8 +147,13 @@ def format_duration(seconds: float) -> str:
|
||||
class Storage(ABC):
|
||||
name: str
|
||||
|
||||
def backup(self, backup_dirs: List[str]) -> bool:
|
||||
"""Backup directories"""
|
||||
def run(
|
||||
self,
|
||||
backup_dirs: List[str],
|
||||
phases: List[str],
|
||||
maintenance: MaintenanceOptions,
|
||||
) -> BackupResult:
|
||||
"""Run the requested phases against this storage."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@@ -101,69 +177,125 @@ class ResticStorage(Storage):
|
||||
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
||||
)
|
||||
|
||||
def backup(self, backup_dirs: List[str]) -> bool:
|
||||
if not backup_dirs:
|
||||
logger.warning("No backup directories found")
|
||||
return True
|
||||
def run(
|
||||
self,
|
||||
backup_dirs: List[str],
|
||||
phases: List[str],
|
||||
maintenance: MaintenanceOptions,
|
||||
) -> BackupResult:
|
||||
try:
|
||||
return self.__backup_internal(backup_dirs)
|
||||
return self.__run_internal(backup_dirs, phases, maintenance)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Restic backup process failed: %s", exc)
|
||||
return False
|
||||
logger.error("Restic process failed: %s", exc)
|
||||
return BackupResult(success=False, error=str(exc))
|
||||
|
||||
def __backup_internal(self, backup_dirs: List[str]) -> bool:
|
||||
logger.info("Starting restic backup for storage '%s'", self.name)
|
||||
def __build_steps(
|
||||
self,
|
||||
backup_dirs: List[str],
|
||||
phases: List[str],
|
||||
maintenance: MaintenanceOptions,
|
||||
) -> List[tuple[str, List[str]]]:
|
||||
"""Собрать restic-команды для запрошенных фаз в порядке PHASE_ORDER."""
|
||||
steps: List[tuple[str, List[str]]] = []
|
||||
|
||||
for phase in PHASE_ORDER:
|
||||
if phase not in phases:
|
||||
continue
|
||||
|
||||
if phase == PHASE_BACKUP:
|
||||
if not backup_dirs:
|
||||
logger.warning(
|
||||
"No backup directories found, skipping backup phase for '%s'",
|
||||
self.name,
|
||||
)
|
||||
continue
|
||||
steps.append(
|
||||
("backup", ["restic", "backup", "--verbose"] + backup_dirs)
|
||||
)
|
||||
elif phase == PHASE_FORGET:
|
||||
# forget БЕЗ --prune: удаляет только метаданные снапшотов, не репакует
|
||||
# data-паки и не сбивает охлаждение в Intelligent Tiering.
|
||||
steps.append(
|
||||
(
|
||||
"forget",
|
||||
[
|
||||
"restic",
|
||||
"forget",
|
||||
"--compact",
|
||||
"--keep-daily",
|
||||
KEEP_DAILY,
|
||||
"--keep-monthly",
|
||||
KEEP_MONTHLY,
|
||||
],
|
||||
)
|
||||
)
|
||||
elif phase == PHASE_CHECK:
|
||||
steps.append(("check", ["restic", "check"]))
|
||||
elif phase == PHASE_PRUNE:
|
||||
steps.append(
|
||||
(
|
||||
"prune",
|
||||
[
|
||||
"restic",
|
||||
"prune",
|
||||
"--max-unused",
|
||||
maintenance.prune_max_unused,
|
||||
"--max-repack-size",
|
||||
maintenance.prune_max_repack,
|
||||
],
|
||||
)
|
||||
)
|
||||
elif phase == PHASE_VERIFY:
|
||||
steps.append(
|
||||
(
|
||||
"verify",
|
||||
[
|
||||
"restic",
|
||||
"check",
|
||||
f"--read-data-subset={maintenance.verify_subset}",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
return steps
|
||||
|
||||
def __run_internal(
|
||||
self,
|
||||
backup_dirs: List[str],
|
||||
phases: List[str],
|
||||
maintenance: MaintenanceOptions,
|
||||
) -> BackupResult:
|
||||
logger.info("Starting restic run for storage '%s'", self.name)
|
||||
logger.info("Destination: %s", self.restic_repository)
|
||||
logger.info("Phases: %s", ", ".join(phases))
|
||||
|
||||
env = os.environ.copy()
|
||||
env["RESTIC_REPOSITORY"] = self.restic_repository
|
||||
env["RESTIC_PASSWORD"] = self.restic_password
|
||||
env.update(self.env)
|
||||
|
||||
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
|
||||
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
|
||||
steps = self.__build_steps(backup_dirs, phases, maintenance)
|
||||
|
||||
for step, cmd in steps:
|
||||
error = self.__run_step(step, cmd, env)
|
||||
if error is not None:
|
||||
return BackupResult(success=False, error=f"restic {step}: {error}")
|
||||
|
||||
return BackupResult(success=True)
|
||||
|
||||
def __run_step(
|
||||
self, step: str, cmd: List[str], env: Dict[str, str]
|
||||
) -> Optional[str]:
|
||||
"""Run a single restic command. Return None on success or error text."""
|
||||
result = subprocess.run(cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Restic backup failed: %s", result.stderr)
|
||||
return False
|
||||
error = result.stderr.strip() or result.stdout.strip() or "no output"
|
||||
logger.error("Restic %s failed: %s", step, error)
|
||||
return error
|
||||
|
||||
logger.info("Restic backup completed successfully")
|
||||
|
||||
check_cmd = ["restic", "check"]
|
||||
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Restic check failed: %s", result.stderr)
|
||||
return False
|
||||
|
||||
logger.info("Restic check completed successfully")
|
||||
|
||||
forget_cmd = [
|
||||
"restic",
|
||||
"forget",
|
||||
"--compact",
|
||||
"--prune",
|
||||
"--keep-daily",
|
||||
"90",
|
||||
"--keep-monthly",
|
||||
"36",
|
||||
]
|
||||
result = subprocess.run(forget_cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Restic forget/prune failed: %s", result.stderr)
|
||||
return False
|
||||
|
||||
logger.info("Restic forget/prune completed successfully")
|
||||
|
||||
result = subprocess.run(check_cmd, env=env, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Final restic check failed: %s", result.stderr)
|
||||
return False
|
||||
|
||||
logger.info("Final restic check completed successfully")
|
||||
return True
|
||||
logger.info("Restic %s completed successfully", step)
|
||||
return None
|
||||
|
||||
|
||||
class Notifier(ABC):
|
||||
@@ -300,6 +432,9 @@ class BackupManager:
|
||||
config: Config,
|
||||
storages: List[Storage],
|
||||
notifiers: List[Notifier],
|
||||
schedule: Schedule,
|
||||
maintenance: MaintenanceOptions,
|
||||
forced_phases: Optional[List[str]] = None,
|
||||
):
|
||||
self.errors: List[str] = []
|
||||
self.warnings: List[str] = []
|
||||
@@ -307,6 +442,10 @@ class BackupManager:
|
||||
self.config = config
|
||||
self.storages = storages
|
||||
self.notifiers = notifiers
|
||||
self.schedule = schedule
|
||||
self.maintenance = maintenance
|
||||
self.forced_phases = forced_phases
|
||||
self.active_phases: List[str] = []
|
||||
self.archive_duration: float = 0.0
|
||||
self.storage_results: List[StorageRunResult] = []
|
||||
|
||||
@@ -315,8 +454,17 @@ class BackupManager:
|
||||
logger.info("Starting backup process")
|
||||
logger.info(f"Found {len(applications)} application directories")
|
||||
|
||||
# Какие фазы выполняем в этот прогон: либо принудительно из CLI, либо по расписанию.
|
||||
if self.forced_phases is not None:
|
||||
self.active_phases = self.forced_phases
|
||||
logger.info("Phases (forced): %s", ", ".join(self.active_phases))
|
||||
else:
|
||||
self.active_phases = self.schedule.due_phases(datetime.now())
|
||||
logger.info("Phases (scheduled): %s", ", ".join(self.active_phases))
|
||||
|
||||
archive_start = time.monotonic()
|
||||
# Process each user's backup
|
||||
# Archive phase (per-app backup scripts) нужна только если будем делать restic backup.
|
||||
if PHASE_BACKUP in self.active_phases:
|
||||
for app in applications:
|
||||
app_dir = str(app.path)
|
||||
username = app.owner
|
||||
@@ -331,6 +479,8 @@ class BackupManager:
|
||||
continue
|
||||
|
||||
self._run_app_backup(str(app.backup_script), app_dir, username)
|
||||
else:
|
||||
logger.info("Backup phase not active, skipping per-app archive scripts")
|
||||
self.archive_duration = time.monotonic() - archive_start
|
||||
logger.info(
|
||||
"Archive phase finished in %s", format_duration(self.archive_duration)
|
||||
@@ -352,31 +502,37 @@ class BackupManager:
|
||||
for storage in self.storages:
|
||||
storage_start = time.monotonic()
|
||||
try:
|
||||
backup_result = storage.backup(backup_dirs)
|
||||
backup_result = storage.run(
|
||||
backup_dirs, self.active_phases, self.maintenance
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error(
|
||||
"Storage '%s' raised an unexpected error: %s", storage.name, exc
|
||||
)
|
||||
backup_result = False
|
||||
backup_result = BackupResult(success=False, error=str(exc))
|
||||
storage_duration = time.monotonic() - storage_start
|
||||
self.storage_results.append(
|
||||
StorageRunResult(
|
||||
name=storage.name,
|
||||
success=backup_result,
|
||||
success=backup_result.success,
|
||||
duration=storage_duration,
|
||||
phases=list(self.active_phases),
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"Storage '%s' finished in %s (success=%s)",
|
||||
storage.name,
|
||||
format_duration(storage_duration),
|
||||
backup_result,
|
||||
backup_result.success,
|
||||
)
|
||||
if not backup_result:
|
||||
self.errors.append(f"Storage '{storage.name}' backup failed")
|
||||
if not backup_result.success:
|
||||
error_msg = f"Storage '{storage.name}' backup failed"
|
||||
if backup_result.error:
|
||||
error_msg += f": {backup_result.error}"
|
||||
self.errors.append(error_msg)
|
||||
|
||||
# Determine overall success
|
||||
overall_success = overall_success and backup_result
|
||||
overall_success = overall_success and backup_result.success
|
||||
|
||||
# Send notification
|
||||
self._send_notification(overall_success)
|
||||
@@ -436,6 +592,7 @@ class BackupManager:
|
||||
"""Send notification to Notifiers"""
|
||||
|
||||
host = self.config.host_name
|
||||
phases_text = ", ".join(self.active_phases) if self.active_phases else "—"
|
||||
|
||||
if success and not self.errors:
|
||||
title = f"{host}: бекап успешно завершен"
|
||||
@@ -459,6 +616,7 @@ class BackupManager:
|
||||
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
||||
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
||||
|
||||
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
|
||||
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
|
||||
if self.storage_results:
|
||||
items = "".join(
|
||||
@@ -474,8 +632,21 @@ class BackupManager:
|
||||
logger.error(f"Failed to send notification: {str(e)}")
|
||||
|
||||
|
||||
def parse_phases(raw: str) -> List[str]:
|
||||
"""Разобрать CLI-список фаз, вернуть их в порядке PHASE_ORDER."""
|
||||
requested = {p.strip() for p in raw.split(",") if p.strip()}
|
||||
unknown = requested - set(PHASE_ORDER)
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"Unknown phases: {', '.join(sorted(unknown))}. "
|
||||
f"Allowed: {', '.join(PHASE_ORDER)}"
|
||||
)
|
||||
return [p for p in PHASE_ORDER if p in requested]
|
||||
|
||||
|
||||
def initialize(
|
||||
config_path: Path,
|
||||
forced_phases: Optional[List[str]] = None,
|
||||
) -> tuple[ApplicationFinder, BackupManager]:
|
||||
try:
|
||||
with config_path.open("rb") as config_file:
|
||||
@@ -513,18 +684,65 @@ def initialize(
|
||||
if not notifiers:
|
||||
raise ValueError("At least one notification backend must be configured")
|
||||
|
||||
schedule_raw = raw_config.get("schedule") or {}
|
||||
if not isinstance(schedule_raw, dict):
|
||||
raise ValueError("'schedule' must be a table in config.toml")
|
||||
schedule = Schedule(
|
||||
cron={
|
||||
phase: str(schedule_raw[phase])
|
||||
for phase in SCHEDULED_PHASES
|
||||
if phase in schedule_raw
|
||||
}
|
||||
)
|
||||
|
||||
maintenance_raw = raw_config.get("maintenance") or {}
|
||||
if not isinstance(maintenance_raw, dict):
|
||||
raise ValueError("'maintenance' must be a table in config.toml")
|
||||
defaults = MaintenanceOptions()
|
||||
maintenance = MaintenanceOptions(
|
||||
verify_subset=str(maintenance_raw.get("verify_subset", defaults.verify_subset)),
|
||||
prune_max_unused=str(
|
||||
maintenance_raw.get("prune_max_unused", defaults.prune_max_unused)
|
||||
),
|
||||
prune_max_repack=str(
|
||||
maintenance_raw.get("prune_max_repack", defaults.prune_max_repack)
|
||||
),
|
||||
)
|
||||
|
||||
config = Config(host_name=host_name)
|
||||
app_finder = ApplicationFinder(roots)
|
||||
backup_manager = BackupManager(
|
||||
config=config, storages=storages, notifiers=notifiers
|
||||
config=config,
|
||||
storages=storages,
|
||||
notifiers=notifiers,
|
||||
schedule=schedule,
|
||||
maintenance=maintenance,
|
||||
forced_phases=forced_phases,
|
||||
)
|
||||
|
||||
return app_finder, backup_manager
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Run application backups via restic")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
type=Path,
|
||||
default=CONFIG_PATH,
|
||||
help=f"Path to config.toml (default: {CONFIG_PATH})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--phases",
|
||||
help=(
|
||||
"Comma-separated phases to run, overriding the schedule "
|
||||
f"(allowed: {', '.join(PHASE_ORDER)}). Useful for manual maintenance runs."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
app_finder, backup_manager = initialize(CONFIG_PATH)
|
||||
forced_phases = parse_phases(args.phases) if args.phases else None
|
||||
app_finder, backup_manager = initialize(args.config, forced_phases)
|
||||
applications = app_finder.find_applications()
|
||||
backup_manager.warnings.extend(app_finder.warnings)
|
||||
success = backup_manager.run_backup_process(applications)
|
||||
|
||||
@@ -4,6 +4,25 @@ roots = [
|
||||
"{{ application_dir }}"
|
||||
]
|
||||
|
||||
# Расписание обслуживающих фаз restic.
|
||||
# Триггер — один ночной запуск (cron в playbook-backups.yml), поэтому в выражениях
|
||||
# значимы ТОЛЬКО поля дня месяца / месяца / дня недели. Минуты и часы держим как
|
||||
# "* *" — они декоративны: фаза всё равно выполняется в момент ночного прогона,
|
||||
# а не во время, указанное в выражении.
|
||||
# День недели по cron-конвенции: 0 = воскресенье.
|
||||
# backup и forget выполняются КАЖДЫЙ прогон и в расписании не участвуют.
|
||||
[schedule]
|
||||
check = "* * * * 0" # структурный restic check — по воскресеньям
|
||||
verify = "* * 5 * *" # check --read-data-subset — 5-го числа
|
||||
prune = "* * 1 1,4,7,10 *" # restic prune — 1-го числа янв/апр/июл/окт
|
||||
|
||||
# Параметры обслуживания. Подобраны под Yandex Object Storage Intelligent Tiering:
|
||||
# редкий prune с высоким --max-unused минимизирует репак и не сбивает охлаждение объектов.
|
||||
[maintenance]
|
||||
verify_subset = "1/12" # порция данных за один verify -> полное покрытие за год
|
||||
prune_max_unused = "20%" # выше дефолтных 5% -> меньше репака -> дольше держится охлаждение
|
||||
prune_max_repack = "5G" # потолок объёма перезаписи за один prune
|
||||
|
||||
[storage.yandex_cloud_s3]
|
||||
type = "restic"
|
||||
restic_repository = "{{ restic_repository }}"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
|
||||
caddyproxy:
|
||||
image: caddy:2.11.2
|
||||
image: caddy:2.11.3
|
||||
restart: unless-stopped
|
||||
container_name: "caddyproxy"
|
||||
ports:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
|
||||
dozzle_app:
|
||||
image: amir20/dozzle:v10.6.0
|
||||
image: amir20/dozzle:v10.6.2
|
||||
container_name: dozzle_app
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
|
||||
gitea_app:
|
||||
image: gitea/gitea:1.26.1
|
||||
image: gitea/gitea:1.26.4
|
||||
restart: unless-stopped
|
||||
container_name: gitea_app
|
||||
ports:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
services:
|
||||
|
||||
goaccess_processor:
|
||||
build: .
|
||||
image: local/goaccess-jq:1.10.2
|
||||
@@ -26,7 +25,7 @@ services:
|
||||
- "web_proxy_network"
|
||||
|
||||
goaccess_app:
|
||||
image: caddy:2.11.2
|
||||
image: caddy:2.11.3
|
||||
container_name: goaccess_app
|
||||
restart: unless-stopped
|
||||
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
|
||||
|
||||
services:
|
||||
|
||||
gramps_app: &gramps_app
|
||||
image: ghcr.io/gramps-project/grampsweb:26.5.1
|
||||
image: ghcr.io/gramps-project/grampsweb:26.6.0
|
||||
container_name: gramps_app
|
||||
depends_on:
|
||||
- gramps_redis
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
services:
|
||||
|
||||
memos_app:
|
||||
image: neosmemo/memos:0.28.0
|
||||
image: neosmemo/memos:0.29.1
|
||||
container_name: memos_app
|
||||
restart: unless-stopped
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
# See sample https://github.com/outline/outline/blob/main/.env.sample
|
||||
|
||||
outline_app:
|
||||
image: outlinewiki/outline:1.7.1
|
||||
image: outlinewiki/outline:1.8.1
|
||||
container_name: outline_app
|
||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
services:
|
||||
|
||||
wakapi_app:
|
||||
image: ghcr.io/muety/wakapi:2.17.3
|
||||
image: ghcr.io/muety/wakapi:2.17.4
|
||||
container_name: wakapi_app
|
||||
restart: unless-stopped
|
||||
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
||||
|
||||
@@ -94,6 +94,8 @@
|
||||
name: "restic backup"
|
||||
minute: "0"
|
||||
hour: "1"
|
||||
job: "{{ backup_all_script }} 2>&1 | logger -t backup"
|
||||
# flock -n: не запускать новый прогон, если предыдущий (например, затянувшийся
|
||||
# квартальный prune) ещё идёт — защита от наложения restic-операций.
|
||||
job: "flock -n /var/lock/backup-all.lock {{ backup_all_script }} 2>&1 | logger -t backup"
|
||||
cron_file: "ansible_restic_backup"
|
||||
user: "root"
|
||||
|
||||
@@ -15,7 +15,9 @@
|
||||
- htop
|
||||
- jq
|
||||
- make
|
||||
- python3-croniter
|
||||
- python3-pip
|
||||
- python3-requests
|
||||
- sqlite3
|
||||
- tree
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"ansible>=13.2.0",
|
||||
"ansible-lint>=25.12.2",
|
||||
"croniter>=6.0.0",
|
||||
"invoke>=2.2.1",
|
||||
"mypy>=1.19.1",
|
||||
"requests>=2.32.5",
|
||||
"ruff>=0.15.2",
|
||||
"types-croniter>=6.0.0",
|
||||
"types-requests>=2.32.4.20260107",
|
||||
"yamllint>=1.37.1",
|
||||
]
|
||||
|
||||
@@ -272,6 +272,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
version = "6.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.3"
|
||||
@@ -593,10 +605,12 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "ansible" },
|
||||
{ name = "ansible-lint" },
|
||||
{ name = "croniter" },
|
||||
{ name = "invoke" },
|
||||
{ name = "mypy" },
|
||||
{ name = "requests" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-croniter" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "yamllint" },
|
||||
]
|
||||
@@ -605,10 +619,12 @@ dependencies = [
|
||||
requires-dist = [
|
||||
{ name = "ansible", specifier = ">=13.2.0" },
|
||||
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
||||
{ name = "croniter", specifier = ">=6.0.0" },
|
||||
{ name = "invoke", specifier = ">=2.2.1" },
|
||||
{ name = "mypy", specifier = ">=1.19.1" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "ruff", specifier = ">=0.15.2" },
|
||||
{ name = "types-croniter", specifier = ">=6.0.0" },
|
||||
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
|
||||
{ name = "yamllint", specifier = ">=1.37.1" },
|
||||
]
|
||||
@@ -631,6 +647,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dateutil"
|
||||
version = "2.9.0.post0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytokens"
|
||||
version = "0.3.0"
|
||||
@@ -886,6 +914,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subprocess-tee"
|
||||
version = "0.4.2"
|
||||
@@ -895,6 +932,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/ab/e3a3be062cd544b2803760ff707dee38f0b1cb5685b2446de0ec19be28d9/subprocess_tee-0.4.2-py3-none-any.whl", hash = "sha256:21942e976715af4a19a526918adb03a8a27a8edab959f2d075b777e3d78f532d", size = 5249, upload-time = "2024-06-17T19:51:15.949Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-croniter"
|
||||
version = "6.2.2.20260518"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/02/f03a44ded34e6abb125c339647b070f2705a0583782f5638d62ab958cdc2/types_croniter-6.2.2.20260518.tar.gz", hash = "sha256:aceb426b9187bb9255b89d17713d07ac034a2b96b437bfdd5d3a56b46b4eb656", size = 12120, upload-time = "2026-05-18T06:03:11.885Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/37/3d/f12c417944d00c42db71de3e334f36a69cafa6767ff3fb705c9e1d101e53/types_croniter-6.2.2.20260518-py3-none-any.whl", hash = "sha256:85018c7ce091428d3643be239ad348e27f9a8fb77ca94335cc39ebeb9403b240", size = 9743, upload-time = "2026-05-18T06:03:10.608Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-requests"
|
||||
version = "2.32.4.20260107"
|
||||
|
||||
Reference in New Issue
Block a user