Compare commits
25 Commits
a3e53b21e6
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
2930842e3f
|
|||
|
0f80e66b66
|
|||
|
2b22fde718
|
|||
|
c39de421e0
|
|||
|
a50b399a85
|
|||
|
94b09be53c
|
|||
|
b637fea882
|
|||
|
933a0b9570
|
|||
|
96710360d9
|
|||
|
d9f0d94e1f
|
|||
|
9b853d351c
|
|||
|
11744f776a
|
|||
|
0df5f358d0
|
|||
|
62e2a72e52
|
|||
|
7c91f4f355
|
|||
|
68d8bf6a68
|
|||
|
e585bfdca2
|
|||
|
41822e04e8
|
|||
|
21ccc7ac8c
|
|||
|
81478c2323
|
|||
|
313b1820be
|
|||
|
2f2c1b0754
|
|||
|
e45e1db002
|
|||
|
dc49b3497b
|
|||
|
02ea9a3735
|
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: adr
|
||||||
|
description: >-
|
||||||
|
Создаёт и сопровождает Architecture Decision Records (ADR) в docs/adr/.
|
||||||
|
Используй, когда уже сделанное архитектурное или инфраструктурное
|
||||||
|
изменение нужно зафиксировать постфактум: выбор инструмента/подхода,
|
||||||
|
структурное решение, намеренный отказ от очевидного варианта, либо
|
||||||
|
прямая просьба «записать решение / завести ADR». А также когда старую
|
||||||
|
ADR нужно пометить заменённой или устаревшей. ADR пишут ПОСТФАКТУМ;
|
||||||
|
идеи, планы и обсуждения — это drafts, а не ADR.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Работа с ADR
|
||||||
|
|
||||||
|
ADR живут в `docs/adr/`. Формат и соглашения — `docs/adr/README.md`,
|
||||||
|
шаблон — `docs/adr/template.md`. Читай README перед первой записью в
|
||||||
|
сессии: правила там — источник истины, эта инструкция лишь даёт порядок
|
||||||
|
действий.
|
||||||
|
|
||||||
|
Язык записей — **русский**, стиль — как в `docs/drafts/`: конкретно,
|
||||||
|
по делу, без воды. Калибровка под личный хобби-сервер: не раздувай
|
||||||
|
запись, не предлагай корпоративные процессы.
|
||||||
|
|
||||||
|
## Сначала реши, нужен ли ADR
|
||||||
|
|
||||||
|
Заводи, если изменение **уже сделано** и это: выбор технологии/
|
||||||
|
инструмента, структурное решение, решение с долгосрочными последствиями
|
||||||
|
или дорогим откатом, либо намеренный отказ от очевидного варианта.
|
||||||
|
|
||||||
|
Не заводи:
|
||||||
|
- для рутины (бамп версии образа, сервис по накатанной схеме) и того,
|
||||||
|
что видно из кода/git;
|
||||||
|
- для идей, планов и того, что ещё не реализовано — это `docs/drafts/`,
|
||||||
|
а не ADR.
|
||||||
|
|
||||||
|
Если сомневаешься — спроси пользователя, не плоди записи молча.
|
||||||
|
|
||||||
|
## Создание новой ADR
|
||||||
|
|
||||||
|
1. **Дата.** Когда изменение реально сделано. Обычно сегодня
|
||||||
|
(`date +%F`). Если оформляем задним числом — уточни дату у
|
||||||
|
пользователя, не подставляй сегодняшнюю вслепую.
|
||||||
|
2. **Идентификатор и имя файла:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`
|
||||||
|
(slug — латиницей). Если за эту дату уже есть ADR — slug просто
|
||||||
|
должен отличаться; проверь `ls docs/adr/`.
|
||||||
|
3. **Файл.** Возьми за основу `docs/adr/template.md`.
|
||||||
|
4. **Заполни** по шаблону:
|
||||||
|
- Заголовок `# Человеческий заголовок` (без даты/ID в тексте).
|
||||||
|
- Метаданные: только `- Дата: ГГГГ-ММ-ДД`. **Строку статуса не
|
||||||
|
добавляй** — у активной записи статуса нет.
|
||||||
|
- **Контекст** — какая проблема и ограничения вынудили это делать.
|
||||||
|
- **Рассмотренные варианты** — *опциональная* секция. **Спроси
|
||||||
|
пользователя, рассматривал ли он какие-то альтернативы.** Если да —
|
||||||
|
перечисли их с плюсами/минусами (особенно те, что отвергнуты); если
|
||||||
|
нет (решение было единственным очевидным) — удали секцию целиком.
|
||||||
|
- **Решение** — что сделано и, **главное, почему**: какое намерение и
|
||||||
|
причина за этим стоят.
|
||||||
|
- **Последствия** — `+`/`-` и что нужно сделать как следствие.
|
||||||
|
5. **Индекс.** Добавь строку в таблицу «Список записей» в
|
||||||
|
`docs/adr/README.md`, **сверху** (новые сверху). Статус — `—`.
|
||||||
|
|
||||||
|
Самое важное в ADR — сохранить **почему**: намерение и причинность, а не
|
||||||
|
просто «что сделали». Не придумывай причины и альтернативы за
|
||||||
|
пользователя — если их нет в контексте сессии, обязательно спроси: что
|
||||||
|
подтолкнуло к изменению и какие варианты рассматривались.
|
||||||
|
|
||||||
|
## Замена / устаревание решения
|
||||||
|
|
||||||
|
Старые ADR неизменяемы. Если решение пересмотрено:
|
||||||
|
|
||||||
|
1. Заведи новую ADR (шаги выше). В её «Контексте» — строка
|
||||||
|
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
|
||||||
|
2. В старой ADR добавь строку статуса сразу под датой:
|
||||||
|
`- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug` (или `- Статус: устарело`,
|
||||||
|
если замены нет). Тело не трогай.
|
||||||
|
3. Обнови статус старой записи в индексе `README.md`.
|
||||||
|
|
||||||
|
## После записи
|
||||||
|
|
||||||
|
Покажи пользователю путь к файлу и кратко содержание. Не коммить без
|
||||||
|
явной просьбы.
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
> В этом проекте не самые оптимальные решения.
|
> В этом проекте не самые оптимальные решения.
|
||||||
> Но они помогают мне поддерживать сервер для моих личных проектов уже много лет.
|
> Но они помогают мне поддерживать сервер для моих личных проектов уже много лет.
|
||||||
|
|
||||||
|
История и обоснования значимых решений — в [ADR-записях](docs/adr/)
|
||||||
|
(`docs/adr/`): *почему* приняты те или иные изменения, а не только что сделано.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- [uv](https://docs.astral.sh/uv/)
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
@@ -40,11 +43,17 @@ uv run ansible-galaxy install --role-file requirements.yml
|
|||||||
Деплой приложения через ansible:
|
Деплой приложения через ansible:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run ansible-playbook ansible-playbook -i production.yml --diff playbook-gitea.yml
|
uv run ansible-playbook ansible-playbook -i timeweb.yml --diff playbook-gitea.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через таску invoke:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./inv pl -- gitea
|
||||||
```
|
```
|
||||||
|
|
||||||
## Удаление приложения <name>
|
## Удаление приложения <name>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run ansible-playbook -i production.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
|
uv run ansible-playbook -i timeweb.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Вести историю решений в виде ADR
|
||||||
|
|
||||||
|
- Дата: 0000-00-00
|
||||||
|
|
||||||
|
> Основополагающая запись о самом процессе ADR. Дата-сентинел
|
||||||
|
> `0000-00-00` (фактически создана 2026-05-24) — исключение: так запись
|
||||||
|
> всегда остаётся в самом низу списка и не путается с реальными
|
||||||
|
> изменениями.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Сервер развивается итеративно: меняются прокси, схема бэкапов, набор
|
||||||
|
сервисов, провайдер хостинга. Решения принимаются по одному, часто с
|
||||||
|
неочевидными компромиссами под ресурсы сервера и стоимость. Через
|
||||||
|
несколько месяцев мотивация забывается, и возникает риск «переоткрыть»
|
||||||
|
уже отвергнутый вариант или сломать то, что было сделано осознанно.
|
||||||
|
|
||||||
|
Журналы в `docs/drafts/` фиксируют хронологию и черновики, но не
|
||||||
|
обоснование выбора — по ним не видно, какие альтернативы отвергнуты и
|
||||||
|
почему.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **ADR (отдельный файл на решение)** — стандартный формат, каждая
|
||||||
|
запись неизменяема, видно эволюцию через накопление и замену записей.
|
||||||
|
- **Один changelog-файл** — проще вести, но правки затирают историю
|
||||||
|
рассуждений, и формат расплывается со временем.
|
||||||
|
- **Ничего, держать в голове / в git-сообщениях** — нулевые затраты,
|
||||||
|
но обоснование теряется, а git-история не отвечает на вопрос «почему».
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Заводим каталог `docs/adr/` с записями в формате ADR (Nygard + блок
|
||||||
|
«Рассмотренные варианты»). Идентификатор записи — датовый,
|
||||||
|
`ADR-ГГГГ-ММ-ДД-slug`: дата (когда изменение реально сделано) сразу
|
||||||
|
видна в списке и позволяет оформлять записи задним числом. ADR пишем
|
||||||
|
постфактум, поэтому жизненный цикл сведён к двум статусам у потерявших
|
||||||
|
силу записей — `заменено на` и `устарело`; идеи и планы остаются в
|
||||||
|
`docs/drafts/`.
|
||||||
|
|
||||||
|
Формат и процесс описаны в [`README.md`](README.md), шаблон — в
|
||||||
|
[`template.md`](template.md). Для единообразия заполнение
|
||||||
|
автоматизировано скиллом `adr` (`.claude/skills/adr/`).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Сохраняется обоснование решений и отвергнутые альтернативы.
|
||||||
|
- `+` Датовый ID даёт хронологию «из коробки» и не мешает оформлять
|
||||||
|
записи задним числом.
|
||||||
|
- `+` Единый формат: записи делает человек или агент по одному шаблону.
|
||||||
|
- `-` Небольшая дисциплина: сделав значимое изменение, нужно не забыть
|
||||||
|
оформить ADR.
|
||||||
|
- Скилл `adr` берёт на себя имя файла, шаблон и обновление индекса в
|
||||||
|
`README.md`, снижая трение.
|
||||||
@@ -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)).
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Переезд сервера с Yandex Cloud на Timeweb VPS
|
||||||
|
|
||||||
|
- Дата: 2026-05-23
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
`rivendell-v2` жил на виртуальной машине в Yandex Cloud. Одновременно копились три
|
||||||
|
проблемы:
|
||||||
|
|
||||||
|
- **Цена.** ≈ 2 887 ₽/мес за конфигурацию, которую другие провайдеры
|
||||||
|
дают дешевле и мощнее.
|
||||||
|
- **Потолок RAM.** 4 ГБ, ≈ 80 % заняты на штатной нагрузке. Любой
|
||||||
|
всплеск (миграции БД, индексация Outline, restic) — конкуренция за
|
||||||
|
память и риск OOM. Расти на этом тарифе дальше — только заметно
|
||||||
|
дороже.
|
||||||
|
- **Медленный диск.** Чтобы сдержать цену в YC, использовался дешёвый
|
||||||
|
HDD вместо SSD/NVMe — страдала отзывчивость (Gitea, Outline, тёплый
|
||||||
|
старт контейнеров, restic check/forget).
|
||||||
|
|
||||||
|
Это личный сервер — допустимы небольшие простои.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **Остаться в YC, поднять тариф** (больше RAM/SSD на месте). Отвергнуто:
|
||||||
|
YC уже дороже альтернатив, апгрейд поднимает цену непропорционально
|
||||||
|
приросту — те же три проблемы решаются дороже, чем переездом.
|
||||||
|
- **Свой / домашний сервер** (железо под контролем, без ежемесячной
|
||||||
|
аренды). Отвергнуто: дома нет надёжного аптайма 24/7 (питание,
|
||||||
|
интернет-канал, железо), а сервисы должны быть всегда доступны.
|
||||||
|
- **Переезд на Timeweb Cloud VPS** — выбранный вариант.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Переносим на Timeweb Cloud VPS: ≈ 1 980 ₽/мес, 8 ГБ RAM (×2), 4 ядра
|
||||||
|
(×2, гарантия CPU 100 % вместо 50 %), 80 ГБ NVMe вместо 120 ГБ HDD.
|
||||||
|
Один переезд закрывает все три причины сразу.
|
||||||
|
|
||||||
|
Рамки решения:
|
||||||
|
|
||||||
|
- Переезжает **только сам сервер с приложениями** (compute). S3 (restic, бэкапы),
|
||||||
|
Container Registry, Postbox SMTP и DNS-зона `vakhrushev.me` остаются
|
||||||
|
в Yandex и используются с новой машины.
|
||||||
|
- Стратегия — **холодное переключение** (cold cutover): погасить сервисы
|
||||||
|
на источнике, раскатать
|
||||||
|
ansible на новом сервере без запуска приложений (сохраняя uid/gid), перенести
|
||||||
|
данные `rsync`'ом, запустить, переключить DNS.
|
||||||
|
- Диск: фаза 1 — один 80 ГБ NVMe (всего 22 ГБ данных + 17 ГБ системных, влезает с
|
||||||
|
запасом). «Холодный» второй диск под крупные данные — отдельная
|
||||||
|
фаза 2, не на критическом пути.
|
||||||
|
- Источник не удаляется сразу после переключения: держим «холодным
|
||||||
|
запасным»
|
||||||
|
пару недель ради отката.
|
||||||
|
|
||||||
|
Детальный план — [`../drafts/timeweb.md`](../drafts/timeweb.md),
|
||||||
|
фактическое выполнение —
|
||||||
|
[`../drafts/timeweb-migration-log.md`](../drafts/timeweb-migration-log.md).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` −907 ₽/мес (≈ −31 %) при вдвое большем RAM и CPU и NVMe-диске —
|
||||||
|
закрыты все три исходные проблемы.
|
||||||
|
- `+` Запас по RAM убирает OOM-риск при всплесках нагрузки.
|
||||||
|
- `+` Диверсификация по облакам: раньше сервер и данные были в одном
|
||||||
|
аккаунте Yandex Cloud, теперь сам сервер в Timeweb, а бэкапы (S3) — в
|
||||||
|
Yandex. Если заблокируют или потеряем доступ к одному провайдеру,
|
||||||
|
данные остаются доступны через другой.
|
||||||
|
- `-` Диск меньше (80 ГБ NVMe против 120 ГБ HDD), но сейчас занят
|
||||||
|
примерно наполовину — запас есть, фаза 2 с холодным диском не срочная.
|
||||||
|
- `-` Сохраняется зависимость от Yandex Cloud (S3, Container Registry,
|
||||||
|
Postbox SMTP, DNS) — переезд её не устраняет.
|
||||||
|
- `-` Timeweb активно блокирует Telegram (в отличие от YC) — интеграция
|
||||||
|
отвалилась. Затронуты `transcriber`, `remembos` и уведомления о
|
||||||
|
бэкапах. Ожидаемо; уведомления остались через почту, второй канал
|
||||||
|
рассматривается через Matrix.
|
||||||
|
- `-` Из-за тех же блокировок Timeweb перестали обновляться некоторые
|
||||||
|
RSS-фиды в `miniflux`.
|
||||||
|
- `-` Для доступа к `cr.yandex` вне YC появился долгоживущий OAuth-токен
|
||||||
|
Яндекса в 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`.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Architecture Decision Records (ADR)
|
||||||
|
|
||||||
|
Журнал значимых архитектурных и инфраструктурных решений по серверу.
|
||||||
|
Одна запись — одно решение. ADR пишем **постфактум**, когда изменение
|
||||||
|
уже сделано: идеи, планы и обсуждения живут в [`../drafts/`](../drafts),
|
||||||
|
а в ADR попадает только то, что реализовано. Записи **неизменяемы**:
|
||||||
|
передумали → не правим старую, а заводим новую и помечаем старую.
|
||||||
|
|
||||||
|
Чем ADR отличается от журналов в `../drafts/`: drafts — оперативная
|
||||||
|
хроника и черновики («что делаю / собираюсь сделать»), ADR — фиксация
|
||||||
|
выбора и его обоснования («почему сделал так, а не иначе»).
|
||||||
|
|
||||||
|
Главная ценность записи — сохранить **почему**: намерение и причинность.
|
||||||
|
Это важнее аккуратности оформления и полноты остальных секций.
|
||||||
|
|
||||||
|
## Когда заводить ADR
|
||||||
|
|
||||||
|
- Выбор технологии или инструмента (Caddy vs Nginx, restic vs borg).
|
||||||
|
- Структурные решения (схема бэкапов, организация плейбуков, сеть).
|
||||||
|
- Решения с долгосрочными последствиями или дорогим откатом.
|
||||||
|
- **Намеренный отказ** от очевидного подхода — чтобы потом не
|
||||||
|
переоткрывать «а почему мы не сделали X».
|
||||||
|
|
||||||
|
Не заводить для рутины (бамп версии образа, добавление сервиса по
|
||||||
|
накатанной схеме) и того, что и так видно из кода и git.
|
||||||
|
|
||||||
|
## Соглашения
|
||||||
|
|
||||||
|
- **Имя файла = идентификатор:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`.
|
||||||
|
Идентификатор — имя без `.md` (например `ADR-2026-05-24-adr-process`).
|
||||||
|
Slug — латиницей.
|
||||||
|
- **Дата** — когда изменение реально сделано (можно задним числом, а не
|
||||||
|
дата оформления записи).
|
||||||
|
- Несколько ADR за один день различаются по slug.
|
||||||
|
- **Заголовок в файле:** `# Человеческий заголовок` (без даты и ID — они
|
||||||
|
в имени файла и в строке «Дата»).
|
||||||
|
- Секция **«Рассмотренные варианты» — опциональна**: оставляй её, только
|
||||||
|
если альтернативы реально рассматривались.
|
||||||
|
- Шаблон новой записи — [`template.md`](template.md).
|
||||||
|
- Исключение: основополагающая мета-запись о самом процессе ADR
|
||||||
|
использует дату-сентинел `0000-00-00`, чтобы всегда оставаться в самом
|
||||||
|
низу списка. Реальные записи такую дату не используют.
|
||||||
|
|
||||||
|
## Статусы
|
||||||
|
|
||||||
|
Активная запись статуса **не имеет**. Статус появляется, только когда
|
||||||
|
запись теряет силу, и значений всего два:
|
||||||
|
|
||||||
|
- `заменено на ADR-ГГГГ-ММ-ДД-slug` — решение пересмотрено новой ADR.
|
||||||
|
- `устарело` — решение потеряло смысл и замены нет.
|
||||||
|
|
||||||
|
## Замена и устаревание
|
||||||
|
|
||||||
|
1. Заводим новую ADR; в её «Контексте» — строка
|
||||||
|
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
|
||||||
|
2. В старой ADR добавляем строку `- Статус: заменено на ADR-…` сразу под
|
||||||
|
датой. Тело не трогаем — это часть истории.
|
||||||
|
3. Обновляем статус старой записи в индексе ниже.
|
||||||
|
|
||||||
|
## Список записей
|
||||||
|
|
||||||
|
Новые сверху.
|
||||||
|
|
||||||
|
| Дата | Запись | Статус |
|
||||||
|
| ---------- | ---------------------------------------------------------------------------------------------- | ------ |
|
||||||
|
| 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,37 @@
|
|||||||
|
# Краткий заголовок решения
|
||||||
|
|
||||||
|
- Дата: ГГГГ-ММ-ДД
|
||||||
|
<!-- Строку статуса добавляют позже, только если запись потеряла силу:
|
||||||
|
- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug
|
||||||
|
- Статус: устарело
|
||||||
|
У активной записи строки статуса нет. -->
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Что вынудило сделать изменение: проблема, силы и ограничения (ресурсы
|
||||||
|
сервера, стоимость, время на поддержку, существующая архитектура).
|
||||||
|
Пиши так, чтобы через год было понятно «почему это вообще делалось»
|
||||||
|
без чтения переписки.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
<!-- Опциональная секция. Оставь, только если варианты реально
|
||||||
|
рассматривались. Если решение было единственным очевидным —
|
||||||
|
удали её, а причину объясни в «Решении». -->
|
||||||
|
|
||||||
|
- **Вариант A** — суть, плюсы и минусы.
|
||||||
|
- **Вариант B** — суть, плюсы и минусы.
|
||||||
|
- **Вариант C** — если отвергнут сразу, коротко почему.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Что именно сделано и — главное — **почему**: какое намерение и какая
|
||||||
|
причина за этим стоят. Если варианты рассматривались — почему выбран
|
||||||
|
этот, а не остальные.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` что стало лучше, какие возможности открылись.
|
||||||
|
- `-` чем платим: новые ограничения, риски, регулярная нагрузка на
|
||||||
|
поддержку.
|
||||||
|
- Что нужно сделать как следствие (если есть).
|
||||||
@@ -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** — фоновая зачистка стиля и структуры.
|
||||||
@@ -8,6 +8,280 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Шаг 14 — VM в YC остановлена (2026-05-23, выполнено)
|
||||||
|
|
||||||
|
Через несколько часов после cutover'а — выключил VM `rivendell-v2` в
|
||||||
|
панели Yandex Cloud (stop, не delete). Источник перешёл в состояние
|
||||||
|
«холодного запасного».
|
||||||
|
|
||||||
|
Формально план рекомендовал держать источник в живых ≥24 часа перед
|
||||||
|
остановкой (`timeweb.md:464`), но:
|
||||||
|
|
||||||
|
- docker и cron на источнике остановлены и `disable`нуты ещё на
|
||||||
|
Шаге 11 — VM работала вхолостую.
|
||||||
|
- Ключевые приложения проверены в браузере на target (см. Шаг 13).
|
||||||
|
- **Stop, не destroy** — состояние VM и диск сохраняются, при
|
||||||
|
необходимости отката достаточно `Start` в панели + `systemctl
|
||||||
|
enable --now docker cron` + откат DNS. Прирост к рекавери ~1-2 мин
|
||||||
|
по сравнению со running idle.
|
||||||
|
|
||||||
|
Compute снят со счёта (Timeweb-VM теперь единственный источник
|
||||||
|
расходов). S3-бакет с restic-бэкапами и Container Registry в YC
|
||||||
|
**не трогаем** — продолжают использоваться с Timeweb.
|
||||||
|
|
||||||
|
### Что осталось
|
||||||
|
|
||||||
|
Через неделю-две, если ничего не всплыло:
|
||||||
|
|
||||||
|
- Удалить VM `rivendell-v2` и связанные compute-ресурсы (только
|
||||||
|
compute! S3 и CR — оставляем).
|
||||||
|
- Удалить `production.yml`, переименовать `timeweb.yml` →
|
||||||
|
`production.yml`, откатить `HOSTS_FILE` в `tasks.py`. Закоммитить.
|
||||||
|
- Перенести `timeweb.md` и `timeweb-migration-log.md` из
|
||||||
|
`docs/drafts/` куда-нибудь в архив или удалить — план выполнен,
|
||||||
|
журнал теряет актуальность.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 13 — приложения подняты на target, cutover завершён (2026-05-23, выполнено)
|
||||||
|
|
||||||
|
После rsync'а (Шаг 12) — финальный прогон ансибла без `--skip-tags`,
|
||||||
|
поэтапно по приложениям. К ~16:30 DNS уже указывал на target (Шаг
|
||||||
|
переключения 15:45 + TTL 20 мин, пропагация подтверждена в 16:20),
|
||||||
|
так что Caddy при старте сразу пошёл за LE-сертификатами без задержек.
|
||||||
|
|
||||||
|
Прогоны делал поштучно через `inv pl -- <app>` (после Шага
|
||||||
|
переключения `HOSTS_FILE = "timeweb.yml"` в `tasks.py`), не всем
|
||||||
|
сразу — чтобы видеть каждый плейбук чисто.
|
||||||
|
|
||||||
|
### Что подтверждено работающим в браузере
|
||||||
|
|
||||||
|
- `vakhrushev.me` — homepage отдаёт страницу.
|
||||||
|
- `auth.vakhrushev.me` — Authelia, логин работает.
|
||||||
|
- `matrix.vakhrushev.me` — Tuwunel поднялся, Element подключается.
|
||||||
|
- `git.vakhrushev.me` — Gitea, репозитории и issue tracker на месте.
|
||||||
|
- `outline.vakhrushev.me` — документы видны.
|
||||||
|
- `gramps.vakhrushev.me` — генеалогическое дерево открывается.
|
||||||
|
- `wakapi.vakhrushev.me` — статистика времени видна.
|
||||||
|
- `status.vakhrushev.me` — Netdata собирает и рисует метрики.
|
||||||
|
|
||||||
|
Точечно зашёл в outline / gramps / wakapi / gitea — данные на месте,
|
||||||
|
ничего не потерялось при rsync'е.
|
||||||
|
|
||||||
|
### Отложенные на «потом по ходу дела» проверки
|
||||||
|
|
||||||
|
- `miniflux`, `memos`, `remembos`, `wanderer`, `calibre`, `rssbridge`,
|
||||||
|
`dozzle`, `goaccess` — открыть и убедиться, что отдают свои данные.
|
||||||
|
- **SMTP-test** — reset-password из gitea/authelia. Проверит, что
|
||||||
|
Postbox после разблокировки в панели Timeweb принимает наши письма.
|
||||||
|
- **Backup-cron в 1:00** — самый поздний smoke-тест системы. Покажет,
|
||||||
|
что `backup-all.py` отработал на target, restic пишет в S3 с новым
|
||||||
|
`host_name`, apprise шлёт уведомление.
|
||||||
|
- `docker pull cr.yandex/...` руками — повторная проверка
|
||||||
|
OAuth-аутентификации.
|
||||||
|
|
||||||
|
### Отклонения от плана сегодня
|
||||||
|
|
||||||
|
1. **VPS пересоздан в СПб** (Шаг 8) — первая выдача попала на
|
||||||
|
гипервизор с битой сетью.
|
||||||
|
2. **Docker Hub rate limit** на pull'е netdata — anonymous лимит
|
||||||
|
подсети Timeweb уже выбран соседями. Лечится ручным
|
||||||
|
`sudo docker login` на target (через free-аккаунт + PAT).
|
||||||
|
**Backlog:** добавить `community.docker.docker_login` для
|
||||||
|
`docker.io` в `playbook-docker.yml`, по аналогии с cr.yandex (Шаг
|
||||||
|
3). Креды в vault как `dockerhub_username` / `dockerhub_token`.
|
||||||
|
3. **Postbox SMTP не доступен извне YC** — оказалось, что в плане
|
||||||
|
(`timeweb.md:81`) предпосылка «Postbox доступен извне YC по тем же
|
||||||
|
credentials» неверна. Yandex Cloud Postbox дропает SMTP от не-YC
|
||||||
|
источников; 443 при этом отвечает. Дополнительно Timeweb по
|
||||||
|
умолчанию **сам** блокирует egress SMTP (25/465/587) — toggle в
|
||||||
|
панели Timeweb снимает блок, после чего Postbox отвечает баннером.
|
||||||
|
Authelia в exit-loop'е поднялась после рестарта. Запись в auto-
|
||||||
|
memory `project_timeweb_smtp_block.md` — пригодится при следующих
|
||||||
|
миграциях.
|
||||||
|
4. **Bug ordering в `playbook-goaccess.yml`** (см. Шаг 9, фикс
|
||||||
|
зашит) — латентный bug, проявившийся только на чистой машине.
|
||||||
|
|
||||||
|
### Что осталось до полной заморозки
|
||||||
|
|
||||||
|
По плану (`timeweb.md:464-473`):
|
||||||
|
|
||||||
|
- **≥ 24 часа** держим источник в выключенном состоянии (docker уже
|
||||||
|
остановлен, daemon отключён через `disable`), как горячее запасное.
|
||||||
|
- Если за сутки ничего не всплыло — выключить VM в YC.
|
||||||
|
- Подождать ещё неделю-две — на всякий случай.
|
||||||
|
- Удалить VM и связанные compute-ресурсы. **S3-бакет с
|
||||||
|
restic-бэкапами и Container Registry — оставляем**, они продолжают
|
||||||
|
использоваться.
|
||||||
|
- Удалить `production.yml`, переименовать `timeweb.yml` →
|
||||||
|
`production.yml`, откатить `HOSTS_FILE = "production.yml"` в
|
||||||
|
`tasks.py`. Закоммитить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 12 — rsync данных с источника на target (2026-05-23, выполнено)
|
||||||
|
|
||||||
|
Перенос `/mnt/applications/` на YC → `/srv/applications/` на Timeweb
|
||||||
|
после заморозки источника (Шаг 11). Это финальный канал переноса
|
||||||
|
данных — основной для всех приложений, единственный для `caddyproxy`,
|
||||||
|
`remembos`, `transcriber` (у которых нет backup-механизма, см. Шаг 7b).
|
||||||
|
|
||||||
|
### Пилотный прогон на remembos
|
||||||
|
|
||||||
|
Прежде чем гнать всё дерево, проверил рецепт на самом маленьком
|
||||||
|
приложении (~35 КБ всего):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -E rsync -aAX --info=progress2 --delete --rsync-path="sudo rsync" \
|
||||||
|
-e "ssh -o StrictHostKeyChecking=accept-new" \
|
||||||
|
major@158.160.46.255:/mnt/applications/remembos/ \
|
||||||
|
/srv/applications/remembos/
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка после прогона:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ sudo ls -la /srv/applications/remembos/
|
||||||
|
drwxr-x--- 4 remembos remembos 4096 Apr 30 13:22 .
|
||||||
|
drwxr-x--- 2 remembos remembos 4096 Feb 12 17:22 config
|
||||||
|
drwxr-x--- 2 remembos remembos 4096 May 23 12:41 data
|
||||||
|
-rw-r----- 1 remembos remembos 494 Apr 30 13:22 docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Owner отрисован именами (`remembos:remembos`, не numeric `1103:1103`)
|
||||||
|
— значит на обеих сторонах ансибл создал юзера с одним и тем же uid,
|
||||||
|
mapping сошёлся. Mode (750) и mtime сохранены.
|
||||||
|
|
||||||
|
### Засада с agent-forwarding'ом под sudo
|
||||||
|
|
||||||
|
Первая попытка упала с `Permission denied (publickey)`. Причина:
|
||||||
|
rsync запускается через `sudo` на target, а sudo по дефолту чистит
|
||||||
|
`SSH_AUTH_SOCK` из env (`Defaults env_reset` в /etc/sudoers) — ssh
|
||||||
|
внутри sudo не видит проброшенный agent, пытается парольную
|
||||||
|
аутентификацию, проваливается.
|
||||||
|
|
||||||
|
Лечится разрешением sudo проносить именно эту переменную:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'Defaults env_keep += "SSH_AUTH_SOCK"' | sudo tee -a /etc/sudoers.d/major
|
||||||
|
sudo visudo -cf /etc/sudoers.d/major
|
||||||
|
```
|
||||||
|
|
||||||
|
Безопасно: сокет агента принадлежит `major`, root к нему имеет доступ
|
||||||
|
по определению; мы просто говорим sudo не вычищать переменную с путём
|
||||||
|
к нему. После этого `sudo -E rsync …` отрабатывает.
|
||||||
|
|
||||||
|
### Полный прогон по всем приложениям
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo -E rsync -aAX --info=progress2 --delete --exclude='lost+found' \
|
||||||
|
--rsync-path="sudo rsync" \
|
||||||
|
-e "ssh -o StrictHostKeyChecking=accept-new" \
|
||||||
|
major@158.160.46.255:/mnt/applications/ \
|
||||||
|
/srv/applications/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Что делает каждый флаг
|
||||||
|
|
||||||
|
- **`sudo -E`** — локальный rsync на target запускается под root
|
||||||
|
(нужно, чтобы писать файлы с любым owner'ом / mode); `-E` сохраняет
|
||||||
|
env, в первую очередь `SSH_AUTH_SOCK` для agent forwarding.
|
||||||
|
- **`-a`** (`--archive`) — собирательный флаг `-rlptgoD`: recursive +
|
||||||
|
symlinks как symlinks + permissions + times + group + owner +
|
||||||
|
special files. Базовое «копировать всё как есть».
|
||||||
|
- **`-A`** — сохранить POSIX ACL.
|
||||||
|
- **`-X`** — сохранить extended attributes (xattrs), включая
|
||||||
|
security-атрибуты типа capabilities или SELinux-меток.
|
||||||
|
- **`--info=progress2`** — совокупный прогресс по всему transfer'у,
|
||||||
|
а не per-file (для больших деревьев читабельнее).
|
||||||
|
- **`--delete`** — стереть на target всё, чего нет на источнике.
|
||||||
|
Безопасно в нашем случае: после rsync'а прогоняем ансибл, он
|
||||||
|
перерендерит конфиги и пересоздаст любые отсутствующие структурные
|
||||||
|
каталоги. Стирается, по сути, только содержимое, отрендеренное
|
||||||
|
плейбуком на Шаге 9 без `run-app`.
|
||||||
|
- **`--exclude='lost+found'`** — на YC `/mnt/applications/` это mount
|
||||||
|
point внешнего диска, в его корне может лежать системный
|
||||||
|
`lost+found`. Нам он не нужен и на target такого монтирования
|
||||||
|
больше нет (`mount_external_storage: false`).
|
||||||
|
- **`--rsync-path="sudo rsync"`** — критично: на удалённой стороне
|
||||||
|
(источнике) rsync запускается через sudo. Иначе он стартует под
|
||||||
|
`major`, у которого нет прав читать чужие `/mnt/applications/<app>/`
|
||||||
|
(mode 750, owner — приложение). У `major` на источнике NOPASSWD
|
||||||
|
sudo, так что sudo прокатывает молча.
|
||||||
|
- **`-e "ssh -o StrictHostKeyChecking=accept-new"`** — кастомная
|
||||||
|
команда транспорта. По умолчанию rsync запускает чистый `ssh`; мы
|
||||||
|
добавляем флаг для автопринятия host key источника (на target
|
||||||
|
`known_hosts` ещё пустой).
|
||||||
|
- **`major@158.160.46.255:/mnt/applications/`** — источник. Trailing
|
||||||
|
slash важен: «копировать содержимое каталога», а не сам каталог.
|
||||||
|
Без слэша получили бы `/srv/applications/applications/...`.
|
||||||
|
- **`/srv/applications/`** — назначение. Trailing slash для
|
||||||
|
симметрии — содержимое кладётся в существующий каталог,
|
||||||
|
созданный ансиблом на Шаге 9.
|
||||||
|
|
||||||
|
### Результат
|
||||||
|
|
||||||
|
```
|
||||||
|
22,613,081,829 99% 7.11MB/s 0:50:34 (xfr#21837, to-chk=0/31024)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Объём — ~22.6 ГБ, файлов — 31 024.
|
||||||
|
- Длительность — 50 минут 34 секунды, средняя скорость ~7 МБ/с
|
||||||
|
(предсказуемо для YC↔Timeweb).
|
||||||
|
- `du -s` после прогона: источник 22 088 224 КБ, target 22 164 172 КБ
|
||||||
|
— разница ~76 МБ (0.34%). Это не рассинхрон данных, а разница в
|
||||||
|
аллокации блоков ФС и метаданных между источником и target (разные
|
||||||
|
inode-таблицы, journal, group descriptors). Содержимое файлов
|
||||||
|
совпадает — rsync'у на это указали checksum'ы, errors не было.
|
||||||
|
|
||||||
|
Окно даунтайма с момента стопа docker'а (Шаг 11) до конца rsync'а —
|
||||||
|
около часа. С учётом параллельно запущенного DNS-переключения
|
||||||
|
(Шаг между 11 и 12, 15:45) к моменту запуска приложений на target
|
||||||
|
пропагация уже прошла (16:20).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 11 — источник заморожен (docker + cron остановлены) (2026-05-23, выполнено)
|
||||||
|
|
||||||
|
Сразу после финального бэкапа (Шаг 10) — отключил docker и cron на
|
||||||
|
источнике, чтобы зафиксировать состояние данных перед rsync'ом и
|
||||||
|
исключить случайные записи в `/mnt/applications/` во время переноса.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl stop docker.service docker.socket
|
||||||
|
sudo systemctl disable docker.service docker.socket
|
||||||
|
sudo systemctl stop cron
|
||||||
|
```
|
||||||
|
|
||||||
|
`disable` — страховка от автостарта docker'а при возможной
|
||||||
|
перезагрузке источника (если вернёмся для отката или проверки).
|
||||||
|
`cron stop` — чтобы ночной `backup-all.py` не запустился впустую без
|
||||||
|
работающего daemon'а.
|
||||||
|
|
||||||
|
С этого момента источник «мёртв» для пользователей — окно даунтайма
|
||||||
|
открыто. Следующий шаг — переключить DNS и параллельно гнать rsync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 10 — финальный бэкап на источнике (2026-05-23, выполнено)
|
||||||
|
|
||||||
|
Прогнал `backup-all.py` на источнике, пока docker ещё жив (он нужен
|
||||||
|
для `pg_dump` и других in-container backup-команд внутри
|
||||||
|
`backup.sh`-скриптов отдельных приложений).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Свежий restic-снапшот в `yandex_cloud_s3` зафиксирован — страховочный
|
||||||
|
канал на случай, если rsync пойдёт криво (для приложений с
|
||||||
|
`backup.sh` можно будет восстановить из S3; для `caddyproxy`,
|
||||||
|
`remembos`, `transcriber` страховки нет, для них только rsync).
|
||||||
|
|
||||||
|
После прогона можно гасить docker без риска потерять backup-окно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Шаг 9 — раскатана база и приложения без запуска (2026-05-23, выполнено)
|
## Шаг 9 — раскатана база и приложения без запуска (2026-05-23, выполнено)
|
||||||
|
|
||||||
На свежей Timeweb-машине прогнаны два плейбука без даунтайма источника
|
На свежей Timeweb-машине прогнаны два плейбука без даунтайма источника
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ server:
|
|||||||
## Configure the authz endpoints.
|
## Configure the authz endpoints.
|
||||||
authz:
|
authz:
|
||||||
forward-auth:
|
forward-auth:
|
||||||
implementation: 'ForwardAuth'
|
implementation: "ForwardAuth"
|
||||||
# authn_strategies: []
|
# authn_strategies: []
|
||||||
# ext-authz:
|
# ext-authz:
|
||||||
# implementation: 'ExtAuthz'
|
# implementation: 'ExtAuthz'
|
||||||
@@ -121,10 +121,10 @@ server:
|
|||||||
##
|
##
|
||||||
log:
|
log:
|
||||||
## Level of verbosity for logs: info, debug, trace.
|
## Level of verbosity for logs: info, debug, trace.
|
||||||
level: 'debug'
|
level: "debug"
|
||||||
|
|
||||||
## Format the logs are written as: json, text.
|
## Format the logs are written as: json, text.
|
||||||
format: 'json'
|
format: "json"
|
||||||
|
|
||||||
## File path where the logs will be written. If not set logs are written to stdout.
|
## File path where the logs will be written. If not set logs are written to stdout.
|
||||||
# file_path: '/config/authelia.log'
|
# file_path: '/config/authelia.log'
|
||||||
@@ -136,7 +136,6 @@ log:
|
|||||||
## Telemetry Configuration
|
## Telemetry Configuration
|
||||||
##
|
##
|
||||||
telemetry:
|
telemetry:
|
||||||
|
|
||||||
##
|
##
|
||||||
## Metrics Configuration
|
## Metrics Configuration
|
||||||
##
|
##
|
||||||
@@ -151,7 +150,7 @@ telemetry:
|
|||||||
## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', 'unix', or 'fd'.
|
## Square brackets indicate optional portions of the format. Scheme must be 'tcp', 'tcp4', 'tcp6', 'unix', or 'fd'.
|
||||||
## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9959'.
|
## The default scheme is 'unix' if the address is an absolute path otherwise it's 'tcp'. The default port is '9959'.
|
||||||
## If the path is not specified it defaults to `/metrics`.
|
## If the path is not specified it defaults to `/metrics`.
|
||||||
address: 'tcp://:9959/metrics'
|
address: "tcp://:9959/metrics"
|
||||||
|
|
||||||
## Metrics Server Buffers configuration.
|
## Metrics Server Buffers configuration.
|
||||||
# buffers:
|
# buffers:
|
||||||
@@ -179,128 +178,128 @@ telemetry:
|
|||||||
##
|
##
|
||||||
## Parameters used for TOTP generation.
|
## Parameters used for TOTP generation.
|
||||||
# totp:
|
# totp:
|
||||||
## Disable TOTP.
|
## Disable TOTP.
|
||||||
# disable: false
|
# disable: false
|
||||||
|
|
||||||
## The issuer name displayed in the Authenticator application of your choice.
|
## The issuer name displayed in the Authenticator application of your choice.
|
||||||
# issuer: 'authelia.com'
|
# issuer: 'authelia.com'
|
||||||
|
|
||||||
## The TOTP algorithm to use.
|
## The TOTP algorithm to use.
|
||||||
## It is CRITICAL you read the documentation before changing this option:
|
## It is CRITICAL you read the documentation before changing this option:
|
||||||
## https://www.authelia.com/c/totp#algorithm
|
## https://www.authelia.com/c/totp#algorithm
|
||||||
# algorithm: 'SHA1'
|
# algorithm: 'SHA1'
|
||||||
|
|
||||||
## The number of digits a user has to input. Must either be 6 or 8.
|
## The number of digits a user has to input. Must either be 6 or 8.
|
||||||
## Changing this option only affects newly generated TOTP configurations.
|
## Changing this option only affects newly generated TOTP configurations.
|
||||||
## It is CRITICAL you read the documentation before changing this option:
|
## It is CRITICAL you read the documentation before changing this option:
|
||||||
## https://www.authelia.com/c/totp#digits
|
## https://www.authelia.com/c/totp#digits
|
||||||
# digits: 6
|
# digits: 6
|
||||||
|
|
||||||
## The period in seconds a Time-based One-Time Password is valid for.
|
## The period in seconds a Time-based One-Time Password is valid for.
|
||||||
## Changing this option only affects newly generated TOTP configurations.
|
## Changing this option only affects newly generated TOTP configurations.
|
||||||
# period: 30
|
# period: 30
|
||||||
|
|
||||||
## The skew controls number of Time-based One-Time Passwords either side of the current one that are valid.
|
## The skew controls number of Time-based One-Time Passwords either side of the current one that are valid.
|
||||||
## Warning: before changing skew read the docs link below.
|
## Warning: before changing skew read the docs link below.
|
||||||
# skew: 1
|
# skew: 1
|
||||||
## See: https://www.authelia.com/c/totp#input-validation to read
|
## See: https://www.authelia.com/c/totp#input-validation to read
|
||||||
## the documentation.
|
## the documentation.
|
||||||
|
|
||||||
## The size of the generated shared secrets. Default is 32 and is sufficient in most use cases, minimum is 20.
|
## The size of the generated shared secrets. Default is 32 and is sufficient in most use cases, minimum is 20.
|
||||||
# secret_size: 32
|
# secret_size: 32
|
||||||
|
|
||||||
## The allowed algorithms for a user to pick from.
|
## The allowed algorithms for a user to pick from.
|
||||||
# allowed_algorithms:
|
# allowed_algorithms:
|
||||||
# - 'SHA1'
|
# - 'SHA1'
|
||||||
|
|
||||||
## The allowed digits for a user to pick from.
|
## The allowed digits for a user to pick from.
|
||||||
# allowed_digits:
|
# allowed_digits:
|
||||||
# - 6
|
# - 6
|
||||||
|
|
||||||
## The allowed periods for a user to pick from.
|
## The allowed periods for a user to pick from.
|
||||||
# allowed_periods:
|
# allowed_periods:
|
||||||
# - 30
|
# - 30
|
||||||
|
|
||||||
## Disable the reuse security policy which prevents replays of one-time password code values.
|
## Disable the reuse security policy which prevents replays of one-time password code values.
|
||||||
# disable_reuse_security_policy: false
|
# disable_reuse_security_policy: false
|
||||||
|
|
||||||
##
|
##
|
||||||
## WebAuthn Configuration
|
## WebAuthn Configuration
|
||||||
##
|
##
|
||||||
## Parameters used for WebAuthn.
|
## Parameters used for WebAuthn.
|
||||||
# webauthn:
|
# webauthn:
|
||||||
## Disable WebAuthn.
|
## Disable WebAuthn.
|
||||||
# disable: false
|
# disable: false
|
||||||
|
|
||||||
## Enables logins via a Passkey.
|
## Enables logins via a Passkey.
|
||||||
# enable_passkey_login: false
|
# enable_passkey_login: false
|
||||||
|
|
||||||
## The display name the browser should show the user for when using WebAuthn to login/register.
|
## The display name the browser should show the user for when using WebAuthn to login/register.
|
||||||
# display_name: 'Authelia'
|
# display_name: 'Authelia'
|
||||||
|
|
||||||
## Conveyance preference controls if we collect the attestation statement including the AAGUID from the device.
|
## Conveyance preference controls if we collect the attestation statement including the AAGUID from the device.
|
||||||
## Options are none, indirect, direct.
|
## Options are none, indirect, direct.
|
||||||
# attestation_conveyance_preference: 'indirect'
|
# attestation_conveyance_preference: 'indirect'
|
||||||
|
|
||||||
## The interaction timeout for WebAuthn dialogues in the duration common syntax.
|
## The interaction timeout for WebAuthn dialogues in the duration common syntax.
|
||||||
# timeout: '60 seconds'
|
# timeout: '60 seconds'
|
||||||
|
|
||||||
## Authenticator Filtering.
|
## Authenticator Filtering.
|
||||||
# filtering:
|
# filtering:
|
||||||
## Prohibits registering Authenticators that claim they can export their credentials in some way.
|
## Prohibits registering Authenticators that claim they can export their credentials in some way.
|
||||||
# prohibit_backup_eligibility: false
|
# prohibit_backup_eligibility: false
|
||||||
|
|
||||||
## Permitted AAGUID's. If configured specifically only allows the listed AAGUID's.
|
## Permitted AAGUID's. If configured specifically only allows the listed AAGUID's.
|
||||||
# permitted_aaguids: []
|
# permitted_aaguids: []
|
||||||
|
|
||||||
## Prohibited AAGUID's. If configured prohibits the use of specific AAGUID's.
|
## Prohibited AAGUID's. If configured prohibits the use of specific AAGUID's.
|
||||||
# prohibited_aaguids: []
|
# prohibited_aaguids: []
|
||||||
|
|
||||||
## Selection Criteria controls the preferences for registration.
|
## Selection Criteria controls the preferences for registration.
|
||||||
# selection_criteria:
|
# selection_criteria:
|
||||||
## The attachment preference. Either 'cross-platform' for dedicated authenticators, or 'platform' for embedded
|
## The attachment preference. Either 'cross-platform' for dedicated authenticators, or 'platform' for embedded
|
||||||
## authenticators.
|
## authenticators.
|
||||||
# attachment: 'cross-platform'
|
# attachment: 'cross-platform'
|
||||||
|
|
||||||
## The discoverability preference. Options are 'discouraged', 'preferred', and 'required'.
|
## The discoverability preference. Options are 'discouraged', 'preferred', and 'required'.
|
||||||
# discoverability: 'discouraged'
|
# discoverability: 'discouraged'
|
||||||
|
|
||||||
## User verification controls if the user must make a gesture or action to confirm they are present.
|
## User verification controls if the user must make a gesture or action to confirm they are present.
|
||||||
## Options are required, preferred, discouraged.
|
## Options are required, preferred, discouraged.
|
||||||
# user_verification: 'preferred'
|
# user_verification: 'preferred'
|
||||||
|
|
||||||
## Metadata Service validation via MDS3.
|
## Metadata Service validation via MDS3.
|
||||||
# metadata:
|
# metadata:
|
||||||
|
|
||||||
## Enable the metadata fetch behaviour.
|
## Enable the metadata fetch behaviour.
|
||||||
# enabled: false
|
# enabled: false
|
||||||
|
|
||||||
## Enable Validation of the Trust Anchor. This generally should be enabled if you're using the metadata. It
|
## Enable Validation of the Trust Anchor. This generally should be enabled if you're using the metadata. It
|
||||||
## ensures the attestation certificate presented by the authenticator is valid against the MDS3 certificate that
|
## ensures the attestation certificate presented by the authenticator is valid against the MDS3 certificate that
|
||||||
## issued the attestation certificate.
|
## issued the attestation certificate.
|
||||||
# validate_trust_anchor: true
|
# validate_trust_anchor: true
|
||||||
|
|
||||||
## Enable Validation of the Entry. This ensures that the MDS3 actually contains the metadata entry. If not enabled
|
## Enable Validation of the Entry. This ensures that the MDS3 actually contains the metadata entry. If not enabled
|
||||||
## attestation certificates which are not formally registered will be skipped. This may potentially exclude some
|
## attestation certificates which are not formally registered will be skipped. This may potentially exclude some
|
||||||
## virtual authenticators.
|
## virtual authenticators.
|
||||||
# validate_entry: true
|
# validate_entry: true
|
||||||
|
|
||||||
## Enabling this allows attestation certificates with a zero AAGUID to pass validation. This is important if you do
|
## Enabling this allows attestation certificates with a zero AAGUID to pass validation. This is important if you do
|
||||||
## use non-conformant authenticators like Apple ID.
|
## use non-conformant authenticators like Apple ID.
|
||||||
# validate_entry_permit_zero_aaguid: false
|
# validate_entry_permit_zero_aaguid: false
|
||||||
|
|
||||||
## Enable Validation of the Authenticator Status.
|
## Enable Validation of the Authenticator Status.
|
||||||
# validate_status: true
|
# validate_status: true
|
||||||
|
|
||||||
## List of statuses which are considered permitted when validating an authenticator's metadata. Generally it is
|
## List of statuses which are considered permitted when validating an authenticator's metadata. Generally it is
|
||||||
## recommended that this is not configured as any other status the authenticator's metadata has will result in an
|
## recommended that this is not configured as any other status the authenticator's metadata has will result in an
|
||||||
## error. This option is ineffectual if validate_status is false.
|
## error. This option is ineffectual if validate_status is false.
|
||||||
# validate_status_permitted: ~
|
# validate_status_permitted: ~
|
||||||
|
|
||||||
## List of statuses that should be prohibited when validating an authenticator's metadata. Generally it is
|
## List of statuses that should be prohibited when validating an authenticator's metadata. Generally it is
|
||||||
## recommended that this is not configured as there are safe defaults. This option is ineffectual if validate_status
|
## recommended that this is not configured as there are safe defaults. This option is ineffectual if validate_status
|
||||||
## is false, or validate_status_permitted has values.
|
## is false, or validate_status_permitted has values.
|
||||||
# validate_status_prohibited: ~
|
# validate_status_prohibited: ~
|
||||||
|
|
||||||
##
|
##
|
||||||
## Duo Push API Configuration
|
## Duo Push API Configuration
|
||||||
@@ -308,19 +307,18 @@ telemetry:
|
|||||||
## Parameters used to contact the Duo API. Those are generated when you protect an application of type
|
## Parameters used to contact the Duo API. Those are generated when you protect an application of type
|
||||||
## "Partner Auth API" in the management panel.
|
## "Partner Auth API" in the management panel.
|
||||||
# duo_api:
|
# duo_api:
|
||||||
# disable: false
|
# disable: false
|
||||||
# hostname: 'api-123456789.example.com'
|
# hostname: 'api-123456789.example.com'
|
||||||
# integration_key: 'ABCDEF'
|
# integration_key: 'ABCDEF'
|
||||||
## Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
## Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
||||||
# secret_key: 'secret'
|
# secret_key: 'secret'
|
||||||
# enable_self_enrollment: false
|
# enable_self_enrollment: false
|
||||||
|
|
||||||
##
|
##
|
||||||
## Identity Validation Configuration
|
## Identity Validation Configuration
|
||||||
##
|
##
|
||||||
## This configuration tunes the identity validation flows.
|
## This configuration tunes the identity validation flows.
|
||||||
identity_validation:
|
identity_validation:
|
||||||
|
|
||||||
## Reset Password flow. Adjusts how the reset password flow operates.
|
## Reset Password flow. Adjusts how the reset password flow operates.
|
||||||
reset_password:
|
reset_password:
|
||||||
## Maximum allowed time before the JWT is generated and when the user uses it in the duration common syntax.
|
## Maximum allowed time before the JWT is generated and when the user uses it in the duration common syntax.
|
||||||
@@ -330,7 +328,7 @@ identity_validation:
|
|||||||
# jwt_algorithm: 'HS256'
|
# jwt_algorithm: 'HS256'
|
||||||
|
|
||||||
## The secret key used to sign and verify the JWT.
|
## The secret key used to sign and verify the JWT.
|
||||||
jwt_secret: '{{ identity_validation__jwt_secret }}'
|
jwt_secret: "{{ identity_validation__jwt_secret }}"
|
||||||
|
|
||||||
## Elevated Session flows. Adjusts the flow which require elevated sessions for example managing credentials, adding,
|
## Elevated Session flows. Adjusts the flow which require elevated sessions for example managing credentials, adding,
|
||||||
## removing, etc.
|
## removing, etc.
|
||||||
@@ -357,26 +355,26 @@ identity_validation:
|
|||||||
##
|
##
|
||||||
## This is used to validate the servers time is accurate enough to validate TOTP.
|
## This is used to validate the servers time is accurate enough to validate TOTP.
|
||||||
# ntp:
|
# ntp:
|
||||||
## The address of the NTP server to connect to in the address common syntax.
|
## The address of the NTP server to connect to in the address common syntax.
|
||||||
## Format: [<scheme>://]<hostname>[:<port>].
|
## Format: [<scheme>://]<hostname>[:<port>].
|
||||||
## Square brackets indicate optional portions of the format. Scheme must be 'udp', 'udp4', or 'udp6'.
|
## Square brackets indicate optional portions of the format. Scheme must be 'udp', 'udp4', or 'udp6'.
|
||||||
## The default scheme is 'udp'. The default port is '123'.
|
## The default scheme is 'udp'. The default port is '123'.
|
||||||
# address: 'udp://time.cloudflare.com:123'
|
# address: 'udp://time.cloudflare.com:123'
|
||||||
|
|
||||||
## NTP version.
|
## NTP version.
|
||||||
# version: 4
|
# version: 4
|
||||||
|
|
||||||
## Maximum allowed time offset between the host and the NTP server in the duration common syntax.
|
## Maximum allowed time offset between the host and the NTP server in the duration common syntax.
|
||||||
# max_desync: '3 seconds'
|
# max_desync: '3 seconds'
|
||||||
|
|
||||||
## Disables the NTP check on startup entirely. This means Authelia will not contact a remote service at all if you
|
## Disables the NTP check on startup entirely. This means Authelia will not contact a remote service at all if you
|
||||||
## set this to true, and can operate in a truly offline mode.
|
## set this to true, and can operate in a truly offline mode.
|
||||||
# disable_startup_check: false
|
# disable_startup_check: false
|
||||||
|
|
||||||
## The default of false will prevent startup only if we can contact the NTP server and the time is out of sync with
|
## The default of false will prevent startup only if we can contact the NTP server and the time is out of sync with
|
||||||
## the NTP server more than the configured max_desync. If you set this to true, an error will be logged but startup
|
## the NTP server more than the configured max_desync. If you set this to true, an error will be logged but startup
|
||||||
## will continue regardless of results.
|
## will continue regardless of results.
|
||||||
# disable_failure: false
|
# disable_failure: false
|
||||||
|
|
||||||
##
|
##
|
||||||
## Definitions
|
## Definitions
|
||||||
@@ -384,22 +382,22 @@ identity_validation:
|
|||||||
## The definitions are used in other areas as reference points to reduce duplication.
|
## The definitions are used in other areas as reference points to reduce duplication.
|
||||||
##
|
##
|
||||||
# definitions:
|
# definitions:
|
||||||
## The user attribute definitions.
|
## The user attribute definitions.
|
||||||
# user_attributes:
|
# user_attributes:
|
||||||
## The name of the definition.
|
## The name of the definition.
|
||||||
# definition_name:
|
# definition_name:
|
||||||
## The common expression language expression for this definition.
|
## The common expression language expression for this definition.
|
||||||
# expression: ''
|
# expression: ''
|
||||||
|
|
||||||
## The network definitions.
|
## The network definitions.
|
||||||
# network:
|
# network:
|
||||||
## The name of the definition followed by the list of CIDR network addresses in this definition.
|
## The name of the definition followed by the list of CIDR network addresses in this definition.
|
||||||
# internal:
|
# internal:
|
||||||
# - '10.10.0.0/16'
|
# - '10.10.0.0/16'
|
||||||
# - '172.16.0.0/12'
|
# - '172.16.0.0/12'
|
||||||
# - '192.168.2.0/24'
|
# - '192.168.2.0/24'
|
||||||
# VPN:
|
# VPN:
|
||||||
# - '10.9.0.0/16'
|
# - '10.9.0.0/16'
|
||||||
|
|
||||||
##
|
##
|
||||||
## Authentication Backend Provider Configuration
|
## Authentication Backend Provider Configuration
|
||||||
@@ -408,7 +406,6 @@ identity_validation:
|
|||||||
##
|
##
|
||||||
## The available providers are: `file`, `ldap`. You must use only one of these providers.
|
## The available providers are: `file`, `ldap`. You must use only one of these providers.
|
||||||
authentication_backend:
|
authentication_backend:
|
||||||
|
|
||||||
## Password Change Options.
|
## Password Change Options.
|
||||||
password_change:
|
password_change:
|
||||||
## Disable both the HTML element and the API for password change functionality.
|
## Disable both the HTML element and the API for password change functionality.
|
||||||
@@ -606,7 +603,7 @@ authentication_backend:
|
|||||||
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
|
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
|
||||||
##
|
##
|
||||||
file:
|
file:
|
||||||
path: '/config/users.yml'
|
path: "/config/users.yml"
|
||||||
# watch: false
|
# watch: false
|
||||||
# search:
|
# search:
|
||||||
# email: false
|
# email: false
|
||||||
@@ -643,34 +640,34 @@ authentication_backend:
|
|||||||
##
|
##
|
||||||
# password_policy:
|
# password_policy:
|
||||||
|
|
||||||
## The standard policy allows you to tune individual settings manually.
|
## The standard policy allows you to tune individual settings manually.
|
||||||
# standard:
|
# standard:
|
||||||
# enabled: false
|
# enabled: false
|
||||||
|
|
||||||
## Require a minimum length for passwords.
|
## Require a minimum length for passwords.
|
||||||
# min_length: 8
|
# min_length: 8
|
||||||
|
|
||||||
## Require a maximum length for passwords.
|
## Require a maximum length for passwords.
|
||||||
# max_length: 0
|
# max_length: 0
|
||||||
|
|
||||||
## Require uppercase characters.
|
## Require uppercase characters.
|
||||||
# require_uppercase: true
|
# require_uppercase: true
|
||||||
|
|
||||||
## Require lowercase characters.
|
## Require lowercase characters.
|
||||||
# require_lowercase: true
|
# require_lowercase: true
|
||||||
|
|
||||||
## Require numeric characters.
|
## Require numeric characters.
|
||||||
# require_number: true
|
# require_number: true
|
||||||
|
|
||||||
## Require special characters.
|
## Require special characters.
|
||||||
# require_special: true
|
# require_special: true
|
||||||
|
|
||||||
## zxcvbn is a well known and used password strength algorithm. It does not have tunable settings.
|
## zxcvbn is a well known and used password strength algorithm. It does not have tunable settings.
|
||||||
# zxcvbn:
|
# zxcvbn:
|
||||||
# enabled: false
|
# enabled: false
|
||||||
|
|
||||||
## Configures the minimum score allowed.
|
## Configures the minimum score allowed.
|
||||||
# min_score: 3
|
# min_score: 3
|
||||||
|
|
||||||
##
|
##
|
||||||
## Privacy Policy Configuration
|
## Privacy Policy Configuration
|
||||||
@@ -678,16 +675,16 @@ authentication_backend:
|
|||||||
## Parameters used for displaying the privacy policy link and drawer.
|
## Parameters used for displaying the privacy policy link and drawer.
|
||||||
# privacy_policy:
|
# privacy_policy:
|
||||||
|
|
||||||
## Enables the display of the privacy policy using the policy_url.
|
## Enables the display of the privacy policy using the policy_url.
|
||||||
# enabled: false
|
# enabled: false
|
||||||
|
|
||||||
## Enables the display of the privacy policy drawer which requires users accept the privacy policy
|
## Enables the display of the privacy policy drawer which requires users accept the privacy policy
|
||||||
## on a per-browser basis.
|
## on a per-browser basis.
|
||||||
# require_user_acceptance: false
|
# require_user_acceptance: false
|
||||||
|
|
||||||
## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme.
|
## The URL of the privacy policy document. Must be an absolute URL and must have the 'https://' scheme.
|
||||||
## If the privacy policy enabled option is true, this MUST be provided.
|
## If the privacy policy enabled option is true, this MUST be provided.
|
||||||
# policy_url: ''
|
# policy_url: ''
|
||||||
|
|
||||||
##
|
##
|
||||||
## Access Control Configuration
|
## Access Control Configuration
|
||||||
@@ -719,33 +716,33 @@ authentication_backend:
|
|||||||
access_control:
|
access_control:
|
||||||
## Default policy can either be 'bypass', 'one_factor', 'two_factor' or 'deny'. It is the policy applied to any
|
## Default policy can either be 'bypass', 'one_factor', 'two_factor' or 'deny'. It is the policy applied to any
|
||||||
## resource if there is no policy to be applied to the user.
|
## resource if there is no policy to be applied to the user.
|
||||||
default_policy: 'deny'
|
default_policy: "deny"
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
## Rules applied to everyone
|
## Rules applied to everyone
|
||||||
- domain: 'status.vakhrushev.me'
|
- domain: "status.vakhrushev.me"
|
||||||
subject: 'group:admins'
|
subject: "group:admins"
|
||||||
policy: 'two_factor'
|
policy: "two_factor"
|
||||||
|
|
||||||
- domain: 'dozzle.vakhrushev.me'
|
- domain: "dozzle.vakhrushev.me"
|
||||||
subject: 'group:admins'
|
subject: "group:admins"
|
||||||
policy: 'two_factor'
|
policy: "two_factor"
|
||||||
|
|
||||||
- domain: 'goaccess.vakhrushev.me'
|
- domain: "goaccess.vakhrushev.me"
|
||||||
subject: 'group:admins'
|
subject: "group:admins"
|
||||||
policy: 'two_factor'
|
policy: "two_factor"
|
||||||
|
|
||||||
- domain: 'wanderbase.vakhrushev.me'
|
- domain: "wanderbase.vakhrushev.me"
|
||||||
subject: 'group:admins'
|
subject: "group:admins"
|
||||||
policy: 'two_factor'
|
policy: "two_factor"
|
||||||
|
|
||||||
- domain: 'remembos.vakhrushev.me'
|
- domain: "remembos.vakhrushev.me"
|
||||||
subject: 'group:admins'
|
subject: "group:admins"
|
||||||
policy: 'two_factor'
|
policy: "two_factor"
|
||||||
|
|
||||||
- domain: 'rssbridge.vakhrushev.me'
|
- domain: "rssbridge.vakhrushev.me"
|
||||||
subject: 'group:admins'
|
subject: "group:admins"
|
||||||
policy: 'one_factor'
|
policy: "one_factor"
|
||||||
|
|
||||||
## Domain Regex examples. Generally we recommend just using a standard domain.
|
## Domain Regex examples. Generally we recommend just using a standard domain.
|
||||||
# - domain_regex: '^(?P<User>\w+)\.example\.com$'
|
# - domain_regex: '^(?P<User>\w+)\.example\.com$'
|
||||||
@@ -826,18 +823,17 @@ access_control:
|
|||||||
session:
|
session:
|
||||||
## The secret to encrypt the session data. This is only used with Redis / Redis Sentinel.
|
## The secret to encrypt the session data. This is only used with Redis / Redis Sentinel.
|
||||||
## Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
## Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
||||||
secret: '{{ session__secret }}'
|
secret: "{{ session__secret }}"
|
||||||
|
|
||||||
## Cookies configures the list of allowed cookie domains for sessions to be created on.
|
## Cookies configures the list of allowed cookie domains for sessions to be created on.
|
||||||
## Undefined values will default to the values below.
|
## Undefined values will default to the values below.
|
||||||
cookies:
|
cookies:
|
||||||
-
|
- ## The name of the session cookie.
|
||||||
## The name of the session cookie.
|
name: "authelia_session"
|
||||||
name: 'authelia_session'
|
|
||||||
|
|
||||||
## The domain to protect.
|
## The domain to protect.
|
||||||
## Note: the Authelia portal must also be in that domain.
|
## Note: the Authelia portal must also be in that domain.
|
||||||
domain: 'vakhrushev.me'
|
domain: "vakhrushev.me"
|
||||||
|
|
||||||
## Required. The fully qualified URI of the portal to redirect users to on proxies that support redirections.
|
## Required. The fully qualified URI of the portal to redirect users to on proxies that support redirections.
|
||||||
## Rules:
|
## Rules:
|
||||||
@@ -845,7 +841,7 @@ session:
|
|||||||
## - The above 'domain' option MUST either:
|
## - The above 'domain' option MUST either:
|
||||||
## - Match the host portion of this URI.
|
## - Match the host portion of this URI.
|
||||||
## - Match the suffix of the host portion when prefixed with '.'.
|
## - Match the suffix of the host portion when prefixed with '.'.
|
||||||
authelia_url: 'https://auth.vakhrushev.me'
|
authelia_url: "https://auth.vakhrushev.me"
|
||||||
|
|
||||||
## Optional. The fully qualified URI used as the redirection location if the portal is accessed directly. Not
|
## Optional. The fully qualified URI used as the redirection location if the portal is accessed directly. Not
|
||||||
## configuring this option disables the automatic redirection behaviour.
|
## configuring this option disables the automatic redirection behaviour.
|
||||||
@@ -904,7 +900,7 @@ session:
|
|||||||
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
|
## Important: Kubernetes (or HA) users must read https://www.authelia.com/t/statelessness
|
||||||
##
|
##
|
||||||
redis:
|
redis:
|
||||||
host: 'authelia_redis'
|
host: "authelia_redis"
|
||||||
port: 6379
|
port: 6379
|
||||||
## Use a unix socket instead
|
## Use a unix socket instead
|
||||||
# host: '/var/run/redis/redis.sock'
|
# host: '/var/run/redis/redis.sock'
|
||||||
@@ -1000,19 +996,19 @@ session:
|
|||||||
## This mechanism prevents attackers from brute forcing the first factor. It bans the user if too many attempts are made
|
## This mechanism prevents attackers from brute forcing the first factor. It bans the user if too many attempts are made
|
||||||
## in a short period of time.
|
## in a short period of time.
|
||||||
# regulation:
|
# regulation:
|
||||||
## Regulation Mode.
|
## Regulation Mode.
|
||||||
# modes:
|
# modes:
|
||||||
# - 'user'
|
# - 'user'
|
||||||
|
|
||||||
## The number of failed login attempts before user is banned. Set it to 0 to disable regulation.
|
## The number of failed login attempts before user is banned. Set it to 0 to disable regulation.
|
||||||
# max_retries: 3
|
# max_retries: 3
|
||||||
|
|
||||||
## The time range during which the user can attempt login before being banned in the duration common syntax. The user
|
## The time range during which the user can attempt login before being banned in the duration common syntax. The user
|
||||||
## is banned if the authentication failed 'max_retries' times in a 'find_time' seconds window.
|
## is banned if the authentication failed 'max_retries' times in a 'find_time' seconds window.
|
||||||
# find_time: '2 minutes'
|
# find_time: '2 minutes'
|
||||||
|
|
||||||
## The length of time before a banned user can login again in the duration common syntax.
|
## The length of time before a banned user can login again in the duration common syntax.
|
||||||
# ban_time: '5 minutes'
|
# ban_time: '5 minutes'
|
||||||
|
|
||||||
##
|
##
|
||||||
## Storage Provider Configuration
|
## Storage Provider Configuration
|
||||||
@@ -1022,7 +1018,7 @@ storage:
|
|||||||
## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum
|
## The encryption key that is used to encrypt sensitive information in the database. Must be a string with a minimum
|
||||||
## length of 20. Please see the docs if you configure this with an undesirable key and need to change it, you MUST use
|
## length of 20. Please see the docs if you configure this with an undesirable key and need to change it, you MUST use
|
||||||
## the CLI to change this in the database if you want to change it from a previously configured value.
|
## the CLI to change this in the database if you want to change it from a previously configured value.
|
||||||
encryption_key: '{{ storage__encryption_key }}'
|
encryption_key: "{{ storage__encryption_key }}"
|
||||||
|
|
||||||
##
|
##
|
||||||
## Local (Storage Provider)
|
## Local (Storage Provider)
|
||||||
@@ -1034,7 +1030,7 @@ storage:
|
|||||||
##
|
##
|
||||||
local:
|
local:
|
||||||
## Path to the SQLite3 Database.
|
## Path to the SQLite3 Database.
|
||||||
path: '/data/authelia_storage.sqlite3'
|
path: "/data/authelia_storage.sqlite3"
|
||||||
|
|
||||||
##
|
##
|
||||||
## MySQL / MariaDB (Storage Provider)
|
## MySQL / MariaDB (Storage Provider)
|
||||||
@@ -1212,22 +1208,22 @@ notifier:
|
|||||||
## (configure in tls section)
|
## (configure in tls section)
|
||||||
smtp:
|
smtp:
|
||||||
## The address of the SMTP server to connect to in the address common syntax.
|
## The address of the SMTP server to connect to in the address common syntax.
|
||||||
address: 'smtp://{{ postbox_host }}:{{ postbox_port }}'
|
address: "smtp://{{ postbox_host }}:{{ postbox_port }}"
|
||||||
|
|
||||||
## The connection timeout in the duration common syntax.
|
## The connection timeout in the duration common syntax.
|
||||||
# timeout: '5 seconds'
|
# timeout: '5 seconds'
|
||||||
|
|
||||||
## The username used for SMTP authentication.
|
## The username used for SMTP authentication.
|
||||||
username: '{{ postbox_user }}'
|
username: "{{ postbox_user }}"
|
||||||
|
|
||||||
## The password used for SMTP authentication.
|
## The password used for SMTP authentication.
|
||||||
## Can also be set using a secret: https://www.authelia.com/c/secrets
|
## Can also be set using a secret: https://www.authelia.com/c/secrets
|
||||||
password: '{{ postbox_pass }}'
|
password: "{{ postbox_pass }}"
|
||||||
|
|
||||||
## The sender is used to is used for the MAIL FROM command and the FROM header.
|
## The sender is used to is used for the MAIL FROM command and the FROM header.
|
||||||
## If this is not defined and the username is an email, we use the username as this value. This can either be just
|
## If this is not defined and the username is an email, we use the username as this value. This can either be just
|
||||||
## an email address or the RFC5322 'Name <email address>' format.
|
## an email address or the RFC5322 'Name <email address>' format.
|
||||||
sender: 'Authelia <authelia@vakhrushev.me>'
|
sender: "Authelia <authelia@vakhrushev.me>"
|
||||||
|
|
||||||
## HELO/EHLO Identifier. Some SMTP Servers may reject the default of localhost.
|
## HELO/EHLO Identifier. Some SMTP Servers may reject the default of localhost.
|
||||||
# identifier: 'localhost'
|
# identifier: 'localhost'
|
||||||
@@ -1237,7 +1233,7 @@ notifier:
|
|||||||
|
|
||||||
## This address is used during the startup check to verify the email configuration is correct.
|
## This address is used during the startup check to verify the email configuration is correct.
|
||||||
## It's not important what it is except if your email server only allows local delivery.
|
## It's not important what it is except if your email server only allows local delivery.
|
||||||
startup_check_address: '{{ smtp__startup_check_address }}'
|
# startup_check_address: '{{ smtp__startup_check_address }}'
|
||||||
|
|
||||||
## By default we require some form of TLS. This disables this check though is not advised.
|
## By default we require some form of TLS. This disables this check though is not advised.
|
||||||
# disable_require_tls: false
|
# disable_require_tls: false
|
||||||
@@ -1285,7 +1281,6 @@ notifier:
|
|||||||
## Identity Providers
|
## Identity Providers
|
||||||
##
|
##
|
||||||
identity_providers:
|
identity_providers:
|
||||||
|
|
||||||
##
|
##
|
||||||
## OpenID Connect (Identity Provider)
|
## OpenID Connect (Identity Provider)
|
||||||
##
|
##
|
||||||
@@ -1294,13 +1289,12 @@ identity_providers:
|
|||||||
oidc:
|
oidc:
|
||||||
## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).
|
## The hmac_secret is used to sign OAuth2 tokens (authorization code, access tokens and refresh tokens).
|
||||||
## HMAC Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
## HMAC Secret can also be set using a secret: https://www.authelia.com/c/secrets
|
||||||
hmac_secret: '{{ oidc__hmac_secret }}'
|
hmac_secret: "{{ oidc__hmac_secret }}"
|
||||||
|
|
||||||
## The JWK's issuer option configures multiple JSON Web Keys. It's required that at least one of the JWK's
|
## The JWK's issuer option configures multiple JSON Web Keys. It's required that at least one of the JWK's
|
||||||
## configured has the RS256 algorithm. For RSA keys (RS or PS) the minimum is a 2048 bit key.
|
## configured has the RS256 algorithm. For RSA keys (RS or PS) the minimum is a 2048 bit key.
|
||||||
jwks:
|
jwks:
|
||||||
-
|
- ## Key ID embedded into the JWT header for key matching.
|
||||||
## Key ID embedded into the JWT header for key matching.
|
|
||||||
## Must be an alphanumeric string with 7 or less characters.
|
## Must be an alphanumeric string with 7 or less characters.
|
||||||
## This value is automatically generated if not provided. It's recommended to not configure this.
|
## This value is automatically generated if not provided. It's recommended to not configure this.
|
||||||
# key_id: 'example'
|
# key_id: 'example'
|
||||||
@@ -1352,8 +1346,8 @@ identity_providers:
|
|||||||
authorization_policies:
|
authorization_policies:
|
||||||
outline_policy:
|
outline_policy:
|
||||||
rules:
|
rules:
|
||||||
- policy: 'one_factor'
|
- policy: "one_factor"
|
||||||
subject: 'group:outline'
|
subject: "group:outline"
|
||||||
|
|
||||||
## The lifespans configure the expiration for these token types in the duration common syntax. In addition to this
|
## The lifespans configure the expiration for these token types in the duration common syntax. In addition to this
|
||||||
## syntax the lifespans can be customized per-client.
|
## syntax the lifespans can be customized per-client.
|
||||||
@@ -1390,53 +1384,49 @@ identity_providers:
|
|||||||
## It's recommended you read the documentation before configuration of a registered client.
|
## It's recommended you read the documentation before configuration of a registered client.
|
||||||
## See: https://www.authelia.com/c/oidc/registered-clients
|
## See: https://www.authelia.com/c/oidc/registered-clients
|
||||||
clients:
|
clients:
|
||||||
-
|
- client_name: "Miniflux"
|
||||||
client_name: 'Miniflux'
|
client_id: "{{ oidc__miniflux__client_id }}"
|
||||||
client_id: '{{ oidc__miniflux__client_id }}'
|
client_secret: "{{ oidc__miniflux__client_secret }}"
|
||||||
client_secret: '{{ oidc__miniflux__client_secret }}'
|
|
||||||
redirect_uris:
|
redirect_uris:
|
||||||
- 'https://miniflux.vakhrushev.me/oauth2/oidc/callback'
|
- "https://miniflux.vakhrushev.me/oauth2/oidc/callback"
|
||||||
scopes:
|
scopes:
|
||||||
- 'openid'
|
- "openid"
|
||||||
- 'profile'
|
- "profile"
|
||||||
- 'email'
|
- "email"
|
||||||
response_types:
|
response_types:
|
||||||
- 'code'
|
- "code"
|
||||||
grant_types:
|
grant_types:
|
||||||
- 'authorization_code'
|
- "authorization_code"
|
||||||
access_token_signed_response_alg: 'none'
|
access_token_signed_response_alg: "none"
|
||||||
userinfo_signed_response_alg: 'none'
|
userinfo_signed_response_alg: "none"
|
||||||
token_endpoint_auth_method: 'client_secret_basic'
|
token_endpoint_auth_method: "client_secret_basic"
|
||||||
|
|
||||||
-
|
- client_name: "Wakapi"
|
||||||
client_name: 'Wakapi'
|
client_id: "{{ oidc__wakapi__client_id }}"
|
||||||
client_id: '{{ oidc__wakapi__client_id }}'
|
client_secret: "{{ oidc__wakapi__client_secret }}"
|
||||||
client_secret: '{{ oidc__wakapi__client_secret }}'
|
|
||||||
redirect_uris:
|
redirect_uris:
|
||||||
- 'https://wakapi.vakhrushev.me/oidc/authelia/callback'
|
- "https://wakapi.vakhrushev.me/oidc/authelia/callback"
|
||||||
scopes:
|
scopes:
|
||||||
- 'openid'
|
- "openid"
|
||||||
- 'profile'
|
- "profile"
|
||||||
- 'email'
|
- "email"
|
||||||
# response_types:
|
# response_types:
|
||||||
# - 'code'
|
# - 'code'
|
||||||
# grant_types:
|
# grant_types:
|
||||||
# - 'authorization_code'
|
# - 'authorization_code'
|
||||||
# access_token_signed_response_alg: 'none'
|
# access_token_signed_response_alg: 'none'
|
||||||
# userinfo_signed_response_alg: 'none'
|
# userinfo_signed_response_alg: 'none'
|
||||||
# token_endpoint_auth_method: 'client_secret_basic'
|
# token_endpoint_auth_method: 'client_secret_basic'
|
||||||
|
- ## The description to show to users when they end up on the consent screen. Defaults to the ID above.
|
||||||
-
|
client_name: "Outline"
|
||||||
## The description to show to users when they end up on the consent screen. Defaults to the ID above.
|
|
||||||
client_name: 'Outline'
|
|
||||||
|
|
||||||
## The Client ID is the OAuth 2.0 and OpenID Connect 1.0 Client ID which is used to link an application to a
|
## The Client ID is the OAuth 2.0 and OpenID Connect 1.0 Client ID which is used to link an application to a
|
||||||
## configuration.
|
## configuration.
|
||||||
client_id: '{{ oidc__outline__client_id }}'
|
client_id: "{{ oidc__outline__client_id }}"
|
||||||
|
|
||||||
## The client secret is a shared secret between Authelia and the consumer of this client.
|
## The client secret is a shared secret between Authelia and the consumer of this client.
|
||||||
# yamllint disable-line rule:line-length
|
# yamllint disable-line rule:line-length
|
||||||
client_secret: '{{ oidc__outline__client_secret }}'
|
client_secret: "{{ oidc__outline__client_secret }}"
|
||||||
|
|
||||||
## Sector Identifiers are occasionally used to generate pairwise subject identifiers. In most cases this is not
|
## Sector Identifiers are occasionally used to generate pairwise subject identifiers. In most cases this is not
|
||||||
## necessary. It is critical to read the documentation for more information.
|
## necessary. It is critical to read the documentation for more information.
|
||||||
@@ -1447,7 +1437,7 @@ identity_providers:
|
|||||||
|
|
||||||
## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.
|
## Redirect URI's specifies a list of valid case-sensitive callbacks for this client.
|
||||||
redirect_uris:
|
redirect_uris:
|
||||||
- 'https://outline.vakhrushev.me/auth/oidc.callback'
|
- "https://outline.vakhrushev.me/auth/oidc.callback"
|
||||||
|
|
||||||
## Request URI's specifies a list of valid case-sensitive TLS-secured URIs for this client for use as
|
## Request URI's specifies a list of valid case-sensitive TLS-secured URIs for this client for use as
|
||||||
## URIs to fetch Request Objects.
|
## URIs to fetch Request Objects.
|
||||||
@@ -1459,9 +1449,9 @@ identity_providers:
|
|||||||
|
|
||||||
## Scopes this client is allowed to request.
|
## Scopes this client is allowed to request.
|
||||||
scopes:
|
scopes:
|
||||||
- 'openid'
|
- "openid"
|
||||||
- 'profile'
|
- "profile"
|
||||||
- 'email'
|
- "email"
|
||||||
|
|
||||||
## Grant Types configures which grants this client can obtain.
|
## Grant Types configures which grants this client can obtain.
|
||||||
## It's not recommended to define this unless you know what you're doing.
|
## It's not recommended to define this unless you know what you're doing.
|
||||||
@@ -1480,7 +1470,7 @@ identity_providers:
|
|||||||
|
|
||||||
## The policy to require for this client; one_factor or two_factor. Can also be the key names for the
|
## The policy to require for this client; one_factor or two_factor. Can also be the key names for the
|
||||||
## authorization policies section.
|
## authorization policies section.
|
||||||
authorization_policy: 'outline_policy'
|
authorization_policy: "outline_policy"
|
||||||
|
|
||||||
## The custom lifespan name to use for this client. This must be configured independent of the client before
|
## The custom lifespan name to use for this client. This must be configured independent of the client before
|
||||||
## utilization. Custom lifespans are reusable similar to authorization policies.
|
## utilization. Custom lifespans are reusable similar to authorization policies.
|
||||||
@@ -1581,7 +1571,7 @@ identity_providers:
|
|||||||
## The signing algorithm used for signing the User Info Request responses.
|
## The signing algorithm used for signing the User Info Request responses.
|
||||||
## Please read the documentation before adjusting this option.
|
## Please read the documentation before adjusting this option.
|
||||||
## See: https://www.authelia.com/c/oidc/registered-clients#userinfo_signed_response_alg
|
## See: https://www.authelia.com/c/oidc/registered-clients#userinfo_signed_response_alg
|
||||||
userinfo_signed_response_alg: 'none'
|
userinfo_signed_response_alg: "none"
|
||||||
|
|
||||||
## The signing key id used for signing the User Info Request responses.
|
## The signing key id used for signing the User Info Request responses.
|
||||||
## Please read the documentation before adjusting this option.
|
## Please read the documentation before adjusting this option.
|
||||||
@@ -1645,7 +1635,7 @@ identity_providers:
|
|||||||
## The permitted client authentication method for the Token Endpoint for this client.
|
## The permitted client authentication method for the Token Endpoint for this client.
|
||||||
## For confidential client types this value defaults to 'client_secret_basic' and for the public client types it
|
## For confidential client types this value defaults to 'client_secret_basic' and for the public client types it
|
||||||
## defaults to 'none' per the specifications.
|
## defaults to 'none' per the specifications.
|
||||||
token_endpoint_auth_method: 'client_secret_post'
|
token_endpoint_auth_method: "client_secret_post"
|
||||||
|
|
||||||
## The permitted client authentication signing algorithm for the Token Endpoint for this client when using
|
## The permitted client authentication signing algorithm for the Token Endpoint for this client when using
|
||||||
## the 'client_secret_jwt' or 'private_key_jwt' token_endpoint_auth_method.
|
## the 'client_secret_jwt' or 'private_key_jwt' token_endpoint_auth_method.
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
authelia_app:
|
authelia_app:
|
||||||
container_name: 'authelia_app'
|
container_name: "authelia_app"
|
||||||
image: 'docker.io/authelia/authelia:4.39.19'
|
image: "docker.io/authelia/authelia:4.39.20"
|
||||||
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||||
restart: 'unless-stopped'
|
restart: "unless-stopped"
|
||||||
networks:
|
networks:
|
||||||
- "web_proxy_network"
|
- "web_proxy_network"
|
||||||
- "monitoring_network"
|
- "monitoring_network"
|
||||||
|
|||||||
+288
-70
@@ -3,21 +3,33 @@
|
|||||||
Backup script for all applications
|
Backup script for all applications
|
||||||
Automatically discovers and runs backup scripts for all users,
|
Automatically discovers and runs backup scripts for all users,
|
||||||
then creates restic backups and sends notifications.
|
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 itertools
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import pwd
|
import pwd
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import time
|
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
|
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
|
# Default config path
|
||||||
CONFIG_PATH = Path("/etc/backup/config.toml")
|
CONFIG_PATH = Path("/etc/backup/config.toml")
|
||||||
@@ -29,6 +41,22 @@ BACKUP_TARGETS_FILE = "backup-targets"
|
|||||||
# Used when backup-targets file not exists
|
# Used when backup-targets file not exists
|
||||||
BACKUP_DEFAULT_DIR = "backups"
|
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
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -46,6 +74,42 @@ class Config:
|
|||||||
host_name: str
|
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
|
@dataclass
|
||||||
class Application:
|
class Application:
|
||||||
path: Path
|
path: Path
|
||||||
@@ -54,11 +118,18 @@ class Application:
|
|||||||
backup_targets: List[Path]
|
backup_targets: List[Path]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BackupResult:
|
||||||
|
success: bool
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StorageRunResult:
|
class StorageRunResult:
|
||||||
name: str
|
name: str
|
||||||
success: bool
|
success: bool
|
||||||
duration: float
|
duration: float
|
||||||
|
phases: List[str]
|
||||||
|
|
||||||
|
|
||||||
def format_duration(seconds: float) -> str:
|
def format_duration(seconds: float) -> str:
|
||||||
@@ -76,8 +147,13 @@ def format_duration(seconds: float) -> str:
|
|||||||
class Storage(ABC):
|
class Storage(ABC):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
def backup(self, backup_dirs: List[str]) -> bool:
|
def run(
|
||||||
"""Backup directories"""
|
self,
|
||||||
|
backup_dirs: List[str],
|
||||||
|
phases: List[str],
|
||||||
|
maintenance: MaintenanceOptions,
|
||||||
|
) -> BackupResult:
|
||||||
|
"""Run the requested phases against this storage."""
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
@@ -101,69 +177,125 @@ class ResticStorage(Storage):
|
|||||||
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
f"Missing storage configuration values for backend ResticStorage: '{self.name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
def backup(self, backup_dirs: List[str]) -> bool:
|
def run(
|
||||||
if not backup_dirs:
|
self,
|
||||||
logger.warning("No backup directories found")
|
backup_dirs: List[str],
|
||||||
return True
|
phases: List[str],
|
||||||
|
maintenance: MaintenanceOptions,
|
||||||
|
) -> BackupResult:
|
||||||
try:
|
try:
|
||||||
return self.__backup_internal(backup_dirs)
|
return self.__run_internal(backup_dirs, phases, maintenance)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error("Restic backup process failed: %s", exc)
|
logger.error("Restic process failed: %s", exc)
|
||||||
return False
|
return BackupResult(success=False, error=str(exc))
|
||||||
|
|
||||||
def __backup_internal(self, backup_dirs: List[str]) -> bool:
|
def __build_steps(
|
||||||
logger.info("Starting restic backup for storage '%s'", self.name)
|
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("Destination: %s", self.restic_repository)
|
||||||
|
logger.info("Phases: %s", ", ".join(phases))
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env["RESTIC_REPOSITORY"] = self.restic_repository
|
env["RESTIC_REPOSITORY"] = self.restic_repository
|
||||||
env["RESTIC_PASSWORD"] = self.restic_password
|
env["RESTIC_PASSWORD"] = self.restic_password
|
||||||
env.update(self.env)
|
env.update(self.env)
|
||||||
|
|
||||||
backup_cmd = ["restic", "backup", "--verbose"] + backup_dirs
|
steps = self.__build_steps(backup_dirs, phases, maintenance)
|
||||||
result = subprocess.run(backup_cmd, env=env, capture_output=True, text=True)
|
|
||||||
|
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:
|
if result.returncode != 0:
|
||||||
logger.error("Restic backup failed: %s", result.stderr)
|
error = result.stderr.strip() or result.stdout.strip() or "no output"
|
||||||
return False
|
logger.error("Restic %s failed: %s", step, error)
|
||||||
|
return error
|
||||||
|
|
||||||
logger.info("Restic backup completed successfully")
|
logger.info("Restic %s completed successfully", step)
|
||||||
|
return None
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Notifier(ABC):
|
class Notifier(ABC):
|
||||||
@@ -300,6 +432,9 @@ class BackupManager:
|
|||||||
config: Config,
|
config: Config,
|
||||||
storages: List[Storage],
|
storages: List[Storage],
|
||||||
notifiers: List[Notifier],
|
notifiers: List[Notifier],
|
||||||
|
schedule: Schedule,
|
||||||
|
maintenance: MaintenanceOptions,
|
||||||
|
forced_phases: Optional[List[str]] = None,
|
||||||
):
|
):
|
||||||
self.errors: List[str] = []
|
self.errors: List[str] = []
|
||||||
self.warnings: List[str] = []
|
self.warnings: List[str] = []
|
||||||
@@ -307,6 +442,10 @@ class BackupManager:
|
|||||||
self.config = config
|
self.config = config
|
||||||
self.storages = storages
|
self.storages = storages
|
||||||
self.notifiers = notifiers
|
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.archive_duration: float = 0.0
|
||||||
self.storage_results: List[StorageRunResult] = []
|
self.storage_results: List[StorageRunResult] = []
|
||||||
|
|
||||||
@@ -315,8 +454,17 @@ class BackupManager:
|
|||||||
logger.info("Starting backup process")
|
logger.info("Starting backup process")
|
||||||
logger.info(f"Found {len(applications)} application directories")
|
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()
|
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:
|
for app in applications:
|
||||||
app_dir = str(app.path)
|
app_dir = str(app.path)
|
||||||
username = app.owner
|
username = app.owner
|
||||||
@@ -331,6 +479,8 @@ class BackupManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
self._run_app_backup(str(app.backup_script), app_dir, username)
|
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
|
self.archive_duration = time.monotonic() - archive_start
|
||||||
logger.info(
|
logger.info(
|
||||||
"Archive phase finished in %s", format_duration(self.archive_duration)
|
"Archive phase finished in %s", format_duration(self.archive_duration)
|
||||||
@@ -352,31 +502,37 @@ class BackupManager:
|
|||||||
for storage in self.storages:
|
for storage in self.storages:
|
||||||
storage_start = time.monotonic()
|
storage_start = time.monotonic()
|
||||||
try:
|
try:
|
||||||
backup_result = storage.backup(backup_dirs)
|
backup_result = storage.run(
|
||||||
|
backup_dirs, self.active_phases, self.maintenance
|
||||||
|
)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.error(
|
logger.error(
|
||||||
"Storage '%s' raised an unexpected error: %s", storage.name, exc
|
"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
|
storage_duration = time.monotonic() - storage_start
|
||||||
self.storage_results.append(
|
self.storage_results.append(
|
||||||
StorageRunResult(
|
StorageRunResult(
|
||||||
name=storage.name,
|
name=storage.name,
|
||||||
success=backup_result,
|
success=backup_result.success,
|
||||||
duration=storage_duration,
|
duration=storage_duration,
|
||||||
|
phases=list(self.active_phases),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Storage '%s' finished in %s (success=%s)",
|
"Storage '%s' finished in %s (success=%s)",
|
||||||
storage.name,
|
storage.name,
|
||||||
format_duration(storage_duration),
|
format_duration(storage_duration),
|
||||||
backup_result,
|
backup_result.success,
|
||||||
)
|
)
|
||||||
if not backup_result:
|
if not backup_result.success:
|
||||||
self.errors.append(f"Storage '{storage.name}' backup failed")
|
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
|
# Determine overall success
|
||||||
overall_success = overall_success and backup_result
|
overall_success = overall_success and backup_result.success
|
||||||
|
|
||||||
# Send notification
|
# Send notification
|
||||||
self._send_notification(overall_success)
|
self._send_notification(overall_success)
|
||||||
@@ -436,6 +592,7 @@ class BackupManager:
|
|||||||
"""Send notification to Notifiers"""
|
"""Send notification to Notifiers"""
|
||||||
|
|
||||||
host = self.config.host_name
|
host = self.config.host_name
|
||||||
|
phases_text = ", ".join(self.active_phases) if self.active_phases else "—"
|
||||||
|
|
||||||
if success and not self.errors:
|
if success and not self.errors:
|
||||||
title = f"{host}: бекап успешно завершен"
|
title = f"{host}: бекап успешно завершен"
|
||||||
@@ -459,6 +616,7 @@ class BackupManager:
|
|||||||
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
items = "".join(f"<li>{e}</li>" for e in self.errors)
|
||||||
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
message += f"<p>❌ Ошибки:</p><ul>{items}</ul>"
|
||||||
|
|
||||||
|
message += f"<p>🔧 Фазы restic: {phases_text}</p>"
|
||||||
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
|
message += f"<p>⏱ Время архивации: {format_duration(self.archive_duration)}</p>"
|
||||||
if self.storage_results:
|
if self.storage_results:
|
||||||
items = "".join(
|
items = "".join(
|
||||||
@@ -474,8 +632,21 @@ class BackupManager:
|
|||||||
logger.error(f"Failed to send notification: {str(e)}")
|
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(
|
def initialize(
|
||||||
config_path: Path,
|
config_path: Path,
|
||||||
|
forced_phases: Optional[List[str]] = None,
|
||||||
) -> tuple[ApplicationFinder, BackupManager]:
|
) -> tuple[ApplicationFinder, BackupManager]:
|
||||||
try:
|
try:
|
||||||
with config_path.open("rb") as config_file:
|
with config_path.open("rb") as config_file:
|
||||||
@@ -513,18 +684,65 @@ def initialize(
|
|||||||
if not notifiers:
|
if not notifiers:
|
||||||
raise ValueError("At least one notification backend must be configured")
|
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)
|
config = Config(host_name=host_name)
|
||||||
app_finder = ApplicationFinder(roots)
|
app_finder = ApplicationFinder(roots)
|
||||||
backup_manager = BackupManager(
|
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
|
return app_finder, backup_manager
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
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:
|
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()
|
applications = app_finder.find_applications()
|
||||||
backup_manager.warnings.extend(app_finder.warnings)
|
backup_manager.warnings.extend(app_finder.warnings)
|
||||||
success = backup_manager.run_backup_process(applications)
|
success = backup_manager.run_backup_process(applications)
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ roots = [
|
|||||||
"{{ application_dir }}"
|
"{{ 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]
|
[storage.yandex_cloud_s3]
|
||||||
type = "restic"
|
type = "restic"
|
||||||
restic_repository = "{{ restic_repository }}"
|
restic_repository = "{{ restic_repository }}"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
caddyproxy:
|
caddyproxy:
|
||||||
image: caddy:2.11.2
|
image: caddy:2.11.3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: "caddyproxy"
|
container_name: "caddyproxy"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
dozzle_app:
|
dozzle_app:
|
||||||
image: amir20/dozzle:v10.6.0
|
image: amir20/dozzle:v10.6.2
|
||||||
container_name: dozzle_app
|
container_name: dozzle_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
gitea_app:
|
gitea_app:
|
||||||
image: gitea/gitea:1.26.1
|
image: gitea/gitea:1.26.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: gitea_app
|
container_name: gitea_app
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
goaccess_processor:
|
goaccess_processor:
|
||||||
build: .
|
build: .
|
||||||
image: local/goaccess-jq:1.10.2
|
image: local/goaccess-jq:1.10.2
|
||||||
@@ -26,7 +25,7 @@ services:
|
|||||||
- "web_proxy_network"
|
- "web_proxy_network"
|
||||||
|
|
||||||
goaccess_app:
|
goaccess_app:
|
||||||
image: caddy:2.11.2
|
image: caddy:2.11.3
|
||||||
container_name: goaccess_app
|
container_name: goaccess_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
|
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
|
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
gramps_app: &gramps_app
|
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
|
container_name: gramps_app
|
||||||
depends_on:
|
depends_on:
|
||||||
- gramps_redis
|
- gramps_redis
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
memos_app:
|
memos_app:
|
||||||
image: neosmemo/memos:0.28.0
|
image: neosmemo/memos:0.29.1
|
||||||
container_name: memos_app
|
container_name: memos_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
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
|
# See sample https://github.com/outline/outline/blob/main/.env.sample
|
||||||
|
|
||||||
outline_app:
|
outline_app:
|
||||||
image: outlinewiki/outline:1.7.1
|
image: outlinewiki/outline:1.8.1
|
||||||
container_name: outline_app
|
container_name: outline_app
|
||||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
wakapi_app:
|
wakapi_app:
|
||||||
image: ghcr.io/muety/wakapi:2.17.3
|
image: ghcr.io/muety/wakapi:2.17.4
|
||||||
container_name: wakapi_app
|
container_name: wakapi_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
name: "restic backup"
|
name: "restic backup"
|
||||||
minute: "0"
|
minute: "0"
|
||||||
hour: "1"
|
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"
|
cron_file: "ansible_restic_backup"
|
||||||
user: "root"
|
user: "root"
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
- htop
|
- htop
|
||||||
- jq
|
- jq
|
||||||
- make
|
- make
|
||||||
|
- python3-croniter
|
||||||
- python3-pip
|
- python3-pip
|
||||||
|
- python3-requests
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- tree
|
- tree
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ansible>=13.2.0",
|
"ansible>=13.2.0",
|
||||||
"ansible-lint>=25.12.2",
|
"ansible-lint>=25.12.2",
|
||||||
|
"croniter>=6.0.0",
|
||||||
"invoke>=2.2.1",
|
"invoke>=2.2.1",
|
||||||
"mypy>=1.19.1",
|
"mypy>=1.19.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"ruff>=0.15.2",
|
"ruff>=0.15.2",
|
||||||
|
"types-croniter>=6.0.0",
|
||||||
"types-requests>=2.32.4.20260107",
|
"types-requests>=2.32.4.20260107",
|
||||||
"yamllint>=1.37.1",
|
"yamllint>=1.37.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from invoke.context import Context
|
|||||||
from invoke.exceptions import Exit
|
from invoke.exceptions import Exit
|
||||||
from invoke.tasks import task
|
from invoke.tasks import task
|
||||||
|
|
||||||
HOSTS_FILE = "production.yml"
|
HOSTS_FILE = "timeweb.yml"
|
||||||
VARS_FILE = "vars/vars.yml"
|
VARS_FILE = "vars/vars.yml"
|
||||||
AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia"
|
AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia"
|
||||||
|
|
||||||
|
|||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.3"
|
version = "46.0.3"
|
||||||
@@ -593,10 +605,12 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ansible" },
|
{ name = "ansible" },
|
||||||
{ name = "ansible-lint" },
|
{ name = "ansible-lint" },
|
||||||
|
{ name = "croniter" },
|
||||||
{ name = "invoke" },
|
{ name = "invoke" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "types-croniter" },
|
||||||
{ name = "types-requests" },
|
{ name = "types-requests" },
|
||||||
{ name = "yamllint" },
|
{ name = "yamllint" },
|
||||||
]
|
]
|
||||||
@@ -605,10 +619,12 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "ansible", specifier = ">=13.2.0" },
|
{ name = "ansible", specifier = ">=13.2.0" },
|
||||||
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
||||||
|
{ name = "croniter", specifier = ">=6.0.0" },
|
||||||
{ name = "invoke", specifier = ">=2.2.1" },
|
{ name = "invoke", specifier = ">=2.2.1" },
|
||||||
{ name = "mypy", specifier = ">=1.19.1" },
|
{ name = "mypy", specifier = ">=1.19.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "ruff", specifier = ">=0.15.2" },
|
{ name = "ruff", specifier = ">=0.15.2" },
|
||||||
|
{ name = "types-croniter", specifier = ">=6.0.0" },
|
||||||
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
|
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
|
||||||
{ name = "yamllint", specifier = ">=1.37.1" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.3.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "subprocess-tee"
|
name = "subprocess-tee"
|
||||||
version = "0.4.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "types-requests"
|
name = "types-requests"
|
||||||
version = "2.32.4.20260107"
|
version = "2.32.4.20260107"
|
||||||
|
|||||||
Reference in New Issue
Block a user