Compare commits
40 Commits
ca6875eaad
...
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
|
|||
|
a3e53b21e6
|
|||
|
1b120e3ae6
|
|||
|
a22be7c7d1
|
|||
|
7d711425fd
|
|||
|
e03a4c417d
|
|||
|
10e1e8187b
|
|||
|
1ce168655d
|
|||
|
fe024b3b12
|
|||
|
8378f0edb0
|
|||
|
48737c1b6d
|
|||
|
600a30ec11
|
|||
|
670947fcdf
|
|||
|
3545905cbd
|
|||
|
893996f0c9
|
|||
|
4a5db6e2bc
|
@@ -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,82 @@
|
|||||||
|
# Алерты на проблемные контейнеры
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Случай с wakapi: при старте упали миграции, контейнер встал в restart-loop и
|
||||||
|
несколько дней крутился по кругу — никто не узнал. Из этого две проблемы:
|
||||||
|
|
||||||
|
1. Контейнеры могут бесконечно перезапускаться при ошибке.
|
||||||
|
2. Нет алертов о таких ситуациях.
|
||||||
|
|
||||||
|
## Что есть и что использовать
|
||||||
|
|
||||||
|
- **Netdata** — Docker-collector через cgroups + Docker API: состояние,
|
||||||
|
restart count, healthcheck status. Алерты в `health.d/*.conf`, нотификации
|
||||||
|
через `health_alarm_notify.conf` (Telegram/Discord/email/ntfy).
|
||||||
|
- **Dozzle** — только для просмотра логов после факта, нормальных алертов нет.
|
||||||
|
- **Caddy** — мог бы участвовать в healthcheck снаружи, но это отдельный слой.
|
||||||
|
|
||||||
|
## План — три слоя
|
||||||
|
|
||||||
|
### 1. Healthchecks в compose (фундамент)
|
||||||
|
|
||||||
|
Без них Docker считает контейнер «running», пока процесс жив, — wakapi с
|
||||||
|
падающими миграциями этому условию удовлетворял. Добавить в каждый
|
||||||
|
`docker-compose.yml.j2`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-q", "-O-", "http://localhost:PORT/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s # окно на миграции — failed-проверки до истечения не считаются
|
||||||
|
```
|
||||||
|
|
||||||
|
`start_period` — ключевая штука для случая wakapi: даём миграциям отработать,
|
||||||
|
до его истечения healthcheck не убивает контейнер.
|
||||||
|
|
||||||
|
### 2. Алерты через Netdata (главное)
|
||||||
|
|
||||||
|
Два разных сигнала:
|
||||||
|
|
||||||
|
- **Restart loop** — алерт на `docker.container_state` или счётчик
|
||||||
|
перезапусков (растёт > N за M минут). Это и есть «контейнер крутится по
|
||||||
|
кругу».
|
||||||
|
- **Unhealthy** — после healthcheck выше алерт на
|
||||||
|
`docker.container_health_status != healthy` дольше M минут.
|
||||||
|
|
||||||
|
Канал нотификаций: один, проще всего Telegram-бот. Настройка в
|
||||||
|
`health_alarm_notify.conf`.
|
||||||
|
|
||||||
|
### 3. Restart policy — что менять (или не менять)
|
||||||
|
|
||||||
|
Скорее **оставить `unless-stopped`**. Альтернативы и их минусы:
|
||||||
|
|
||||||
|
- `on-failure:5` — Docker сам остановит после 5 попыток. Минус: после ребута
|
||||||
|
сервера сервис не поднимется (только `always`/`unless-stopped` встают на
|
||||||
|
старте докера). Серьёзный регресс для домашнего сервера.
|
||||||
|
- Внешний sidecar, слушающий `docker events` и останавливающий контейнер
|
||||||
|
после N рестартов в окне — лишняя сложность ради того, что уже сделает
|
||||||
|
алерт.
|
||||||
|
|
||||||
|
Лучше: алерт пришёл → решаем вручную, остановить или чинить.
|
||||||
|
|
||||||
|
## Опционально (вне netdata)
|
||||||
|
|
||||||
|
- **Uptime Kuma** — внешний HTTP-чек по публичным URL. Ловит случаи, когда
|
||||||
|
контейнер «здоров», но прокся/DNS/Caddy сломаны. Свои нотификации, дашборд.
|
||||||
|
Не дублирует netdata, проверяет с другой стороны.
|
||||||
|
|
||||||
|
## Шаги при реализации
|
||||||
|
|
||||||
|
1. Добавить healthcheck + start_period в compose-шаблоны (начать с wakapi,
|
||||||
|
потом по списку).
|
||||||
|
2. Проверить, что netdata собирает Docker-метрики (collector включён).
|
||||||
|
3. Настроить один канал нотификаций (Telegram/ntfy/email — выбрать).
|
||||||
|
4. Написать пару алертов: restart-loop и unhealthy.
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
- Какой канал нотификаций использовать.
|
||||||
|
- Добавлять ли Uptime Kuma сразу или потом.
|
||||||
@@ -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** — фоновая зачистка стиля и структуры.
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
# Gitea runner on-demand в Yandex Cloud
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
В YC планируется развернуть self-hosted раннер для Gitea Actions. Сборки —
|
||||||
|
несколько раз в неделю, в среднем ~10 в неделю по ~5 минут. ВМ 24/7 даёт
|
||||||
|
утилизацию в районе 1%, остальное оплачивается впустую.
|
||||||
|
|
||||||
|
Цель — раннер активен только во время сборки и небольшого окна простоя
|
||||||
|
после, без ручных команд от разработчика.
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
```
|
||||||
|
push → Gitea ──webhook──► Cloud Function ──Compute API──► ВМ (раннер)
|
||||||
|
(HMAC validate, │
|
||||||
|
start logic) ▼
|
||||||
|
act_runner (docker)
|
||||||
|
probe + decide
|
||||||
|
│
|
||||||
|
└─REST self-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
Без API Gateway: функция публикуется напрямую через свой HTTPS-эндпоинт
|
||||||
|
`https://functions.yandexcloud.net/<id>`. Этот URL вписывается в Gitea
|
||||||
|
webhook. Аутентификация — HMAC-SHA256 в заголовке `X-Gitea-Signature`,
|
||||||
|
проверяется внутри функции.
|
||||||
|
|
||||||
|
Поток событий:
|
||||||
|
|
||||||
|
1. Push в Gitea → System Webhook на URL функции.
|
||||||
|
2. Функция валидирует HMAC, читает state ВМ, действует по стейт-машине
|
||||||
|
(см. ниже).
|
||||||
|
3. ВМ стартует, docker поднимает контейнер `act_runner`, тот подключается
|
||||||
|
к Gitea и забирает джобу.
|
||||||
|
4. На ВМ работают probe (раз в 30 сек собирает телеметрию) и decide
|
||||||
|
(раз в 1 мин принимает решение).
|
||||||
|
5. После idle-окна decide дёргает Compute REST API на gas самой себя.
|
||||||
|
|
||||||
|
## Cloud-side
|
||||||
|
|
||||||
|
### Ресурсы в YC
|
||||||
|
|
||||||
|
- Один фолдер на старте — общий с Gitea-сервером. Принятый риск: SA
|
||||||
|
самогашения формально может остановить любую ВМ в фолдере. Перенос в
|
||||||
|
отдельный фолдер — миграция на потом.
|
||||||
|
- Два сервисных аккаунта:
|
||||||
|
- `runner-self-stop` (привязан к ВМ): `compute.instances.stop`,
|
||||||
|
`compute.instances.get`.
|
||||||
|
- `runner-starter-fn` (привязан к функции): `compute.instances.start`,
|
||||||
|
`compute.instances.get`.
|
||||||
|
- Cloud Function `runner-starter`, runtime Python 3.11, 256 MB, timeout
|
||||||
|
30 сек. Публичный HTTPS-эндпоинт включён.
|
||||||
|
- Алерт Cloud Monitoring: `compute.instance.status = RUNNING` дольше 24 ч
|
||||||
|
подряд → нотификация (канал — на этапе внедрения).
|
||||||
|
|
||||||
|
### Bootstrap-скрипты
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/
|
||||||
|
├── runner-starter/ # код Cloud Function
|
||||||
|
│ ├── handler.py # webhook → start, стейт-машина
|
||||||
|
│ └── requirements.txt
|
||||||
|
├── runner_bootstrap.py # one-time: создать SA, ВМ, функцию, алерт
|
||||||
|
└── runner_deploy_function.py # обновить версию функции (yc CLI)
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипты на Python поверх `yc` CLI (через `subprocess`). Идемпотентность —
|
||||||
|
проверкой существования ресурсов перед созданием. Terraform не вводим:
|
||||||
|
ресурсов мало, оверкилл.
|
||||||
|
|
||||||
|
### Стейт-машина функции
|
||||||
|
|
||||||
|
| State ВМ | Действие |
|
||||||
|
| ------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `RUNNING`, `STARTING`, `RESTARTING` | 200, ничего не делаем |
|
||||||
|
| `STOPPED` | `instances:start` → 200 |
|
||||||
|
| `STOPPING` | poll до `STOPPED` (до 25 сек), затем `start` → 200 |
|
||||||
|
| `PROVISIONING`, `UPDATING` | 503 (временное состояние, retry клиентом) |
|
||||||
|
| `ERROR`, `CRASHED` | 500 + лог ошибки (нужен человек) |
|
||||||
|
| `DELETING`, `DELETED` | 500 + лог ошибки (что-то очень не так) |
|
||||||
|
|
||||||
|
## Host-side
|
||||||
|
|
||||||
|
### ВМ
|
||||||
|
|
||||||
|
- 2 vCPU (100%), 4 GB RAM, 25 GB network-hdd.
|
||||||
|
- Ubuntu 22.04 LTS.
|
||||||
|
- Без публичного IP при возможности (все исходящие к Gitea — через NAT
|
||||||
|
или внутренний адрес).
|
||||||
|
- Привязан SA `runner-self-stop`.
|
||||||
|
- Регистрация в Gitea Actions делается **один раз** при первой настройке.
|
||||||
|
Registration token берётся в Site Admin → Actions → Runners, кладётся
|
||||||
|
в Vault. Плейбук проверяет наличие `.runner` файла на ВМ; если есть —
|
||||||
|
пропускает регистрацию.
|
||||||
|
|
||||||
|
### Плейбук `playbook-gitea-runner.yml`
|
||||||
|
|
||||||
|
Стандартная структура проекта:
|
||||||
|
|
||||||
|
- `roles/owner` — пользователь `gitea-runner` (uid/gid выделить
|
||||||
|
отдельные, в группе `docker`).
|
||||||
|
- `files/gitea-runner/`:
|
||||||
|
- `docker-compose.template.yml` — `act_runner` в docker
|
||||||
|
(`gitea/act_runner:<pinned>`), `restart: unless-stopped`, mount
|
||||||
|
docker socket для запуска job-контейнеров.
|
||||||
|
- `act-runner-config.template.yaml` — конфиг раннера.
|
||||||
|
- `runner-probe.template.py` + `runner-probe.template.service` +
|
||||||
|
`runner-probe.template.timer`.
|
||||||
|
- `runner-decide.template.py` + `runner-decide.template.service` +
|
||||||
|
`runner-decide.template.timer`.
|
||||||
|
- `samples-logrotate.template.conf` — ротация `samples.log`.
|
||||||
|
|
||||||
|
Расширения шаблонов — `.template.<ext>`, не `.j2` (соглашение проекта).
|
||||||
|
|
||||||
|
### Раннер в docker
|
||||||
|
|
||||||
|
`act_runner` стартует через `docker compose up -d` под пользователем
|
||||||
|
`gitea-runner`. Поскольку `restart: unless-stopped`, дополнительный
|
||||||
|
systemd-юнит для самого раннера не нужен — после `Start` ВМ docker
|
||||||
|
поднимет контейнер автоматически.
|
||||||
|
|
||||||
|
Идентификатор контейнера фиксированный (`gitea_runner_app`), чтобы probe
|
||||||
|
мог исключать его из счёта.
|
||||||
|
|
||||||
|
### Probe и decide
|
||||||
|
|
||||||
|
Два независимых юнита, телеметрия — append-only лог.
|
||||||
|
|
||||||
|
`runner-probe` (timer раз в 30 сек):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# pseudocode
|
||||||
|
busy_count=$(docker ps -q | grep -v <runner_container_id> | wc -l)
|
||||||
|
state=$([ "$busy_count" -gt 0 ] && echo busy || echo idle)
|
||||||
|
echo "$(date -u +%FT%TZ) $state containers=$busy_count" \
|
||||||
|
>> /var/lib/runner-idle/samples.log
|
||||||
|
```
|
||||||
|
|
||||||
|
В реальной реализации — Python, фильтр по docker SDK или по результату
|
||||||
|
`docker ps --format '{{.Names}}'`.
|
||||||
|
|
||||||
|
`runner-decide` (timer раз в 1 мин):
|
||||||
|
|
||||||
|
1. Читает хвост `samples.log`.
|
||||||
|
2. Находит `last_busy_at` — timestamp последней `busy`-строки.
|
||||||
|
3. Находит `last_sample_at` — timestamp последней любой строки.
|
||||||
|
4. Логика:
|
||||||
|
- `now - last_sample_at > STALE_THRESHOLD` (5 мин) → **probe сломан**,
|
||||||
|
не гасим, логируем error. Алерт CM поймает по uptime.
|
||||||
|
- `now - last_busy_at > IDLE_THRESHOLD` (10 мин) → `instances:stop`
|
||||||
|
через REST.
|
||||||
|
- Иначе → ничего.
|
||||||
|
|
||||||
|
Параметры (`IDLE_THRESHOLD`, `STALE_THRESHOLD`) — переменные в шаблоне,
|
||||||
|
тюнятся по эксплуатации.
|
||||||
|
|
||||||
|
### Самогашение через REST
|
||||||
|
|
||||||
|
Без `yc` CLI. Decide-скрипт получает IAM-токен из metadata-сервиса и
|
||||||
|
дёргает Compute REST:
|
||||||
|
|
||||||
|
```python
|
||||||
|
TOKEN_URL = "http://169.254.169.254/computeMetadata/v1/instance/" \
|
||||||
|
"service-accounts/default/token"
|
||||||
|
ID_URL = "http://169.254.169.254/computeMetadata/v1/instance/id"
|
||||||
|
HEADERS = {"Metadata-Flavor": "Google"}
|
||||||
|
|
||||||
|
token = requests.get(TOKEN_URL, headers=HEADERS).json()["access_token"]
|
||||||
|
instance_id = requests.get(ID_URL, headers=HEADERS).text
|
||||||
|
requests.post(
|
||||||
|
f"https://compute.api.cloud.yandex.net/compute/v1/instances/{instance_id}:stop",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Никаких файлов с SA-key, никаких зависимостей сверх `python3 +
|
||||||
|
requests`.
|
||||||
|
|
||||||
|
## Страховки от зависшей ВМ
|
||||||
|
|
||||||
|
Главный failure mode — probe или decide молча сломались, ВМ работает 24/7.
|
||||||
|
|
||||||
|
Слой 1 — soft idle-stop в decide. Нормальная работа.
|
||||||
|
|
||||||
|
Слой 2 — probe-staleness в decide. Если `samples.log` не обновляется
|
||||||
|
дольше `STALE_THRESHOLD` — логируем error, **не гасим** (мог идти
|
||||||
|
длинный билд). Полагаемся на слой 3.
|
||||||
|
|
||||||
|
Слой 3 — внешний алерт через Cloud Monitoring на uptime ВМ > 24 ч. Не
|
||||||
|
дёргает остановку, только нотификация. Порог высокий, чтобы дни активной
|
||||||
|
отладки не триггерили его. Если фактически висит сутки — это сигнал
|
||||||
|
смотреть руками.
|
||||||
|
|
||||||
|
Hard-cap по uptime внутри decide **не делаем**: ломает кейс «активно
|
||||||
|
тестирую несколько часов подряд», когда busy-сэмплы есть и логика идёт
|
||||||
|
правильно.
|
||||||
|
|
||||||
|
## Секреты (Vault, `vars/secrets.yml`)
|
||||||
|
|
||||||
|
| Ключ | Назначение |
|
||||||
|
| --------------------------------- | ----------------------------------------------------- |
|
||||||
|
| `gitea_runner_registration_token` | одноразовый токен для `act_runner register` |
|
||||||
|
| `gitea_webhook_secret` | общий с функцией HMAC-секрет для webhook |
|
||||||
|
| `yc_runner_folder_id` | в каком фолдере живёт ВМ |
|
||||||
|
| `yc_runner_instance_id` | ID ВМ (заполняется после bootstrap) |
|
||||||
|
| `yc_runner_function_url` | URL функции для webhook (заполняется после bootstrap) |
|
||||||
|
|
||||||
|
## invoke-таски
|
||||||
|
|
||||||
|
| Таск | Что делает |
|
||||||
|
| ----------------------------- | ----------------------------------------------------------------------------- |
|
||||||
|
| `inv runner-bootstrap` | one-time: создаёт SA, ВМ, функцию, алерт. Идемпотентен. |
|
||||||
|
| `inv runner-deploy-function` | заливает новую версию `runner-starter`. |
|
||||||
|
| `inv runner-pl` | up → `ansible-playbook playbook-gitea-runner.yml` → down. С `try/finally`. |
|
||||||
|
| `inv runner-up` / `down` | ручной старт/стоп ВМ для дебага. |
|
||||||
|
| `inv runner-status` | state ВМ + хвост `samples.log` (через ssh). |
|
||||||
|
| `inv runner-ssh` | ssh на ВМ, поднимает её при необходимости. |
|
||||||
|
|
||||||
|
`runner-pl` — основной таск, единственный «штатный» путь обновления
|
||||||
|
конфига ВМ. Если плейбук падает посередине, `finally` всё равно гасит ВМ
|
||||||
|
(idle-watch её и так бы погасил, но явное лучше).
|
||||||
|
|
||||||
|
## Стоимость
|
||||||
|
|
||||||
|
Базовая ставка YC (USD, после повышения 1 мая 2026): vCPU 100% =
|
||||||
|
$0.010164/ч, RAM = $0.002705/ГБ·ч, network-hdd = $0.0000356/ГБ·ч.
|
||||||
|
|
||||||
|
Профиль: 10,75 ч активной ВМ в месяц.
|
||||||
|
|
||||||
|
| Конфиг (2 vCPU 100%, 4 GB RAM, 25 GB HDD) | $/мес |
|
||||||
|
| ----------------------------------------- | ----- |
|
||||||
|
| Compute (vCPU + RAM) при 10,75 ч | ~0.33 |
|
||||||
|
| Disk (HDD, 24/7) | ~0.64 |
|
||||||
|
| Cloud Function, Monitoring | 0.00 |
|
||||||
|
| **Итого** | **~1.0** |
|
||||||
|
|
||||||
|
Сравнение: эта же ВМ в режиме 24/7 ≈ $23/мес. Экономия — порядка 95%.
|
||||||
|
|
||||||
|
Дальнейшая оптимизация — диск (15 GB вместо 25, ещё ~$0.25/мес). Делать
|
||||||
|
не сейчас.
|
||||||
|
|
||||||
|
## Принятые риски
|
||||||
|
|
||||||
|
- **Общий фолдер с другими ВМ.** SA `runner-self-stop` теоретически
|
||||||
|
может погасить и Gitea-сервер, если тот переедет в YC рядом. Митигация
|
||||||
|
при появлении такой ВМ — перенос в отдельный фолдер.
|
||||||
|
- **Холодный старт ~60 сек.** Дизайн заявляет 40, реальность ближе к
|
||||||
|
60 (Ubuntu boot + docker pull + act_runner connect). Документируем как
|
||||||
|
«нормальная задержка первой джобы».
|
||||||
|
- **Регистрационный токен утерян.** При пересоздании диска ВМ нужен
|
||||||
|
новый токен из Gitea UI. Документируем процесс. Раз в годы — терпимо.
|
||||||
|
- **Probe сломан, ВМ висит.** Поймает алерт CM, ручное расследование.
|
||||||
|
|
||||||
|
## План внедрения
|
||||||
|
|
||||||
|
1. Создать в YC: 2 SA, ВМ, дисковый ресурс. Через `inv
|
||||||
|
runner-bootstrap` или вручную через консоль (выбираем по желанию на
|
||||||
|
этапе реализации).
|
||||||
|
2. Прогнать `inv runner-pl` на свежесозданной ВМ. С временно
|
||||||
|
уменьшенным `IDLE_THRESHOLD` (2 мин вместо 10) — чтобы тестировать
|
||||||
|
гашение быстро.
|
||||||
|
3. Зарегистрировать раннер в Gitea руками: получить registration token,
|
||||||
|
положить в Vault, повторить `inv runner-pl`.
|
||||||
|
4. Проверить, что раннер появился в Gitea UI и забирает тестовую джобу.
|
||||||
|
5. Проверить idle-watch: дать ВМ постоять, убедиться, что гасится.
|
||||||
|
6. Создать функцию `runner-starter` через `inv runner-deploy-function`.
|
||||||
|
Проверить ручным `yc serverless function invoke`.
|
||||||
|
7. Прописать System Webhook в Gitea на URL функции, секрет совпадает с
|
||||||
|
Vault.
|
||||||
|
8. Тестовый push → end-to-end проверка.
|
||||||
|
9. Поднять `IDLE_THRESHOLD` обратно до 10 мин.
|
||||||
|
10. Настроить алерт Cloud Monitoring на uptime > 24 ч.
|
||||||
|
11. Неделя наблюдения: лог функции, samples.log, uptime ВМ, счёт.
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
- **Канал нотификаций** для алерта Cloud Monitoring (Telegram, ntfy,
|
||||||
|
email) — выбрать на этапе настройки.
|
||||||
|
- **Тип executor** в act_runner — docker (по умолчанию) или host. Ходим
|
||||||
|
через docker, host-executor пока не обсуждается.
|
||||||
|
- **Webhook на pull request** — нужно или только push? По умолчанию
|
||||||
|
только push. Расширим, если возникнет PR-flow.
|
||||||
|
- **Перенос ВМ в отдельный фолдер** — когда в общем появится вторая
|
||||||
|
ВМ. Пока не критично.
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
# Журнал миграции в Timeweb
|
||||||
|
|
||||||
|
Хронология фактического переезда. План и архитектурные решения —
|
||||||
|
в [timeweb.md](timeweb.md). Здесь только то, что реально сделано,
|
||||||
|
с датами.
|
||||||
|
|
||||||
|
Новые записи — сверху.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 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, выполнено)
|
||||||
|
|
||||||
|
На свежей Timeweb-машине прогнаны два плейбука без даунтайма источника
|
||||||
|
(контейнеры на target не запускались).
|
||||||
|
|
||||||
|
### 9a. Системная база
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ansible -i timeweb.yml -m ping server # pong
|
||||||
|
uv run ansible-playbook -i timeweb.yml --diff playbook-all-setup.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
После прогона на target поднято: apt-пакеты (`geerlingguy.security`),
|
||||||
|
docker + сети (`web_proxy_network`, `monitoring_network`), eget с
|
||||||
|
инструментами (restic, rclone, btop, zellij и др.), ufw (порты 22,
|
||||||
|
2222, 80, 443), fail2ban, backup-инфра (`backup-all.py`,
|
||||||
|
resticprofile, cron).
|
||||||
|
|
||||||
|
Заодно `geerlingguy.security` отключил root по SSH и
|
||||||
|
`PasswordAuthentication` — root-канал закрыт, доступ только через
|
||||||
|
`major` + ключ. Перепроверено `ssh major@<новый-ip>` — работает.
|
||||||
|
|
||||||
|
### 9b. Application-плейбуки без запуска контейнеров
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ansible-playbook -i timeweb.yml --diff \
|
||||||
|
--skip-tags run-app \
|
||||||
|
playbook-all-applications.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
На target созданы все `<app>`-пользователи с правильными uid/gid
|
||||||
|
(совпадают с источником, см. таблицу в [плане](timeweb.md)), каталоги
|
||||||
|
`/srv/applications/<app>/{data,config,backups}`, отрендерены
|
||||||
|
`docker-compose.yml` и application-конфиги. Контейнеры **не**
|
||||||
|
запускались — это шаг 5 cutover'а (после rsync'а данных).
|
||||||
|
|
||||||
|
OAuth-аутентификация в `cr.yandex` (из Шага 3) сработала с
|
||||||
|
Timeweb-айпишника без замечаний — `community.docker.docker_login` в
|
||||||
|
плейбуках homepage и transcriber прошёл.
|
||||||
|
|
||||||
|
### Обнаруженный латентный bug ordering'а в goaccess
|
||||||
|
|
||||||
|
На fresh-install упала задача
|
||||||
|
`playbook-goaccess.yml:55 «Ensure caddy access log exists before
|
||||||
|
goaccess starts»` — пыталась туч'ить файл в `/var/log/caddy/`, который
|
||||||
|
к этому моменту не существовал. Причина: каталог создаётся в
|
||||||
|
`playbook-caddyproxy.yml`, а в `playbook-all-applications.yml`
|
||||||
|
goaccess идёт **раньше** caddyproxy (caddyproxy специально последний,
|
||||||
|
чтобы стартовать после backends). На предыдущем сервере не проявлялось
|
||||||
|
— каталог уже существовал от прошлых прогонов.
|
||||||
|
|
||||||
|
Фикс: добавил в `playbook-goaccess.yml` явное создание
|
||||||
|
`caddy_logs_dir` перед touch'ем `access.log`. Owner/mode выставит
|
||||||
|
caddyproxy при своём прогоне, идемпотентность сохранена.
|
||||||
|
|
||||||
|
**Backlog (после миграции):** `caddy_logs_dir` — shared-ресурс между
|
||||||
|
плеями (caddyproxy пишет, goaccess читает), концептуально это
|
||||||
|
provisioning-time забота. Вынести его создание в `playbook-system.yml`
|
||||||
|
(или в отдельный shared-resources плей в `playbook-all-setup.yml`) и
|
||||||
|
убрать дубль из goaccess/caddyproxy. Делать после переезда отдельным
|
||||||
|
PR, не во время миграции.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 8 — VPS заказан, пользователь `major` создан (2026-05-23, выполнено)
|
||||||
|
|
||||||
|
Заказан Cloud VPS в Timeweb по тарифу из плана (4 × 3.3 ГГц, 8 ГБ RAM,
|
||||||
|
80 ГБ NVMe, Ubuntu 24.04 LTS), ДЦ Санкт-Петербург.
|
||||||
|
|
||||||
|
Первая выданная VPS попала на гипервизор с битой сетью: TCP-handshake
|
||||||
|
проходил нормально, но первый data-сегмент в любой TCP-сессии не
|
||||||
|
доставлялся ни в одну сторону. Подтверждено:
|
||||||
|
|
||||||
|
- `nc -l 12345` на сервере не получал данные от клиента, при этом
|
||||||
|
клиент видел `Connection succeeded`;
|
||||||
|
- strace зависшего `sshd: [accepted]`-child показывал
|
||||||
|
`read(socket, ..., 1) = ERESTARTSYS`, далее `SIGALRM` через 120 сек
|
||||||
|
по `LoginGraceTime` → exit (т.е. sshd ушёл в `read()` за клиентским
|
||||||
|
баннером и не дождался);
|
||||||
|
- `iptables -S` / `nft list ruleset` / `ufw status` — пусто, локального
|
||||||
|
firewall нет;
|
||||||
|
- исходящие соединения с VM (`curl http://example.com`) работали
|
||||||
|
штатно — ломались только входящие data-сегменты после handshake.
|
||||||
|
|
||||||
|
Ребут и переустановка ОС из панели не помогли. Пересоздал VPS в ДЦ СПб
|
||||||
|
с новым IP — заработало с первой попытки. Потеря времени ~1 час; на
|
||||||
|
будущее: при таком паттерне сразу пересоздаём в другом ДЦ, глубже
|
||||||
|
диагностику не ведём (это однозначно проблема сети провайдера).
|
||||||
|
|
||||||
|
### Bootstrap пользователя `major`
|
||||||
|
|
||||||
|
На свежей VPS только root по SSH-ключу. Поднял пользователя
|
||||||
|
аналогично YC-серверу — sudo через NOPASSWD, вход только по ключу.
|
||||||
|
Дальше `geerlingguy.security` + `roles/owner` пересоздадут пользователя
|
||||||
|
идемпотентно с теми же uid/gid и приклеят политику sshd при первом
|
||||||
|
прогоне ансибла.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Создать пользователя с home и bash, добавить в sudo
|
||||||
|
useradd -m -s /bin/bash major
|
||||||
|
usermod -aG sudo major
|
||||||
|
|
||||||
|
# 2. NOPASSWD-политика sudo
|
||||||
|
echo 'major ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/major
|
||||||
|
chmod 0440 /etc/sudoers.d/major
|
||||||
|
visudo -cf /etc/sudoers.d/major # должно сказать "parsed OK"
|
||||||
|
|
||||||
|
# 3. SSH-ключ (тот же, что залит для root при создании VPS)
|
||||||
|
install -d -m 700 -o major -g major /home/major/.ssh
|
||||||
|
install -m 600 -o major -g major \
|
||||||
|
/root/.ssh/authorized_keys \
|
||||||
|
/home/major/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка с локальной машины:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh major@<новый-ip>
|
||||||
|
sudo whoami # root, без пароля
|
||||||
|
```
|
||||||
|
|
||||||
|
Прошло. Root-доступ по SSH пока оставлен как резервный канал — первый
|
||||||
|
прогон ансибла отключит его через `geerlingguy.security`
|
||||||
|
(`PermitRootLogin no`, `PasswordAuthentication no`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 7 — `run-app` тег + унификация registry (2026-05-22, выполнено)
|
||||||
|
|
||||||
|
По итогам аудита подготовительных задач выявлены и закрыты две
|
||||||
|
несостыковки:
|
||||||
|
|
||||||
|
### 7a. Пропущенный `run-app` тег в remembos
|
||||||
|
|
||||||
|
В `playbook-remembos.yml:73` была задача
|
||||||
|
`Restart docker compose services if config changed but not
|
||||||
|
docker-compose.yml` (условный рестарт через `state: restarted`,
|
||||||
|
триггер — изменение `config.toml` без изменения `docker-compose.yml`),
|
||||||
|
у неё не было тега `run-app`. На cutover'е при
|
||||||
|
`--skip-tags run-app` основной запуск пропустился бы (правильно), а
|
||||||
|
эта условная задача всё равно сработала бы (потому что её `when:`
|
||||||
|
истинно при первом деплое — конфиг создаётся), попыталась бы
|
||||||
|
рестартануть несуществующий compose-стек и упала. Тег добавлен.
|
||||||
|
|
||||||
|
### 7b. Унификация `registry_url` в docker_login
|
||||||
|
|
||||||
|
`playbook-homepage.yml` и `playbook-transcriber.yml` использовали
|
||||||
|
хардкод `registry_url: "cr.yandex"`, а `playbook-remembos.yml` —
|
||||||
|
`'{{ yc_container_registry }}'` из vault. Привёл к одному виду:
|
||||||
|
теперь во всех трёх — `"{{ yc_container_registry }}"` из vault.
|
||||||
|
|
||||||
|
`docker_registry_prefix` в `vars/homepage.yml` и `vars/transcriber.yml`
|
||||||
|
не трогал — там полный image-prefix вида `cr.yandex/<org-id>`,
|
||||||
|
это отдельная концепция (есть отдельный vault-var
|
||||||
|
`yc_container_registry_repository`, используемый в
|
||||||
|
`files/remembos/docker-compose.template.yml`). Если позже захочется
|
||||||
|
унифицировать целиком — это отдельная итерация.
|
||||||
|
|
||||||
|
### Аудит бэкапов: gap'ы по `caddyproxy`, `remembos`, `transcriber`
|
||||||
|
|
||||||
|
Эти три приложения имеют состояние в `data_dir`, но не имеют ни
|
||||||
|
`backup.template.sh`, ни ansible-генерируемого `backup-targets`.
|
||||||
|
Для миграции это закрывается через **rsync** на cutover'е — данные
|
||||||
|
переносятся напрямую, без зависимости от restic-снапшотов:
|
||||||
|
|
||||||
|
- `caddyproxy/data/` — TLS-сертификаты Let's Encrypt (важно, чтобы
|
||||||
|
не упереться в rate-limit LE при перевыпуске ~17 сертов).
|
||||||
|
- `remembos/data/` — user data (memos-токен, telegram tokens).
|
||||||
|
- `transcriber/data/` — пользовательские транскрипции.
|
||||||
|
|
||||||
|
Это означает: на этапе rsync (шаг 4 cutover'а в плане) **нельзя**
|
||||||
|
полагаться только на restic-restore — для этих трёх апов rsync —
|
||||||
|
единственный канал. Для остальных приложений (которые имеют
|
||||||
|
`backup.sh` или `backup-targets`) можно при необходимости использовать
|
||||||
|
restic как фолбэк, но rsync всё равно остаётся основным методом.
|
||||||
|
|
||||||
|
Долгосрочно — добавить им backup-механизм отдельной итерацией после
|
||||||
|
миграции. Сейчас это сверх сферы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 6 — `vars/vars.yml` загружается во всех плейбуках (2026-05-22, выполнено)
|
||||||
|
|
||||||
|
Сегодняшний коммит `8378f0e` («Migration: expose some public vars»)
|
||||||
|
вынес общие переменные (`application_dir`, `host_name`, `primary_user`,
|
||||||
|
`primary_user_uid`, `primary_user_gid`, `bin_prefix`,
|
||||||
|
`apprise_external_port`, `apprise_external_url`, `caddy_logs_dir`) из
|
||||||
|
vault в `vars/vars.yml`. Но большая часть плейбуков загружала только
|
||||||
|
`vars/secrets.yml` — на текущем сервере они работали лишь потому, что
|
||||||
|
inventory дублирует `application_dir` как override. На чистом
|
||||||
|
Timeweb-инвентаре без override они бы упали с undefined.
|
||||||
|
|
||||||
|
Прошёлся по всем плейбукам, добавил `- vars/vars.yml` сразу после
|
||||||
|
`- vars/secrets.yml`:
|
||||||
|
|
||||||
|
```
|
||||||
|
playbook-authelia.yml playbook-netdata.yml
|
||||||
|
playbook-calibre.yml playbook-outline.yml
|
||||||
|
playbook-docker.yml playbook-remembos.yml
|
||||||
|
playbook-dozzle.yml playbook-rssbridge.yml
|
||||||
|
playbook-eget.yml playbook-transcriber.yml
|
||||||
|
playbook-gitea.yml playbook-transcriber-registry.yml
|
||||||
|
playbook-gramps.yml playbook-tuwunel.yml
|
||||||
|
playbook-homepage.yml playbook-ufw.yml
|
||||||
|
playbook-homepage-registry.yml playbook-upgrade.yml
|
||||||
|
playbook-memos.yml playbook-wakapi.yml
|
||||||
|
playbook-miniflux.yml playbook-wanderer.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
(21 файл — все «обычные» плейбуки, которые ещё не подключали vars.yml.)
|
||||||
|
|
||||||
|
Aggregator'ы `playbook-all-applications.yml` и `playbook-all-setup.yml`
|
||||||
|
не трогал — у них нет собственных `vars_files`, они используют
|
||||||
|
`import_playbook`, каждый импортируемый плейбук уже сам подключает
|
||||||
|
`vars.yml`.
|
||||||
|
|
||||||
|
`yamllint` чист. Идемпотентность проверить отдельным прогоном.
|
||||||
|
|
||||||
|
Проверить прогоном `inv pl -- all-applications` (или хотя бы
|
||||||
|
`inv pl -- gitea outline miniflux`) на текущем сервере — diff
|
||||||
|
ожидается пустой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 5 — переезд default application_dir на /srv (2026-05-22, выполнено)
|
||||||
|
|
||||||
|
`/mnt` по FHS — место для точек монтирования внешних дисков; на
|
||||||
|
системном диске Timeweb (фаза 1) это семантически неверно. Поменяли
|
||||||
|
дефолт на `/srv/applications` (FHS: «data for services provided by
|
||||||
|
this system»), для текущего YC-сервера сделали override в инвентаре.
|
||||||
|
|
||||||
|
Изменения:
|
||||||
|
|
||||||
|
- `vars/vars.yml` — `application_dir: "/srv/applications"`
|
||||||
|
(комментарий обновлён).
|
||||||
|
- `production.yml` — у хоста `server` добавлен override
|
||||||
|
`application_dir: "/mnt/applications"`.
|
||||||
|
- `playbook-system.yml` — добавлен `vars/vars.yml` в `vars_files`,
|
||||||
|
захардкоженный `/mnt/applications` в задачах
|
||||||
|
`Create directory for mount` и `Mount external storages` заменён
|
||||||
|
на `{{ application_dir }}`.
|
||||||
|
- `playbook-remove-user-and-app.yml` — то же самое (`vars/vars.yml`
|
||||||
|
в `vars_files` + `{{ (application_dir, user_name) | path_join }}`).
|
||||||
|
- `tasks.py` — новый helper `_application_dir()` читает значение
|
||||||
|
сначала из inventory (override), затем из `vars/vars.yml`. `login_as_app`
|
||||||
|
больше не содержит `/mnt/applications`.
|
||||||
|
|
||||||
|
Что остаётся хардкодом — только `/mnt/applications` в `production.yml`
|
||||||
|
как override, и это правильно.
|
||||||
|
|
||||||
|
На Timeweb-инвентаре (когда появится) можно либо не задавать
|
||||||
|
`application_dir` вовсе (применится дефолт `/srv/applications`), либо
|
||||||
|
задать явно — для читаемости.
|
||||||
|
|
||||||
|
Проверить прогоном `inv pl -- system` на текущем сервере (Yandex
|
||||||
|
Cloud) — ничего не должно поменяться, потому что inventory override
|
||||||
|
возвращает `/mnt/applications` и mount всё ещё включён. Diff ожидается
|
||||||
|
пустой.
|
||||||
|
|
||||||
|
### Восстановление restic-снапшотов после смены путей
|
||||||
|
|
||||||
|
Старые снапшоты записаны с путями `/mnt/applications/<app>`. На
|
||||||
|
Timeweb данные должны лежать в `/srv/applications/<app>`. У restic
|
||||||
|
нет встроенного «remap path» при restore, поэтому делается в два
|
||||||
|
шага: восстановить во временный каталог, затем `rsync` на новое
|
||||||
|
место с сохранением uid/gid (приложения уже созданы playbook'ом с
|
||||||
|
теми же uid/gid, см. шаг про подготовку target).
|
||||||
|
|
||||||
|
Пример — восстановить gitea на Timeweb-машине:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /usr/local/sbin/restic-shell.sh
|
||||||
|
|
||||||
|
# Распакуем нужную поддиректорию во временный каталог
|
||||||
|
restic restore latest \
|
||||||
|
--target /tmp/restic-restore \
|
||||||
|
--include /mnt/applications/gitea
|
||||||
|
|
||||||
|
# Перенесём данные на новый путь, сохранив владельца/группу/ACL/xattr
|
||||||
|
sudo rsync -aAX --info=progress2 \
|
||||||
|
/tmp/restic-restore/mnt/applications/gitea/ \
|
||||||
|
/srv/applications/gitea/
|
||||||
|
|
||||||
|
sudo rm -rf /tmp/restic-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Несколько приложений за один проход:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
restic restore latest \
|
||||||
|
--target /tmp/restic-restore \
|
||||||
|
--include /mnt/applications/gitea \
|
||||||
|
--include /mnt/applications/outline \
|
||||||
|
--include /mnt/applications/miniflux
|
||||||
|
|
||||||
|
for app in gitea outline miniflux; do
|
||||||
|
sudo rsync -aAX --info=progress2 \
|
||||||
|
"/tmp/restic-restore/mnt/applications/$app/" \
|
||||||
|
"/srv/applications/$app/"
|
||||||
|
done
|
||||||
|
sudo rm -rf /tmp/restic-restore
|
||||||
|
```
|
||||||
|
|
||||||
|
Альтернатива через `restic mount` (если не хочется промежуточной
|
||||||
|
копии — данные мапятся как FUSE-FS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /mnt/restic-snapshots
|
||||||
|
restic mount /mnt/restic-snapshots &
|
||||||
|
sudo rsync -aAX \
|
||||||
|
/mnt/restic-snapshots/snapshots/latest/mnt/applications/gitea/ \
|
||||||
|
/srv/applications/gitea/
|
||||||
|
sudo fusermount -u /mnt/restic-snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
После переезда новые снапшоты будут записываться уже с путями
|
||||||
|
`/srv/applications/<app>` — никаких трюков для текущих бэкапов не
|
||||||
|
нужно.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 4 — условное монтирование внешнего диска (2026-05-22, выполнено)
|
||||||
|
|
||||||
|
Задача `Mount external storages` в `playbook-system.yml` теперь
|
||||||
|
выполняется только при включённом флаге `mount_external_storage`
|
||||||
|
(default `false`). Сам UUID диска оставлен захардкоженным в
|
||||||
|
плейбуке — параметризовать не стали, потому что для Timeweb (фаза 1)
|
||||||
|
монтирование вообще не нужно, а для фазы 2 пока неизвестно, какой
|
||||||
|
UUID получится у второго диска.
|
||||||
|
|
||||||
|
Изменения:
|
||||||
|
|
||||||
|
- `playbook-system.yml` — у задачи mount добавлен
|
||||||
|
`when: mount_external_storage | default(false) | bool`.
|
||||||
|
- `production.yml` (инвентарь YC) — у хоста `server` добавлен
|
||||||
|
`mount_external_storage: true`, чтобы текущее поведение
|
||||||
|
сохранилось.
|
||||||
|
|
||||||
|
В будущем `timeweb.yml` просто не будет задавать эту переменную —
|
||||||
|
mount пропустится, `/mnt/applications` останется обычной директорией
|
||||||
|
на системном диске.
|
||||||
|
|
||||||
|
На фазе 2 (подключение медленного диска в Timeweb) UUID в
|
||||||
|
`playbook-system.yml` придётся поменять и включить флаг — это
|
||||||
|
осознанный шаг, не автоматизировано.
|
||||||
|
|
||||||
|
Проверено прогоном `inv pl -- system` на текущем сервере (Yandex
|
||||||
|
Cloud) — задача mount по-прежнему выполняется, `/mnt/applications`
|
||||||
|
смонтирован, изменений нет.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 3 — переключение auth на cr.yandex (2026-05-22, выполнено)
|
||||||
|
|
||||||
|
Заменена аутентификация в Yandex Container Registry с YC-metadata
|
||||||
|
service на OAuth-token из vault.
|
||||||
|
|
||||||
|
Изменения:
|
||||||
|
|
||||||
|
- `files/yandex-docker-registry-auth.sh` — **удалён**.
|
||||||
|
- `playbook-homepage.yml` — задача `ansible.builtin.script:
|
||||||
|
yandex-docker-registry-auth.sh` заменена на
|
||||||
|
`community.docker.docker_login` с `username: oauth`, `password:
|
||||||
|
"{{ yc_oauth_token }}"`.
|
||||||
|
- `playbook-transcriber.yml` — то же самое.
|
||||||
|
|
||||||
|
Локальные push-плейбуки (`playbook-homepage-registry.yml`,
|
||||||
|
`playbook-transcriber-registry.yml`) не трогал — там нет auth-задачи
|
||||||
|
в принципе, локальный docker аутентифицируется вручную
|
||||||
|
(`yc container registry configure-docker` или `docker login`).
|
||||||
|
Если позже захочется унифицировать — можно добавить тот же
|
||||||
|
`docker_login` с `delegate_to: 127.0.0.1`.
|
||||||
|
|
||||||
|
Проверено прогоном `inv pl -- homepage` и `inv pl -- transcriber` на
|
||||||
|
текущем сервере (Yandex Cloud) — ошибок нет, контейнеры работают.
|
||||||
|
Значит и на Timeweb заработает (единственная разница — исходящий IP,
|
||||||
|
а OAuth-токен в YC принимается извне).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 2 — OAuth-token для cr.yandex (2026-05-22, выполнено)
|
||||||
|
|
||||||
|
В `vars/secrets.yml` добавлена (или обновлена) переменная
|
||||||
|
`yc_oauth_token` со свежим OAuth-токеном Яндекса. Токен будет
|
||||||
|
использоваться для логина в `cr.yandex` с новой машины Timeweb
|
||||||
|
(вместо текущего скрипта `files/yandex-docker-registry-auth.sh`,
|
||||||
|
который завязан на YC metadata service `169.254.169.254` и
|
||||||
|
работает только внутри YC).
|
||||||
|
|
||||||
|
Сам код переключения на `community.docker.docker_login` пока не
|
||||||
|
вносится — это следующая итерация. Сейчас токен просто положен в
|
||||||
|
vault, чтобы не делать этого в день cutover'а под прессом.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 1 — снижение TTL DNS (2026-05-22, выполнено)
|
||||||
|
|
||||||
|
В админке Yandex 360 для зоны `vakhrushev.me` уменьшен TTL
|
||||||
|
A-записей с **21 600 с (6 ч)** до **1 200 с (20 мин)**. Это даёт
|
||||||
|
запас по времени на распространение изменений после смены IP в
|
||||||
|
день cutover'а — старые кэширующие резолверы перестанут отдавать
|
||||||
|
старый адрес максимум через 20 минут (вместо 6 часов).
|
||||||
|
|
||||||
|
Делается **заранее**, потому что само снижение TTL тоже
|
||||||
|
распространяется по кэшам по правилам старого TTL — то есть после
|
||||||
|
правки нужно подождать ≥ 6 часов, чтобы новое значение TTL само
|
||||||
|
успело прижиться. Раньше cutover'а нужно сделать с большим
|
||||||
|
запасом — день в день не сработает.
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
# Миграция сервера в Timeweb
|
||||||
|
|
||||||
|
## Контекст и цели
|
||||||
|
|
||||||
|
Сервер `rivendell-v2` переезжает с виртуальной машины в Yandex Cloud
|
||||||
|
(`158.160.46.255`) на VPS в Timeweb.
|
||||||
|
|
||||||
|
### Причины переезда
|
||||||
|
|
||||||
|
1. **Высокая стоимость.** Тариф в Yandex Cloud обходится в ≈ 2 900 ₽/мес
|
||||||
|
за конфигурацию, которая в Timeweb стоит ≈ 2 000 ₽/мес и при этом
|
||||||
|
мощнее по всем параметрам (см. сравнение ниже).
|
||||||
|
2. **Упор в потолок RAM.** Текущий сервер уже использует ≈ 80 %
|
||||||
|
доступной памяти на штатной нагрузке (см.
|
||||||
|
`project_server_specs`). Любой всплеск (миграции БД, индексация
|
||||||
|
в Outline, бэкап с restic) — и приложения начинают конкурировать
|
||||||
|
за память, появляются OOM-риски. Дальше расти на этом тарифе
|
||||||
|
некуда без значительного увеличения цены.
|
||||||
|
3. **Медленные диски.** Из-за высокой стоимости в YC приходится
|
||||||
|
использовать дешёвый HDD-том вместо SSD/NVMe — это заметно
|
||||||
|
снижает отзывчивость приложений (особенно Gitea, Outline,
|
||||||
|
тёплый старт контейнеров, рестики check/forget). На Timeweb за
|
||||||
|
меньшие деньги получаем NVMe.
|
||||||
|
|
||||||
|
Переезд решает все три проблемы одновременно: дешевле, больше
|
||||||
|
RAM, быстрее диск.
|
||||||
|
|
||||||
|
### Сравнение тарифов
|
||||||
|
|
||||||
|
| Параметр | Yandex Cloud | Timeweb Cloud VPS |
|
||||||
|
| -------------- | ----------------------------------------- | ------------------ |
|
||||||
|
| CPU | Intel Cascade Lake, vCPU 2, гарантия 50 % | 4 × 3.3 ГГц |
|
||||||
|
| RAM | 4 ГБ | 8 ГБ |
|
||||||
|
| Диск | 120 ГБ HDD | 80 ГБ NVMe |
|
||||||
|
| Публичный IP | да | да |
|
||||||
|
| **Цена/месяц** | **2 887 ₽** | **1 980 ₽** |
|
||||||
|
|
||||||
|
Итого: **−907 ₽/мес (≈ −31 %)**, при этом **×2 RAM** (закрывает
|
||||||
|
причину 2), **×2 ядер**, гарантия CPU 100 % вместо 50 %,
|
||||||
|
**NVMe вместо HDD** (закрывает причину 3). Минус — диск меньше
|
||||||
|
(80 ГБ против 120 ГБ HDD), что и стало основанием для фазы 2 с
|
||||||
|
подключением второго «холодного» диска под крупные данные.
|
||||||
|
|
||||||
|
Переезжает **только compute** (VM с приложениями). Остальные сервисы
|
||||||
|
Yandex Cloud остаются на месте и продолжают использоваться с новой
|
||||||
|
машины:
|
||||||
|
|
||||||
|
- **Container Registry** — `cr.yandex/crplfk0168i4o8kd7ade` для образов
|
||||||
|
`homepage-nginx` и `transcriber`.
|
||||||
|
- **Object Storage (S3)** — restic-репозиторий `yandex_cloud_s3`.
|
||||||
|
- **Postbox SMTP** — `postbox.cloud.yandex.net` (gitea, gramps, wakapi,
|
||||||
|
outline, authelia, apprise).
|
||||||
|
- **Yandex 360 / DNS-зона** `vakhrushev.me` — там же управляются записи
|
||||||
|
и почтовый домен.
|
||||||
|
|
||||||
|
Параметры даунтайма — мягкие, это личная машина. Стратегия — «cold
|
||||||
|
cutover»: остановить сервисы на источнике, раскатать ansible на
|
||||||
|
target без запуска приложений, перенести данные с сохранением
|
||||||
|
uid/gid, запустить сервисы на target, переключить DNS.
|
||||||
|
|
||||||
|
Конфигурация target — Cloud VPS Timeweb с одним диском **80 ГБ** на
|
||||||
|
первой фазе. Позднее (отдельной фазой) будет подключён второй
|
||||||
|
«медленный» диск под крупные данные (`calibre`, бэкапы, возможно
|
||||||
|
`outline`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> Фактическое выполнение переезда — в отдельном файле
|
||||||
|
> [timeweb-migration-log.md](timeweb-migration-log.md). Здесь только
|
||||||
|
> план и архитектурные решения.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инвентаризация YC-зависимостей в коде
|
||||||
|
|
||||||
|
| Компонент | Где | Что делать при переезде |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `production.yml` | `ansible_host: 158.160.46.255`, `ansible_user: major` | Заменить на новый IP/пользователя Timeweb |
|
||||||
|
| `files/yandex-docker-registry-auth.sh` | Логин в `cr.yandex` через **YC metadata service** (`169.254.169.254`) | **Не работает вне YC.** Перейти на static OAuth-token / IAM-token (новый скрипт + секрет в vault) |
|
||||||
|
| `playbook-system.yml` (mount-storage) | UUID `3942bffd-…` монтируется в `/mnt/applications` | Фаза 1: отключить mount или сделать UUID переменной vault. Фаза 2 (после подключения медленного диска): включить заново с новым UUID |
|
||||||
|
| `files/backups/config.template.toml` | `[storage.yandex_cloud_s3]` + `AWS_*` ключи | **Не меняем.** Тот же бакет/ключи продолжают работать. Меняется только `host_name` (для подписи снапшотов и нотификаций) — он уже шаблонится |
|
||||||
|
| SMTP (`postbox_host/port/user/pass`) | gitea, gramps, wakapi, outline, authelia, apprise | **Не меняем.** Postbox SMTP доступен извне YC по тем же credentials |
|
||||||
|
| `files/backups/rclone.template.conf` (`pr86keedav`) | WebDAV-копия restic — внешний сервис | **Не меняем** |
|
||||||
|
| Caddy `tls anwinged@ya.ru` | ACME | Не меняется, ACME перевыпустит сертификаты после смены IP |
|
||||||
|
|
||||||
|
Никаких других hardcoded YC-эндпоинтов в плейбуках / шаблонах нет —
|
||||||
|
SSH, ufw, fail2ban, docker, eget, restic, Caddy полностью переносимы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UID / GID — критично для rsync
|
||||||
|
|
||||||
|
UID/GID каждого приложения зафиксированы в плейбуках и в
|
||||||
|
`vars/homepage.yml` / `vars/transcriber.yml`. Роль `owner` создаёт
|
||||||
|
группы и пользователей **с явно указанными gid/uid**
|
||||||
|
(`roles/owner/tasks/main.yml`). Это значит:
|
||||||
|
|
||||||
|
- Если на новой машине **сначала** раскатать все плейбуки (без
|
||||||
|
запуска приложений), пользователи получатся с теми же uid/gid.
|
||||||
|
- Тогда `rsync -aAX` (с сохранением owner) корректно ляжет на target.
|
||||||
|
- Дополнительный maping uid не нужен.
|
||||||
|
|
||||||
|
Список приложений с uid/gid (для сверки и для документации):
|
||||||
|
|
||||||
|
```
|
||||||
|
caddyproxy 1010 / 1011
|
||||||
|
authelia 1011 / 1012
|
||||||
|
netdata 1012 / 1013
|
||||||
|
miniflux 1013 / 1014
|
||||||
|
rssbridge 1014 / 1015
|
||||||
|
wakapi 1015 / 1016
|
||||||
|
dozzle 1016 / 1017
|
||||||
|
transcriber 1017 / 1018
|
||||||
|
wanderer 1018 / 1019
|
||||||
|
memos 1019 / 1020
|
||||||
|
gitea 1005 / 1006
|
||||||
|
outline 1007 / 1008
|
||||||
|
homepage 1008 / 1009
|
||||||
|
gramps 1009 / 1010
|
||||||
|
calibre 1102 / 1102
|
||||||
|
remembos 1103 / 1103
|
||||||
|
apprise 1104 / 1104
|
||||||
|
tuwunel 1105 / 1105
|
||||||
|
goaccess 1106 / 1106
|
||||||
|
```
|
||||||
|
|
||||||
|
(Возможные пересечения uid одного приложения и gid другого
|
||||||
|
существуют, но Linux держит их в разных пространствах имён — не
|
||||||
|
страшно.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подготовка кода проекта
|
||||||
|
|
||||||
|
Делается **до** аренды Timeweb-машины, отдельным PR (или сериями
|
||||||
|
коммитов на отдельной ветке). Цель — чтобы тот же ansible
|
||||||
|
работал и на источнике, и на target без условных хаков.
|
||||||
|
|
||||||
|
### 1. Заменить YC-specific docker registry auth
|
||||||
|
|
||||||
|
`files/yandex-docker-registry-auth.sh` сейчас использует metadata
|
||||||
|
service (`169.254.169.254`). Это работает только внутри YC VM,
|
||||||
|
поэтому на Timeweb его надо заменить.
|
||||||
|
|
||||||
|
**Решение — OAuth-token Яндекса.** Простой и достаточный для
|
||||||
|
домашнего сервера механизм:
|
||||||
|
|
||||||
|
1. Получить OAuth-token в кабинете Яндекса:
|
||||||
|
<https://oauth.yandex.ru/authorize?response_type=token&client_id=1a6990aa636648e9b2ef855fa7bec2fb>
|
||||||
|
(стандартный client_id для `yc` CLI, токен с правом доступа к
|
||||||
|
Container Registry).
|
||||||
|
2. Положить в `vars/secrets.yml` как `yc_oauth_token` (vault).
|
||||||
|
3. Переписать `files/yandex-docker-registry-auth.sh` как шаблон
|
||||||
|
(`.template.sh`) и рендерить через `ansible.builtin.template`
|
||||||
|
вместо `script:`. Скрипт сводится к:
|
||||||
|
```sh
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
echo "{{ yc_oauth_token }}" | \
|
||||||
|
docker login --username oauth --password-stdin cr.yandex
|
||||||
|
```
|
||||||
|
Альтернатива — не рендерить, а передавать токен в скрипт
|
||||||
|
аргументом или через переменную окружения, чтобы не светить его
|
||||||
|
в системе.
|
||||||
|
4. В `playbook-homepage.yml` и `playbook-transcriber.yml` поменять
|
||||||
|
`ansible.builtin.script:` на `ansible.builtin.template:` +
|
||||||
|
`ansible.builtin.command:` (либо использовать модуль
|
||||||
|
`community.docker.docker_login` напрямую с `username: oauth`,
|
||||||
|
`password: "{{ yc_oauth_token }}"` — это самый чистый вариант,
|
||||||
|
тогда отдельный скрипт вообще не нужен).
|
||||||
|
5. То же самое — для локальных push-плейбуков
|
||||||
|
`playbook-homepage-registry.yml` и
|
||||||
|
`playbook-transcriber-registry.yml`.
|
||||||
|
|
||||||
|
Рекомендую вариант с `community.docker.docker_login` — это убирает
|
||||||
|
shell-скрипт целиком и сильно проще.
|
||||||
|
|
||||||
|
Минусы OAuth-token: токен живёт долго и даёт доступ ко всему
|
||||||
|
аккаунту Яндекса. Для личного сервера приемлемо; если позже
|
||||||
|
захочется минимизировать blast radius — заменить на IAM-key
|
||||||
|
сервисного аккаунта (отдельная итерация после миграции).
|
||||||
|
|
||||||
|
Затронутые места: `files/yandex-docker-registry-auth.sh` (удалить
|
||||||
|
или переписать), `playbook-homepage.yml`, `playbook-transcriber.yml`,
|
||||||
|
`playbook-homepage-registry.yml`, `playbook-transcriber-registry.yml`,
|
||||||
|
`vars/secrets.yml` (новый ключ `yc_oauth_token`).
|
||||||
|
|
||||||
|
### 2. Сделать опциональным монтирование внешнего диска
|
||||||
|
|
||||||
|
Сейчас `playbook-system.yml` жёстко монтирует UUID `3942bffd-…` в
|
||||||
|
`/mnt/applications`. На Timeweb этого диска нет.
|
||||||
|
|
||||||
|
Минимальная правка — вытащить UUID в переменную (`storage_uuid`) и
|
||||||
|
обернуть mount-задачу `when: storage_uuid is defined`. В
|
||||||
|
`vars/secrets.yml` или `vars/vars.yml` для текущего сервера задать
|
||||||
|
UUID, для Timeweb (фаза 1) — не задавать. На фазе 2 (когда придёт
|
||||||
|
медленный диск) — задать новый UUID.
|
||||||
|
|
||||||
|
Альтернатива: вынести параметры в инвентарь
|
||||||
|
(`production.yml` → `host_vars/server.yml`).
|
||||||
|
|
||||||
|
При этом сама директория `/mnt/applications` должна создаваться в
|
||||||
|
любом случае — playbook уже это делает, надо лишь убедиться, что
|
||||||
|
задача «Create directory for mount» не зависит от mount-задачи.
|
||||||
|
|
||||||
|
### 3. Параметризовать инвентарь
|
||||||
|
|
||||||
|
На время перехода — **два отдельных файла**: текущий
|
||||||
|
`production.yml` остаётся как есть, рядом появляется новый
|
||||||
|
`timeweb.yml` с настройками Timeweb-машины. Все ansible-команды
|
||||||
|
во время миграции явно указывают `-i timeweb.yml`. После того, как
|
||||||
|
переезд закончен и старая машина выключена — `production.yml`
|
||||||
|
просто удаляется, `timeweb.yml` переименовывается в
|
||||||
|
`production.yml`.
|
||||||
|
|
||||||
|
`tasks.py` использует `yq` для извлечения `ansible_host` / `ansible_user`
|
||||||
|
из инвентаря (`_yq(".ungrouped.hosts.server…")`) — путь к файлу
|
||||||
|
зашит константой `HOSTS_FILE = "production.yml"`. Варианты:
|
||||||
|
|
||||||
|
- На время миграции временно поменять `HOSTS_FILE = "timeweb.yml"`
|
||||||
|
в локальном коммите (или через env override), потом откатить — после
|
||||||
|
переименования всё снова работает.
|
||||||
|
- Принять, что `inv ssh / zj / btop / login` работают только с
|
||||||
|
активным сервером (тем, что в `production.yml`), а к старой
|
||||||
|
машине во время миграции ходим напрямую через `ssh
|
||||||
|
major@158.160.46.255`.
|
||||||
|
|
||||||
|
Первый вариант чище. Достаточно одной строчки правки.
|
||||||
|
|
||||||
|
### 4. Прочее
|
||||||
|
|
||||||
|
- `README.md` — обновить инструкцию по DNS и упомянуть Timeweb.
|
||||||
|
- Удалить (или пометить deprecated) yandex-метаданные в комментариях
|
||||||
|
`yandex-docker-registry-auth.sh`.
|
||||||
|
- Проверить, что у всех application-плейбуков задача с
|
||||||
|
`community.docker.docker_compose_v2: state: present` помечена
|
||||||
|
тегом `run-app` — это позволит раскатывать `--skip-tags run-app`
|
||||||
|
для подготовки target без запуска контейнеров. Сейчас тег `run-app`
|
||||||
|
есть в большинстве плейбуков, но надо пройтись и убедиться, что
|
||||||
|
он покрывает **все** контейнеры (включая calibre, dozzle,
|
||||||
|
remembos, transcriber, tuwunel, wanderer, memos).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Подготовка target-машины
|
||||||
|
|
||||||
|
1. Заказать Cloud VPS в Timeweb:
|
||||||
|
- Ubuntu LTS (та же мажорная версия, что и сейчас — упростит
|
||||||
|
совместимость пакетов).
|
||||||
|
- 4 GB RAM (текущий лимит ≈ 3.8 GiB, см. `project_server_specs`),
|
||||||
|
можно взять чуть с запасом — 4–6 GB, иначе netdata + tuwunel +
|
||||||
|
outline начнут давить.
|
||||||
|
- 2 vCPU.
|
||||||
|
- SSD 80 ГБ.
|
||||||
|
- Снять/настроить firewall провайдера (или отключить, т.к. у нас
|
||||||
|
свой ufw).
|
||||||
|
|
||||||
|
2. Создать пользователя с правами sudo (аналог `major`), залить
|
||||||
|
свой SSH-ключ.
|
||||||
|
|
||||||
|
3. Добавить хост в инвентарь как `server` (или временный
|
||||||
|
`timeweb`), убедиться, что `ansible -m ping` отвечает.
|
||||||
|
|
||||||
|
4. Снизить TTL DNS-записей в Yandex 360 до 60–300 секунд **за
|
||||||
|
~24–48 часов** до cutover.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cutover (план дня X)
|
||||||
|
|
||||||
|
Предусловия: код выкатан, target-машина пингуется по ansible, TTL
|
||||||
|
DNS снижены.
|
||||||
|
|
||||||
|
### Шаг 1. Финальный бэкап на источнике
|
||||||
|
|
||||||
|
```bash
|
||||||
|
inv ssh
|
||||||
|
sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
Убедиться, что в логе все приложения отработали успешно и в S3
|
||||||
|
появился свежий restic-snapshot (на случай отката или потери
|
||||||
|
данных при rsync).
|
||||||
|
|
||||||
|
### Шаг 2. Остановить все приложения на источнике
|
||||||
|
|
||||||
|
Останавливаем docker-демон целиком — это атомарно гасит все
|
||||||
|
контейнеры за один вызов, не зависит от текущего списка приложений
|
||||||
|
и шлёт корректный SIGTERM (с грейс-периодом ~15 сек) каждому, что
|
||||||
|
функционально эквивалентно `docker compose down` по всем стекам.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
inv ssh
|
||||||
|
sudo systemctl stop docker.service docker.socket
|
||||||
|
sudo systemctl disable docker.service docker.socket # страховка от автостарта при ребуте
|
||||||
|
sudo systemctl stop cron # чтобы ночной backup-cron не побежал
|
||||||
|
```
|
||||||
|
|
||||||
|
Финальный бэкап (шаг 1) **обязательно** должен пройти до этого
|
||||||
|
момента — `backup-all.py` запускает скрипты приложений, которые
|
||||||
|
делают `docker compose exec ... pg_dump ...`; без работающего
|
||||||
|
daemon это сломается.
|
||||||
|
|
||||||
|
`disable` — страховка: если по какой-то причине старая машина
|
||||||
|
перезагрузится во время rsync (или мы вернёмся на источник для
|
||||||
|
проверки/отката), docker не поднимется автоматически и сервисы
|
||||||
|
не начнут писать в данные, которые мы уже считаем «фиксированной
|
||||||
|
копией». В случае отката — `enable` + `start` обратно.
|
||||||
|
|
||||||
|
Проверить, что `docker ps` сейчас отвечает «daemon not running»
|
||||||
|
(или вернёт пустой список — зависит от того, как `inv ssh` пройдёт
|
||||||
|
до/после стопа). Если нужно убедиться, что контейнеры реально
|
||||||
|
ушли — `ps auxf | grep -E "containerd|docker" | grep -v grep`.
|
||||||
|
|
||||||
|
### Шаг 3. Раскатать инфраструктуру на target БЕЗ запуска приложений
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1) системная база
|
||||||
|
uv run ansible-playbook -i timeweb.yml --diff playbook-all-setup.yml
|
||||||
|
|
||||||
|
# 2) приложения (создаём пользователей, каталоги, конфиги,
|
||||||
|
# но НЕ запускаем контейнеры)
|
||||||
|
uv run ansible-playbook -i timeweb.yml --diff \
|
||||||
|
--skip-tags run-app \
|
||||||
|
playbook-all-applications.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Цель — после этого на target есть:
|
||||||
|
|
||||||
|
- Корректные uid/gid для всех приложений.
|
||||||
|
- Каталоги `/srv/applications/<app>/{data,config,backups}` (на
|
||||||
|
Timeweb дефолт изменён с `/mnt/applications`; см.
|
||||||
|
[журнал шаг 5](timeweb-migration-log.md)).
|
||||||
|
- Шаблоны `docker-compose.yml` и application-конфиги — отрендерены
|
||||||
|
и лежат на месте.
|
||||||
|
- Docker и сети созданы.
|
||||||
|
- ufw настроен, fail2ban работает.
|
||||||
|
|
||||||
|
### Шаг 4. Перенос данных
|
||||||
|
|
||||||
|
Пути меняются: на YC данные лежат в `/mnt/applications/<app>`, на
|
||||||
|
Timeweb — в `/srv/applications/<app>`. Rsync делает remap сам
|
||||||
|
(потому что мы указываем источник и приёмник явно). Для трёх
|
||||||
|
приложений без backup-механизма (`caddyproxy`, `remembos`,
|
||||||
|
`transcriber`) rsync — **единственный** канал переноса, restic
|
||||||
|
для них не альтернатива.
|
||||||
|
|
||||||
|
**Вариант A — rsync напрямую (основной путь).** С target-машины
|
||||||
|
тянем данные со старой:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rsync -aAX --info=progress2 --delete \
|
||||||
|
--exclude='lost+found' \
|
||||||
|
major@158.160.46.255:/mnt/applications/ \
|
||||||
|
/srv/applications/
|
||||||
|
```
|
||||||
|
|
||||||
|
`-aAX` сохраняет ACL/xattrs и uid/gid (численные значения).
|
||||||
|
Численные uid/gid на target совпадают с источником, потому что
|
||||||
|
плейбуки на обеих машинах создают пользователей с одинаковыми
|
||||||
|
явно заданными `app_owner_uid`/`gid`.
|
||||||
|
|
||||||
|
Каждое приложение можно тянуть отдельно — удобнее наблюдать
|
||||||
|
прогресс и можно частично пересинхронизировать в случае ошибок:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo rsync -aAX --info=progress2 --delete \
|
||||||
|
major@158.160.46.255:/mnt/applications/gitea/ \
|
||||||
|
/srv/applications/gitea/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Вариант B — restore из restic (страховка).** Если по сети
|
||||||
|
источник недоступен или хочется проверить, что бэкапы вообще
|
||||||
|
рабочие. Подробный пример (с учётом смены `/mnt` → `/srv`) — в
|
||||||
|
[журнале миграции, шаг 5](timeweb-migration-log.md).
|
||||||
|
|
||||||
|
Для `caddyproxy`, `remembos`, `transcriber` использовать B
|
||||||
|
**нельзя** — у них нет архивации, в restic-снапшоте данных просто
|
||||||
|
нет. Только A.
|
||||||
|
|
||||||
|
Рекомендую **A как основной метод**, B держим как страховку
|
||||||
|
для приложений, у которых есть восстановимый снапшот.
|
||||||
|
|
||||||
|
### Шаг 5. Запуск приложений на target
|
||||||
|
|
||||||
|
Раскатываем application-плейбуки ещё раз — теперь без `--skip-tags`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ansible-playbook -i timeweb.yml --diff \
|
||||||
|
playbook-all-applications.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот же запуск проверит идемпотентность шаблонов (не должно быть
|
||||||
|
diff'ов кроме docker-up).
|
||||||
|
|
||||||
|
После старта — проверить:
|
||||||
|
|
||||||
|
- `docker ps` — все контейнеры в healthy.
|
||||||
|
- Локально (по IP) `curl http://<target-ip>` — Caddy отвечает (на
|
||||||
|
редирект, т.к. сертификаты ещё не выпущены под этим IP).
|
||||||
|
- Логи Caddy — выпуск сертификатов запустится после смены DNS, не
|
||||||
|
раньше. Это нормально.
|
||||||
|
|
||||||
|
### Шаг 6. Переключение DNS
|
||||||
|
|
||||||
|
В Yandex 360 admin (`admin.yandex.ru/domains/vakhrushev.me`)
|
||||||
|
поменять A-записи для всех subdomain'ов на новый IP. Перечень
|
||||||
|
поддоменов (из `Caddyfile.template`):
|
||||||
|
|
||||||
|
```
|
||||||
|
vakhrushev.me (apex)
|
||||||
|
matrix.vakhrushev.me
|
||||||
|
auth.vakhrushev.me
|
||||||
|
status.vakhrushev.me
|
||||||
|
git.vakhrushev.me
|
||||||
|
outline.vakhrushev.me
|
||||||
|
gramps.vakhrushev.me
|
||||||
|
miniflux.vakhrushev.me
|
||||||
|
wakapi.vakhrushev.me
|
||||||
|
wanderer.vakhrushev.me
|
||||||
|
memos.vakhrushev.me
|
||||||
|
remembos.vakhrushev.me
|
||||||
|
calibre.vakhrushev.me
|
||||||
|
wanderbase.vakhrushev.me
|
||||||
|
rssbridge.vakhrushev.me
|
||||||
|
dozzle.vakhrushev.me
|
||||||
|
goaccess.vakhrushev.me
|
||||||
|
```
|
||||||
|
|
||||||
|
После смены — подождать пока TTL разойдётся, проверить через
|
||||||
|
`dig +short <hostname>` с независимой машины.
|
||||||
|
|
||||||
|
Caddy сам пойдёт за сертификатами Let's Encrypt — следить за его
|
||||||
|
логами (`docker logs caddyproxy_app -f`).
|
||||||
|
|
||||||
|
### Шаг 7. Проверка после cutover
|
||||||
|
|
||||||
|
Чеклист (примерно по приоритету):
|
||||||
|
|
||||||
|
- [ ] `vakhrushev.me` отвечает 200, отдаёт homepage.
|
||||||
|
- [ ] `auth.vakhrushev.me` — Authelia, можно залогиниться.
|
||||||
|
- [ ] `git.vakhrushev.me` — Gitea, репозитории на месте, ssh-доступ
|
||||||
|
(порт 2222 в ufw уже открыт).
|
||||||
|
- [ ] `outline.vakhrushev.me` — открывается, документы на месте.
|
||||||
|
- [ ] `matrix.vakhrushev.me` — Tuwunel/Element подключается;
|
||||||
|
federation проверяется через
|
||||||
|
<https://federationtester.matrix.org/>.
|
||||||
|
- [ ] `miniflux.vakhrushev.me`, `wakapi.vakhrushev.me`,
|
||||||
|
`memos.vakhrushev.me`, `gramps.vakhrushev.me`,
|
||||||
|
`remembos.vakhrushev.me`, `wanderer.vakhrushev.me`,
|
||||||
|
`calibre.vakhrushev.me`, `rssbridge.vakhrushev.me`,
|
||||||
|
`dozzle.vakhrushev.me`, `goaccess.vakhrushev.me` —
|
||||||
|
открываются, данные на месте.
|
||||||
|
- [ ] Netdata `status.vakhrushev.me` — собирает метрики.
|
||||||
|
- [ ] Backup-cron — следующий запуск (1:00) проходит успешно,
|
||||||
|
приходит уведомление в apprise.
|
||||||
|
- [ ] SMTP — отправить тестовое письмо из gitea/authelia (триггер
|
||||||
|
reset password).
|
||||||
|
- [ ] Container Registry — `docker pull cr.yandex/...` на новой
|
||||||
|
машине проходит (это значит, что наша новая аутентификация
|
||||||
|
через OAuth/IAM работает).
|
||||||
|
|
||||||
|
### Шаг 8. Заморозка источника
|
||||||
|
|
||||||
|
Когда всё подтверждено стабильным (≥ 24 часа):
|
||||||
|
|
||||||
|
- Остановить и выключить старую VM в YC.
|
||||||
|
- Подождать неделю-две на случай отката.
|
||||||
|
- Удалить VM и связанные ресурсы (только compute! S3-бакет с
|
||||||
|
restic-бэкапами и Container Registry **остаются**).
|
||||||
|
- Удалить `production.yml`, переименовать `timeweb.yml` →
|
||||||
|
`production.yml`, откатить временную правку `HOSTS_FILE` в
|
||||||
|
`tasks.py` (теперь снова `production.yml`). Закоммитить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Фаза 2: подключение медленного диска
|
||||||
|
|
||||||
|
После того как Timeweb-сервер стабилен:
|
||||||
|
|
||||||
|
1. Заказать дополнительный «холодный» диск в Timeweb, прицепить
|
||||||
|
к VPS.
|
||||||
|
2. Узнать UUID нового устройства (`lsblk -f`).
|
||||||
|
3. Решить, куда монтировать — варианты:
|
||||||
|
- Сохранить текущую схему (`/mnt/applications` на медленном
|
||||||
|
диске целиком). Минус: всё IO приложений уходит на медленный
|
||||||
|
диск.
|
||||||
|
- **Лучше:** оставить `/mnt/applications` на быстром SSD,
|
||||||
|
медленный смонтировать как `/mnt/cold` и под calibre/большие
|
||||||
|
бэкапы делать bind-mount или поменять `data_dir` у нужных
|
||||||
|
приложений.
|
||||||
|
4. Восстановить в `playbook-system.yml` mount-задачу с новым
|
||||||
|
UUID (через переменную, заведённую на фазе 1).
|
||||||
|
5. Прогнать `inv pl -- system` с тегом `mount-storage`.
|
||||||
|
6. Переехать на холодный диск только большие данные. Для calibre
|
||||||
|
это означает остановить контейнер, `rsync` библиотеки книг,
|
||||||
|
поправить `data_dir` в `vars`, запустить.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что НЕ менять во время миграции
|
||||||
|
|
||||||
|
Чтобы не накапливать изменения в одном переезде:
|
||||||
|
|
||||||
|
- Версии docker-образов всех приложений — те же, что в источнике.
|
||||||
|
- Конфиги приложений — без правок.
|
||||||
|
- Restic snapshot policy.
|
||||||
|
- Apprise/notification каналы.
|
||||||
|
|
||||||
|
Любые улучшения (healthchecks из `docs/drafts/alerts.md`,
|
||||||
|
gitea runner и т.п.) — отдельным циклом после миграции.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Откат
|
||||||
|
|
||||||
|
Если на target что-то критично сломалось:
|
||||||
|
|
||||||
|
1. DNS возвращаем обратно на старый IP.
|
||||||
|
2. Старая VM в YC жива и заглушена → стартуем её, поднимаем
|
||||||
|
сервисы (`docker compose up -d` под каждым пользователем).
|
||||||
|
3. Изучаем, в чём дело на target, лечим, повторяем cutover.
|
||||||
|
|
||||||
|
Поэтому шаг «Заморозка источника» отделён от «удаления» — у нас
|
||||||
|
есть «горячее запасное» как минимум на пару дней.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Открытые вопросы
|
||||||
|
|
||||||
|
На текущей итерации — нет, все ключевые развилки закрыты:
|
||||||
|
|
||||||
|
- ~~Auth для cr.yandex~~ → OAuth-token Яндекса (`yc_oauth_token` в
|
||||||
|
vault, `community.docker.docker_login` в плейбуках).
|
||||||
|
- ~~Инвентарь~~ → два отдельных файла, после cutover `timeweb.yml`
|
||||||
|
переименовывается в `production.yml`.
|
||||||
|
- ~~Регион/TZ Timeweb~~ → совпадает с текущим.
|
||||||
|
- ~~IP-whitelist в конфигах~~ → отсутствует, смена IP безопасна.
|
||||||
|
- ~~Объём данных vs 80 ГБ~~ → 22 ГБ всего, из них calibre 16 ГБ;
|
||||||
|
с запасом влезает в фазе 1, второй диск не на критическом пути.
|
||||||
|
|
||||||
|
Возможные вопросы по ходу реализации (выяснятся в процессе):
|
||||||
|
|
||||||
|
- Конкретная процедура получения OAuth-token Яндекса (через
|
||||||
|
`oauth.yandex.ru` или через `yc` CLI).
|
||||||
|
- Поведение Caddy при первом выпуске сертификатов после смены DNS —
|
||||||
|
убедиться, что rate-limit Let's Encrypt не упрётся (≈ 17
|
||||||
|
поддоменов выпускаются сразу, лимит LE — 50 сертификатов в неделю
|
||||||
|
на registered domain, запас есть).
|
||||||
|
- Federation Matrix после смены IP — обычно достаточно того, что
|
||||||
|
apex `vakhrushev.me` отдаёт `.well-known/matrix/server`, но
|
||||||
|
стоит проверить через `federationtester.matrix.org` сразу после
|
||||||
|
cutover.
|
||||||
@@ -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.5.3
|
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 }}'
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
|
|
||||||
# Must be executed for every user
|
|
||||||
# See https://cloud.yandex.ru/docs/container-registry/tutorials/run-docker-on-vm#run
|
|
||||||
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
curl --silent --show-error -H Metadata-Flavor:Google 169.254.169.254/computeMetadata/v1/instance/service-accounts/default/token | \
|
|
||||||
cut -f1 -d',' | \
|
|
||||||
cut -f2 -d':' | \
|
|
||||||
tr -d '"' | \
|
|
||||||
docker login --username iam --password-stdin cr.yandex
|
|
||||||
@@ -1,63 +1,63 @@
|
|||||||
---
|
---
|
||||||
- name: 'Configure netdata'
|
- name: "Configure netdata"
|
||||||
ansible.builtin.import_playbook: playbook-netdata.yml
|
ansible.builtin.import_playbook: playbook-netdata.yml
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
- name: 'Configure dozzle'
|
- name: "Configure dozzle"
|
||||||
ansible.builtin.import_playbook: playbook-dozzle.yml
|
ansible.builtin.import_playbook: playbook-dozzle.yml
|
||||||
|
|
||||||
- name: 'Configure goaccess'
|
- name: "Configure gitea"
|
||||||
ansible.builtin.import_playbook: playbook-goaccess.yml
|
|
||||||
|
|
||||||
- name: 'Configure gitea'
|
|
||||||
ansible.builtin.import_playbook: playbook-gitea.yml
|
ansible.builtin.import_playbook: playbook-gitea.yml
|
||||||
|
|
||||||
- name: 'Configure gramps'
|
- name: "Configure gramps"
|
||||||
ansible.builtin.import_playbook: playbook-gramps.yml
|
ansible.builtin.import_playbook: playbook-gramps.yml
|
||||||
|
|
||||||
- name: 'Configure memos'
|
- name: "Configure memos"
|
||||||
ansible.builtin.import_playbook: playbook-memos.yml
|
ansible.builtin.import_playbook: playbook-memos.yml
|
||||||
|
|
||||||
- name: 'Configure miniflux'
|
- name: "Configure miniflux"
|
||||||
ansible.builtin.import_playbook: playbook-miniflux.yml
|
ansible.builtin.import_playbook: playbook-miniflux.yml
|
||||||
|
|
||||||
- name: 'Configure outline'
|
- name: "Configure outline"
|
||||||
ansible.builtin.import_playbook: playbook-outline.yml
|
ansible.builtin.import_playbook: playbook-outline.yml
|
||||||
|
|
||||||
- name: 'Configure rssbridge'
|
- name: "Configure rssbridge"
|
||||||
ansible.builtin.import_playbook: playbook-rssbridge.yml
|
ansible.builtin.import_playbook: playbook-rssbridge.yml
|
||||||
|
|
||||||
- name: 'Configure wakapi'
|
- name: "Configure wakapi"
|
||||||
ansible.builtin.import_playbook: playbook-wakapi.yml
|
ansible.builtin.import_playbook: playbook-wakapi.yml
|
||||||
|
|
||||||
- name: 'Configure wanderer'
|
- name: "Configure wanderer"
|
||||||
ansible.builtin.import_playbook: playbook-wanderer.yml
|
ansible.builtin.import_playbook: playbook-wanderer.yml
|
||||||
|
|
||||||
- name: 'Configure calibre'
|
- name: "Configure calibre"
|
||||||
ansible.builtin.import_playbook: playbook-calibre.yml
|
ansible.builtin.import_playbook: playbook-calibre.yml
|
||||||
|
|
||||||
- name: 'Configure remembos'
|
- name: "Configure remembos"
|
||||||
ansible.builtin.import_playbook: playbook-remembos.yml
|
ansible.builtin.import_playbook: playbook-remembos.yml
|
||||||
|
|
||||||
- name: 'Configure apprise'
|
- name: "Configure apprise"
|
||||||
ansible.builtin.import_playbook: playbook-apprise.yml
|
ansible.builtin.import_playbook: playbook-apprise.yml
|
||||||
|
|
||||||
- name: 'Configure tuwunel'
|
- name: "Configure tuwunel"
|
||||||
ansible.builtin.import_playbook: playbook-tuwunel.yml
|
ansible.builtin.import_playbook: playbook-tuwunel.yml
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
- name: 'Configure homepage'
|
- name: "Configure homepage"
|
||||||
ansible.builtin.import_playbook: playbook-homepage.yml
|
ansible.builtin.import_playbook: playbook-homepage.yml
|
||||||
|
|
||||||
- name: 'Configure transcriber'
|
- name: "Configure transcriber"
|
||||||
ansible.builtin.import_playbook: playbook-transcriber.yml
|
ansible.builtin.import_playbook: playbook-transcriber.yml
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|
||||||
- name: 'Configure authelia'
|
- name: "Configure authelia"
|
||||||
ansible.builtin.import_playbook: playbook-authelia.yml
|
ansible.builtin.import_playbook: playbook-authelia.yml
|
||||||
|
|
||||||
- name: 'Configure caddy proxy'
|
- name: "Configure caddy proxy"
|
||||||
ansible.builtin.import_playbook: playbook-caddyproxy.yml
|
ansible.builtin.import_playbook: playbook-caddyproxy.yml
|
||||||
|
|
||||||
|
- name: "Configure goaccess"
|
||||||
|
ansible.builtin.import_playbook: playbook-goaccess.yml
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
- files/authelia/secrets.yml
|
- files/authelia/secrets.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "calibre"
|
app_name: "calibre"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
# - name: "Install python docker lib from pip"
|
# - name: "Install python docker lib from pip"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "dozzle"
|
app_name: "dozzle"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
# See: https://github.com/zyedidia/eget/releases
|
# See: https://github.com/zyedidia/eget/releases
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "gitea"
|
app_name: "gitea"
|
||||||
|
|||||||
@@ -52,6 +52,13 @@
|
|||||||
- "{{ db_dir }}"
|
- "{{ db_dir }}"
|
||||||
- "{{ report_dir }}"
|
- "{{ report_dir }}"
|
||||||
|
|
||||||
|
# Owner/mode проставит caddyproxy при своём (позднем) прогоне.
|
||||||
|
- name: "Ensure caddy logs directory exists"
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: "{{ caddy_logs_dir }}"
|
||||||
|
state: "directory"
|
||||||
|
mode: "0755"
|
||||||
|
|
||||||
- name: "Ensure caddy access log exists before goaccess starts"
|
- name: "Ensure caddy access log exists before goaccess starts"
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
content: ""
|
content: ""
|
||||||
@@ -77,8 +84,8 @@
|
|||||||
group: "{{ app_user }}"
|
group: "{{ app_user }}"
|
||||||
mode: "{{ item.mode }}"
|
mode: "{{ item.mode }}"
|
||||||
loop:
|
loop:
|
||||||
- {name: "Dockerfile", mode: "0640"}
|
- { name: "Dockerfile", mode: "0640" }
|
||||||
- {name: "entrypoint.sh", mode: "0750"}
|
- { name: "entrypoint.sh", mode: "0750" }
|
||||||
|
|
||||||
- name: "Run application with docker compose"
|
- name: "Run application with docker compose"
|
||||||
community.docker.docker_compose_v2:
|
community.docker.docker_compose_v2:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "gramps"
|
app_name: "gramps"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
- vars/homepage.yml
|
- vars/homepage.yml
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
- vars/homepage.yml
|
- vars/homepage.yml
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
@@ -26,9 +27,11 @@
|
|||||||
loop:
|
loop:
|
||||||
- "{{ base_dir }}"
|
- "{{ base_dir }}"
|
||||||
|
|
||||||
- name: "Login to yandex docker registry."
|
- name: "Login to Yandex Container Registry"
|
||||||
ansible.builtin.script:
|
community.docker.docker_login:
|
||||||
cmd: "files/yandex-docker-registry-auth.sh"
|
registry_url: "{{ yc_container_registry }}"
|
||||||
|
username: "oauth"
|
||||||
|
password: "{{ yc_oauth_token }}"
|
||||||
|
|
||||||
- name: "Copy docker compose file"
|
- name: "Copy docker compose file"
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "memos"
|
app_name: "memos"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "miniflux"
|
app_name: "miniflux"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "netdata"
|
app_name: "netdata"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "outline"
|
app_name: "outline"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "remembos"
|
app_name: "remembos"
|
||||||
@@ -76,3 +77,5 @@
|
|||||||
when:
|
when:
|
||||||
- config_file_result.changed
|
- config_file_result.changed
|
||||||
- not docker_compose_file_result.changed
|
- not docker_compose_file_result.changed
|
||||||
|
tags:
|
||||||
|
- run-app
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
user_name: "<put-name-here>"
|
user_name: "<put-name-here>"
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
|
|
||||||
- name: "Remove application dir"
|
- name: "Remove application dir"
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "/mnt/applications/{{ user_name }}"
|
path: "{{ (application_dir, user_name) | path_join }}"
|
||||||
state: absent
|
state: absent
|
||||||
|
|
||||||
- name: "Remove home dir"
|
- name: "Remove home dir"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "rssbridge"
|
app_name: "rssbridge"
|
||||||
|
|||||||
+7
-3
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
apt_packages:
|
apt_packages:
|
||||||
@@ -14,7 +15,9 @@
|
|||||||
- htop
|
- htop
|
||||||
- jq
|
- jq
|
||||||
- make
|
- make
|
||||||
|
- python3-croniter
|
||||||
- python3-pip
|
- python3-pip
|
||||||
|
- python3-requests
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- tree
|
- tree
|
||||||
|
|
||||||
@@ -40,9 +43,9 @@
|
|||||||
group: root
|
group: root
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
|
|
||||||
- name: 'Create directory for mount'
|
- name: 'Create directory for applications'
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: '/mnt/applications'
|
path: '{{ application_dir }}'
|
||||||
state: 'directory'
|
state: 'directory'
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
tags:
|
tags:
|
||||||
@@ -50,9 +53,10 @@
|
|||||||
|
|
||||||
- name: 'Mount external storages'
|
- name: 'Mount external storages'
|
||||||
ansible.posix.mount:
|
ansible.posix.mount:
|
||||||
path: '/mnt/applications'
|
path: '{{ application_dir }}'
|
||||||
src: 'UUID=3942bffd-8328-4536-8e88-07926fb17d17'
|
src: 'UUID=3942bffd-8328-4536-8e88-07926fb17d17'
|
||||||
fstype: ext4
|
fstype: ext4
|
||||||
state: mounted
|
state: mounted
|
||||||
|
when: mount_external_storage | default(false) | bool
|
||||||
tags:
|
tags:
|
||||||
- mount-storage
|
- mount-storage
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
- vars/transcriber.yml
|
- vars/transcriber.yml
|
||||||
- vars/transcriber.images.yml
|
- vars/transcriber.images.yml
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
- vars/transcriber.yml
|
- vars/transcriber.yml
|
||||||
- vars/transcriber.images.yml
|
- vars/transcriber.images.yml
|
||||||
|
|
||||||
@@ -38,9 +39,11 @@
|
|||||||
group: "{{ app_user }}"
|
group: "{{ app_user }}"
|
||||||
mode: "0600"
|
mode: "0600"
|
||||||
|
|
||||||
- name: "Login to yandex docker registry."
|
- name: "Login to Yandex Container Registry"
|
||||||
ansible.builtin.script:
|
community.docker.docker_login:
|
||||||
cmd: "files/yandex-docker-registry-auth.sh"
|
registry_url: "{{ yc_container_registry }}"
|
||||||
|
username: "oauth"
|
||||||
|
password: "{{ yc_oauth_token }}"
|
||||||
|
|
||||||
- name: "Copy docker compose file"
|
- name: "Copy docker compose file"
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "tuwunel"
|
app_name: "tuwunel"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: "Ensure UFW is installed"
|
- name: "Ensure UFW is installed"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: Perform an upgrade of packages
|
- name: Perform an upgrade of packages
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "wakapi"
|
app_name: "wakapi"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
vars_files:
|
vars_files:
|
||||||
- vars/secrets.yml
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
|
||||||
vars:
|
vars:
|
||||||
app_name: "wanderer"
|
app_name: "wanderer"
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ ungrouped:
|
|||||||
ansible_host: "158.160.46.255"
|
ansible_host: "158.160.46.255"
|
||||||
ansible_user: "major"
|
ansible_user: "major"
|
||||||
ansible_become: true
|
ansible_become: true
|
||||||
|
application_dir: "/mnt/applications"
|
||||||
|
mount_external_storage: true
|
||||||
|
|||||||
@@ -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,8 @@ 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"
|
||||||
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"
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +29,25 @@ def _remote_host() -> str:
|
|||||||
return _yq(".ungrouped.hosts.server.ansible_host")
|
return _yq(".ungrouped.hosts.server.ansible_host")
|
||||||
|
|
||||||
|
|
||||||
|
def _application_dir() -> str:
|
||||||
|
"""Чтение application_dir: сначала из inventory (override), затем из vars/vars.yml."""
|
||||||
|
inv_value = _yq('.ungrouped.hosts.server.application_dir // ""')
|
||||||
|
if inv_value:
|
||||||
|
return inv_value
|
||||||
|
result = subprocess.run(
|
||||||
|
["yq", ".application_dir", VARS_FILE],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
value = result.stdout.strip()
|
||||||
|
if not value or value == "null":
|
||||||
|
raise Exit(
|
||||||
|
f"application_dir не определён ни в inventory, ни в {VARS_FILE}", code=1
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _rest_args() -> list[str]:
|
def _rest_args() -> list[str]:
|
||||||
"""Возвращает аргументы после '--' из sys.argv"""
|
"""Возвращает аргументы после '--' из sys.argv"""
|
||||||
try:
|
try:
|
||||||
@@ -60,7 +80,7 @@ def pl(ctx: Context) -> None:
|
|||||||
raise Exit("Укажи хотя бы один плейбук: inv pl -- <name> [name ...]", code=1)
|
raise Exit("Укажи хотя бы один плейбук: inv pl -- <name> [name ...]", code=1)
|
||||||
playbooks = [_resolve_playbook(name) for name in names]
|
playbooks = [_resolve_playbook(name) for name in names]
|
||||||
ctx.run(
|
ctx.run(
|
||||||
f"uv run ansible-playbook -i production.yml --diff {' '.join(playbooks)}",
|
f"uv run ansible-playbook -i {HOSTS_FILE} --diff {' '.join(playbooks)}",
|
||||||
pty=True,
|
pty=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,8 +105,9 @@ def login_as_app(ctx: Context, app: str) -> None:
|
|||||||
"""SSH и переключиться на пользователя приложения: inv login gitea"""
|
"""SSH и переключиться на пользователя приложения: inv login gitea"""
|
||||||
# sudo -i: login shell, -u: от имени пользователя
|
# sudo -i: login shell, -u: от имени пользователя
|
||||||
# bash -i: интерактивный режим (job control), -l: login (читает профиль)
|
# bash -i: интерактивный режим (job control), -l: login (читает профиль)
|
||||||
|
app_dir = f"{_application_dir()}/{app}"
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
f"""ssh {_remote_user()}@{_remote_host()} -t 'sudo -iu {app} bash -c "cd /mnt/applications/{app} && exec bash -il"'""",
|
f"""ssh {_remote_user()}@{_remote_host()} -t 'sudo -iu {app} bash -c "cd {app_dir} && exec bash -il"'""",
|
||||||
shell=True,
|
shell=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
ungrouped:
|
||||||
|
hosts:
|
||||||
|
server:
|
||||||
|
ansible_host: "92.53.105.41"
|
||||||
|
ansible_user: "major"
|
||||||
|
ansible_become: true
|
||||||
@@ -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"
|
||||||
|
|||||||
+162
-174
@@ -1,175 +1,163 @@
|
|||||||
$ANSIBLE_VAULT;1.1;AES256
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
30613937343031343632383733623435366535373231316163393436363636656462326262383565
|
37316466386337633433633632333065613137643963356437653030653433353464666636383238
|
||||||
3032663665323131626263356531633934326639636231620a363635376263333438336331343366
|
6131616231356633383839653530666333343666316363630a323736656238336633336363656634
|
||||||
36386337323165333861633062656433313062343764636138663533333639316336306230653732
|
35633031623730613966343533393033386538346162333061613135353063323533336135376337
|
||||||
3331336137616263630a306135333566646434663231383138363966386661643836626561376338
|
6262346462636361370a356336616330323738383535356238383238656366303833313137613461
|
||||||
33636362323937386664646630383062613535666431393634316337626564613733313861386238
|
39613266616632376433636263653332363661373832316538343530353963363762356534373264
|
||||||
39316263666662633066633836366236346431313531656339613566303962656165396662326563
|
38316134643632386564626662616235316563343833646164336439623035303838316630393030
|
||||||
65623036333932393739646162353836646562643866396263386232633933326538316637656365
|
32636566663835626362666235363432316430633139333935383031303564353633323835616530
|
||||||
38656562383861613030306635613236646235613436316635386531656666363738396461313263
|
39353336366634356530393339633836613563666435323533323731666666656339313431613161
|
||||||
30653934366537303133613962653137633131323431396266646339376339623034373963666438
|
63386639663635393339643965643664303130633234333130663336623738313665366630316539
|
||||||
66383464303431353962323032316533613138383831343036383230303931326433396333623935
|
39373930316434636261646431633666653066373632396136313639633266383036643739323936
|
||||||
37396432376637373135666236333332383262323931616432343665653836626265376632643765
|
63343836666263623966333364616230393338386439616264616437373739373366326430363038
|
||||||
33303835393863333334653664613337343063313362363136383234666335636565383237656639
|
61333237323139336236333366666164313364366663333335646139353061323362313465613865
|
||||||
34323839613765626231303230616661626530633530333165373535663139643339656438396237
|
32376234323936336466356239666162323330323133616166333733613566636635303965316665
|
||||||
36656134643636643733363336343739616532666130393863666665393138383261353730626565
|
61616135333964613862303432323032313533353132303762396539323033636636643539663334
|
||||||
38306133343463306462656534326431623238336562653433316233383861303032393437316336
|
36663866313130303531333961373337316462376564396639656265393663373230623937376337
|
||||||
32353338613639653735393239333235633565636563313933333763323339656237326162316465
|
34656339323864633462633030333730613765653631636233356535643333356661333331303935
|
||||||
66313034306263343462376632303539656533353265336366613338326439323732623438626162
|
33646364636434336162356535393162353533636262333735616463623432653933316230306261
|
||||||
39393937613836656236383030343436303632363330313734333665643365666138633034323462
|
38353766363130333963613830643735663930353162343665323435613435326638363162356438
|
||||||
66376333386237383666623434636662363338626538353933636632646236393630343739636666
|
35666666343165323561396336646264613633323066613665346535313165306435666463353030
|
||||||
37666531633839363365633863646530396432613166313035353638313463373338313139616133
|
33626661326536646264343531653834616563316533386539656431353165633161666366386433
|
||||||
62383234356665333132613664383931316238353863306538343831363233303862383737373939
|
66623662303636393732643330333664373935313932306232306433653038623461393030306562
|
||||||
37303430303766343366633536643139363366663734326162366434333165613033653666383337
|
66663637636534393631633939363037303331383166343561373163666263323563313636326538
|
||||||
62626538316463343466613065326666396266643661656164376336336532666134613663623163
|
31656132376636653336353666316639623230613563333363653965333435323231303161333231
|
||||||
64316335633839356231393130343938613334393737666663363662356466326235666561653239
|
65353539633132373036346233626533356139373938646130376437626532383864623138613463
|
||||||
39386635616165633063383032666366383861333038373636613663613461316433633562623664
|
65373530343563613633323439636638316330663639306438323761306238616539353262326166
|
||||||
65373536663230356632663133356639323838653431333836376330316162633261333934363335
|
61323239666661366665343736323834633065306237643538383238616563353366343936663835
|
||||||
34383937343063303835626435316534356239316230326566383036646237336238623036323161
|
66313066653832636534346136373962653136303435623365376235626463366633623561313231
|
||||||
62326264636130323965313866616631663039623431363139363462663435323866393437373566
|
66343861363336303136666136363531386166316631643565383835333261356237373736386638
|
||||||
37353463353731303434303435353061633531663464656336306439373238633038343237313133
|
32653565623034303939383134613235353237393035336662303432653734636630373235303733
|
||||||
34333463626261333038363438343034373335346332316430376436656331626664376664323037
|
37303234333764303863323733356363353864323038393762373339303239656566363966363832
|
||||||
32393631663434383265326231353035356333343739386132326435653438306136373237396539
|
66373965333362383031636330613637656436653365303930316233396431323338636239333930
|
||||||
33613462623562343966343933363037326234323836363636313938666534333337646139326533
|
62353637333562343534663964636235623363313463613135373639653536636339376435313662
|
||||||
61633666623936646366643336333339303633643230393465623031643963643635313264353236
|
31616465643730336562373463353965616263366137316430303666346532633735333661616337
|
||||||
65313631663430336262326463663938386630363464386230383766376363373235366438393635
|
31303361633437663065326666633437383534366639396661623336643565383336623232666563
|
||||||
65313232383334666263626662646264393565326164613364313138303638653333653963316561
|
64376136303637393330363263666631336461396163373932346138653362326163666331336434
|
||||||
34346464653637376433356335663930396432386238366132393562393162353235393438633533
|
66356232666439633132363537626436613064663562333536303238313834303666363830353632
|
||||||
62656533316431666463633530653832356263653030326366663932306662613465643638313633
|
33633633373738383165363135323232313735393335646238326261643765613061623264343261
|
||||||
62396562616463313066343832316238386234343537346436623039643132393562303130613331
|
37663531396165653838346431666534636365373563366636383165376137396537353031653261
|
||||||
38653261353132633036623138643338366534396237613333333765653436363032616235373035
|
62393437633735366433613166356337303564313538633933633465363331316332656262303439
|
||||||
35313966623531373636363638383862333935353931653861663966643531383335653739356565
|
62383535383666383535326538356431313365663937313065363138636336666631643737343634
|
||||||
31373937396234616135653765643131666530383030343064366531336135366265633232653433
|
61396664383539316261386235643430383436396638303737366632306538636133666239616162
|
||||||
65396566626232633831343734353432633462343336616135373861303836613463393736306133
|
33373066623365353832666139633436343664333434393933646265323132616665623938643061
|
||||||
64663531643630326432376235386433623365373163366663623632333531623863623663643434
|
32663763316639333036343333373632633431373038326666323036333664333332313731313865
|
||||||
36646134623665633531643732663137613862343666613139336231646564363266343935653263
|
33363638616661363763313263623230663735383834353835613861373063666538363764333433
|
||||||
66653366666635666535636637626134363633336233613732656166373063333237323465616434
|
65316333643937366632313335643364333736633936316132313534373862646365323037363963
|
||||||
39623238636235333866666536346430373735323530633133663937636366663530326465386161
|
63353237373730623533633961323038616532643034643939353034383036326461383566346230
|
||||||
39346466386133656633373438333133303566363233626238366133636333656462373065613863
|
61363561666265393536323164323462356237373764306632383034646665613130373030383336
|
||||||
65363439383163323332383931663833303234326132343462333835323664363461656566393065
|
39353235353036323964356139363530636364346666663331643736306361653966636164613830
|
||||||
38646261323336316239363465343238643132306235613031626438323838653066376561626661
|
65353664643733383737663766613965616334356561376538623763613331343334613532386562
|
||||||
34356530666665323230646436633935343861323638656638323163306236393865366630636236
|
33623564636233663234313631623734333934303439383662336665623936643039623039303962
|
||||||
62386161646131623738333664636361396239643666323837646332383538623734386531313664
|
63336433333766376130356339303239626536383364373063663435653236386139613366323938
|
||||||
65313632343365393130643137353735666565663030383231616231313237323866386336316361
|
34653161313061333837373164653238663539326562633237616134623366643039373033353331
|
||||||
66656165643261653464316639613635323531306362353164373531326461666437303434346233
|
31633436323463623661656636383964643362613130336537653731646239313365633662656132
|
||||||
31383864346233313633353065343236633636386138323761666662373564623234613965323131
|
35393262613830323964346662666664346664316662623865663236643065333533313135643063
|
||||||
36313861316563333262306434663265313237626631396561303236343330633738356666633663
|
37343461316564316666343465303539383463613663306136653465383336353232366238316332
|
||||||
61313663336237653361383963333764336137396666613634313036373564353564643334623363
|
31313837323533313863333565643762653362633231333436396238666562363139626336666532
|
||||||
62353531306532323664376363383938646536393339346666656339393230613362666337663861
|
38326331653736393031636264316330643532353862366235613334383266613238326431343066
|
||||||
37633633653463343430666634643863383438633933343839663865616136363538643061343437
|
33666566613061363465376637636636626432383132636330343736653034643965396566373064
|
||||||
34613037353835613866303230303162396531626663616164343263633261363335313936666339
|
37303830393531383131646161613335666631633535383739626130366464313930343262393533
|
||||||
63383533616530356262363838636466333038656339316364626263383731313464313734613630
|
66373137303531623663643339643233373737303831323735623561656165383034373131386639
|
||||||
62646266666136616632636161363631623362346230643134663664396565323932343462383661
|
32333838663264366661616262373439323433386538373261373465343163326438623962333566
|
||||||
38303663653262333236613833396237663834333139316666343065396137306562613265343863
|
30376463646631663330366364626133386131313261396434656336636566323234633864323731
|
||||||
65323065663862636230636664623132306231366462346432343030376236346465663831623537
|
61393864666537656338646462353764356131303836333762333932643130346439356432323231
|
||||||
63633231333165613731626137656539366131633364623661616136616434306563656139346137
|
65373866393263313236353937363031366661666664346362313466356366663336383761623637
|
||||||
63313032343161623235306230633361666163623061333738383135636664623438323238663631
|
66343938303839663630363562353634353938643638373335636530323430656336303963666662
|
||||||
37613964643931323432353431306564393639386437666539376238643065343738313265373661
|
63643031396133646531316162323836616461373863643463663132383764363034663562363365
|
||||||
61303764646463326632653335323432646436353765633862623838386337623464333839643833
|
37383234663939646539643562376532323936616264326533333637323633373330643162623537
|
||||||
39383961666234363638323735636231623962666461373435633631323530643237656464396465
|
63626138616165343938643237313562616234303665303635653830343735326661666235643333
|
||||||
66623431393461613634373237646636333965396435663563363161626666356638366462373261
|
36623333343365613161663663356566656533363736313231336536386339326336373730643630
|
||||||
37633238323135666136623663653665303832656437663536383236313334313461353032663933
|
61386139313064376161303066323162316564313761303734376663653464323138303339323763
|
||||||
63643164363664663939613635373362376162336262653332663936313737396130366330656532
|
35373930366262373936356464613731396136613634663562373737663739613562373333386265
|
||||||
31653463383132643262613839613962663836376463343661393736633633396164643264653431
|
35323161316633373430633833663266633030613430613635356135393161653836366464326236
|
||||||
30663732303236653165386537653432656266363239373030333630353661666636303730373937
|
63373738366661333964623962363035636131656539613139373138643839643138373564333430
|
||||||
65363237366333376133306437376534636133356238326461333762326563386265363636323831
|
34623932396134343762393334373033663132306636633864643235373861386633366639316566
|
||||||
39343665386262336265383865343563343832623766656534306661326462333561373835366631
|
66623439613730353237366366663161363030646663323836653638383330323164333461666334
|
||||||
39636361623831623533353962633363393531313530363833613962616331653565633733303964
|
62333662643631373361643263646537323239643234346333333261623933626166333630356662
|
||||||
32393433303938323566646264323761633035653231353761643261663839313665663434643834
|
61333362313230343134343331303031303634383335313339303363383930373136333336313062
|
||||||
65356432393431336235306437643861653437643362363839623634333835376636623664616139
|
39626532646365663734313462343766333962333266363965373630363831626633623830633733
|
||||||
66376562633232636431626436653161333137633466313433663433383230636337653535643430
|
37633666663361343639366663313237633161353062396337393138613261383935353730353932
|
||||||
61613032656135323765613837626266313632353661346636643866613138303930346563623738
|
32383066386636346433306662326130633662363135313331383530346632356333363238383266
|
||||||
35613831623565353432336338373465303437623234313736353661353430656661366365373230
|
61303138636664303637326137376638333266343132356237383432613238333632356163643232
|
||||||
33646134356661616164303865623464306339653439613365626261323237623135346537393535
|
32333966383164343064643934333765386136323736303138326635663561343964393934316433
|
||||||
62393465343134626333333462316331656134383362383031353863316632393061333933336362
|
37663763653034363039636436346630303664346237616433376462303463633930383430646430
|
||||||
36326662363833303436663166383365346433323866346462663261333330656666663162383564
|
38343762663635373033373135326361393935386566363031366630366561663431333461663332
|
||||||
35336438643064313833393638323864343237616163383033313966303262326135323335353931
|
62393538306163386139656431353235343265373033663530306163316464373432353661343439
|
||||||
66333938393264323533353231303935346661653835386262306133393065356535643835663665
|
35356634323966646263613937663234613431316333656330343765316163626339376430343064
|
||||||
38363930356530366135313734306464623739376438613430373634396339393864396264303135
|
30363335633337623139343464656431353738623230633137633130353430373435303963663333
|
||||||
61356333636236326566386264353930626564636438616265353939383733663837313233356363
|
66646563626336393538613134636162643864316163626361663663313131343063643336396532
|
||||||
63643835393437336366313030303864306536666638623430356263336234646462383666316431
|
36333336386130653537653637653163343031376436383763386163376336623837336365616530
|
||||||
36313464346266646438383762313138376338323537386635636561656662306533316362396162
|
66386262363633353465663664326537343037383838333863366662306131303364626538623763
|
||||||
38326165633532623933376165643861323735353831363264376162316561613038633961333337
|
30376132613861636539393138326131356333613837336365383862383362303233633333396238
|
||||||
30646461636332623466643033633764333330353832616365376633643263336131313733653139
|
34663163363234363564336338656366313931353262303032343334303864386337356437633361
|
||||||
39646239366261366465333962643565636430393464613866613038333636393362383636343534
|
39383366386334303039313439376236663533633035326662626466333539626261643466653966
|
||||||
61323830616234633364346131336630393965373730343464366166376232346464636263323639
|
66383862663635346539396139323138613264373436653338653136366135613962373562373932
|
||||||
63336464623733363139366665336131653163613833383261376138373032666663356637383832
|
62316134636636373264623365613636373132396433373630643531633536333130646130373963
|
||||||
32313130633363346435383638616236633761616166663339316437353938636636613530383836
|
33356538316334336236346532356263363933633933376165666563313161343638656365373137
|
||||||
64623661366130656439306266343435396334383564353466663339383862313733313931383463
|
61656438313063363661316537336538646233326564633934303766383738343130373737653961
|
||||||
64323237656361383262343735366562623965356636343963363966616333313333646233373464
|
32326462626435343363326366663364376431613837646365383933396437383138333665383832
|
||||||
33383939386262663730316333616663636161356463396362643237356532386162363131626461
|
33376563363466333637656233643137333636326535653335393931653063623937373836313863
|
||||||
37323965313063623463356133626531393339336535303562343530316663613639646531323136
|
30363238356466363461313633306565646336633366346461333032643635366336646365643761
|
||||||
30353732646237623264653963373863363965326338666264306562373932393333633639396131
|
37363433623037333838663731376663306134363665383835333164643131646632393639363538
|
||||||
34303764396330326165636264313532393961303038623031336631653831323337306261333630
|
32656539643461653861376436396130316166616265336433356663343235333763636564326464
|
||||||
37333964376533636132303335653935343932373330373632626235356437636165623436383036
|
32663530333030306130366532636439353264303137613366353266363664346433666565303135
|
||||||
38313565373561393834316532333930356135623439373161643063643738353031353565396330
|
34343835623936643631643166636339306231323138356565373663636665303264653161613539
|
||||||
37656162346433326638353439613666336534336562623633643230636134383931653538616665
|
65336230633035336464373431663130383131376430306136333634376435393933623238653136
|
||||||
32393432383265613237323138386361353934373965306462393666616532653563626232643035
|
61633138653339336631313932353264366334616630363464373131376538303630663566653061
|
||||||
61643732376434633537633663633130313437656166333239633533393334373163333566343430
|
30386665383864383838613835613337633937616437376363626263376634643061393937653430
|
||||||
38633165353637306237316436663235633162353132646562353638333038663636323465633632
|
38626539383634616536396665396262633666376438363930383635353936623534353131666333
|
||||||
34643037623634643534663366633133363030323966313065353333633636646636306565333238
|
31616536383662663666333265623032613936383063303636366362346337333337633739393665
|
||||||
39336662626138306464613461343762316533656433626165323764616535623539336439396663
|
62306639376466636161333233656239313964373239336134646434613166646534656466356431
|
||||||
63393365626235613063613934306132333162646237316364306637346136623061363236383765
|
36626162383866643633323938646130366261373366353764646234653236323166363463366435
|
||||||
37353138363337346530626563366136333635663863313038643537366237633362343136396664
|
65636137643639353039616138383265633832373864643438366333393031326363333461376138
|
||||||
61623237353433333238633163636565386134356565303763336238636366316330666339383365
|
65303838626435656634626236663338316632613664646463306561663532353062643637663866
|
||||||
30323235356633656362353738393234616435663333613364316539636430623262643162313337
|
32663236633630383365663235313337373837396531326532333632343736343035643733333262
|
||||||
36323466303832336530336566343731306362333862663537613339663562623739343636613162
|
61633532323330623338393838626333323932376630316239303335313765343039313332316336
|
||||||
36343563373665376565366266343461643562636630623166626165636337613931653338633862
|
32303638356539333136353163396536313830333264613266303139376465613166363730343635
|
||||||
65393138353661656265666335343263333063653430326532663839383433643966363639643636
|
37623532356135613831363137356238373863653933366532346532363130386363616133373764
|
||||||
61376365363538636235666235623638376334363265626136313536353637386564303936636263
|
62353066376631343934356337326465613436363361646431373962656639393566323731316136
|
||||||
32306239306339656238393864666135613663366332666135663461353366313833376430376263
|
30313730386438663261636562643061333664303636373732663464356530336333383739313566
|
||||||
62613163303964333735396338373737653837666435656130376435376434356462383264636561
|
35616566663065313732656532346330636335383664383061323131306530623632353132326432
|
||||||
34353563316132336663316166663832383939333634316562383634383838336531313731613666
|
34313365396430396338363063376534363332306432313531303261353736353138383733346363
|
||||||
62643231636266353935343539366465376139643834306261623738313432306133653461383738
|
37373063666630633632613039353763626165613038616464363333383662356363373133623261
|
||||||
39396332373364353833626661333634346131396337636235653431616336393666373231383030
|
61316130393165633434623334616239666137653831636131393732363330363039306666346363
|
||||||
32306466613136346265653038636537646330643337663863383562323638616661333037323232
|
38353832356138383732306533646133663865663062663862663232323038383532616230386265
|
||||||
35613138363330353533643064613366343339343032373737306364353135353334336666663732
|
38626535313931386533613936343637373064666663336436326464313834333334366264336130
|
||||||
36343963613636376561666266623537316432666161326331383761323437383738373762643937
|
37346438383739313735636139343638373063306162313230366364316136333137653939313032
|
||||||
30623737643239326261343939663065643265653363633661376265626637643336613635393335
|
63303035663465653062613764333434323234323332316365333165383533396430316537623164
|
||||||
65373565333936333431656331633039323135336236656337343532643939386338663239393065
|
37356138623233656436663738393966346662646339626137663533616234653535663663383738
|
||||||
65666536333732646235633762633032393463663334616165333834653938346230316236353839
|
37323564653664653530653533396332333664636261306339663830373730613832363565613363
|
||||||
34396362386265646261373561636230363962663433303535373035346334353932643365383763
|
63333635646339656535366365633638393164663562633562383162373932306233313164386331
|
||||||
32333239613961346466356562376663613062373162666264633636323833323263333765616563
|
37613036323664316237336130366138333863316563666337333464383932353363306231336338
|
||||||
39646530343962353362363634336336323463623137646531373362353832343335366461646535
|
65393537636162316637343262313333386532616632376563333039386131336230316266396265
|
||||||
38653735316536396438613866326438363036653833366636626130323437623366373833366165
|
34626630633038623462346365316438316238663163346136663436393736623464643961303339
|
||||||
30303636666263323062343931306435363961643838636163366433376436303231316338613034
|
35613565393633373861626239663563336633643565383833373632353737393838666161633462
|
||||||
37393631363632383461373566306365306631396335633432383939336332626237653462393136
|
36316634313530663032366430656131373838616161613265633936633930646137646661653738
|
||||||
34316636643464363634366535333463326533333564633163363062666463343731396231656234
|
31356266666162643736363538646464363164633434643161643239333336326262386135633165
|
||||||
39346333303465363037313063373366373439306333636465636366666437326362626264653033
|
65656334313632336138393364356664386237313936626333616638663666396530623464613665
|
||||||
64613062343538303931646630373565663530336133633032366331626536353237336235633636
|
36636537396565343864353465633638613439663663633766356331333036313130383432366136
|
||||||
63366639366439386530303966323563323862383865356630313636333333393464653762626634
|
66383333643938613831386631386537353166653561376161633431306531343033646233343731
|
||||||
65366231613661313233626239303035323666346236636362393036353839333636343434646266
|
34363164316463633133616230316433373966343031353733343762643032356264646631323236
|
||||||
65653039353966616361363335346565383863616161316134383365616636333732653233383261
|
33393231333330303339613634643333346134643035653330343230663035343230626465386339
|
||||||
65616664343830353861616666616237313532363334653430313437313535666436383338396363
|
36323030633037363462343130343333313538393163393263633339323133656239366161323837
|
||||||
33313436363061306431366332373936633034393733646137636338336431333033343532613531
|
30383231323061323538393063316666303534663664623464383634613834326237363532353165
|
||||||
32613839393232646565663931303530376432376337613762346230646366613935383234313666
|
30356561643738373537343831663461623839303363366266356663666664623030333936656436
|
||||||
61643339353933336434666466623133336637343534303737366162316561366632333335663233
|
39616261626464343039623161343234626566623537396264366439666438343739306361383666
|
||||||
34643036326630306632353438643666623939393033646238353261386231626634303266303530
|
33326433383830313933383030393235306261323462323265623862363861653331643736363336
|
||||||
64643436653234616332623835333165626135613465346162393335353133356233666536313632
|
36373561363566346232613631653464613034653438343462383432643962626665303634646230
|
||||||
65616135666533343839666132623639343565303436623162383738353633613864356535646365
|
63656261613463623466393064623534306363663835616138626332356430633165363366343461
|
||||||
32373337393936393830666365383462333437373539666633386361373135333163393334303235
|
66623133666461313934346365623434653433373734376632616435386636613166633935376337
|
||||||
33663631386566356366666132616265373533373561616564343538303432346562356234336663
|
61326437333132306637663436663930396461393131643766643362316434346438316630343833
|
||||||
32623866396434326264636539323132613239343938353739376539383139313833376563623434
|
31343638383339646436363238393634636363303861383266313638623663303732333231316366
|
||||||
62636334326234313230666662396561393130396137306437393334323561356435343866386636
|
37653861663737656631666535383366643265373135396236393936353264613765656534313430
|
||||||
32656337656439653830653365313031326562643437376538316561653963643232353434313538
|
39663431623737346462303035613162393939623239326232326330636562326236323830613238
|
||||||
64373638616133666463393462643465306565646136643862363162643638343565316139626539
|
32303263333864616434316361646162623163306262646131346135613162353833366537666532
|
||||||
64643939383936313035323936656438313039376635383733633032613165343130663930323166
|
39326430306331663736363365386264653634613530376639313336613939363135656638353632
|
||||||
37343261333332663863366533386335373962323163616564376434636361356438393035656533
|
31656430306363396366306539626436356436306233303032623265356337303666363965353964
|
||||||
30383139323931306232353664636662313036643431663536353035356139643761613235663837
|
62373031336438326635386632313365336236303737373832366363333634383835306231623065
|
||||||
37613133363433356536316466343237613131386536356234343135323861396130663464323236
|
62646236643264373036303465663838366565616530306463613563613463323735646430663566
|
||||||
63636563633031396465663563366263373938373531336239323138653531386535643332653736
|
34356263353637386163316639636334616663303935666530396333653633343861393335313966
|
||||||
61396432356161643663623130656632633862333861656464613432623732656465376236313437
|
36383139383835343937393934316330623338646663636466396436353739303464313161376138
|
||||||
65386630633036636663303633636134343739366562643062343030383138653466326636366266
|
61663033623031653361643735366562393431643236636531323061303033313966633838646237
|
||||||
32633239343039633636313837643432333238366533393061646237626130303934356438633936
|
3831653036383565396363663066316432636537376430376165
|
||||||
38366265656365333338363431643432633463313438633361333764653637623964363732303737
|
|
||||||
61343137653930353361653364656233343166633162313964306531383834356237343031396137
|
|
||||||
34366135623530366164646532643636346233353563333031343931643037613463613639356238
|
|
||||||
36306235336562333935643035313934366339623365616661616461653832336137336464393662
|
|
||||||
38363433646139646633353162616661323433636531393339643562373538616430363061366330
|
|
||||||
35333138613136323865346462653761666534343538313033663835653631363631623532663133
|
|
||||||
64383135326333626438363066633366316364643332623030653230353861633837646362626333
|
|
||||||
38363236616265313638626263316164323563616237653465353031353734333032323761393761
|
|
||||||
31333331643161396338653330653537353634306139656536363665643437633433666236356334
|
|
||||||
64386566623836306666653766626465646664303231613062663862613565393364303233333636
|
|
||||||
61633631643437373235636133333832646463366633353939383834373362633539333766303661
|
|
||||||
3132333365333061633665366432346636646564313437333061
|
|
||||||
|
|||||||
@@ -1,4 +1,16 @@
|
|||||||
---
|
---
|
||||||
|
host_name: "rivendell"
|
||||||
|
primary_user: "major"
|
||||||
|
primary_user_uid: 1000
|
||||||
|
primary_user_gid: 1001
|
||||||
|
|
||||||
|
# Directory for all user binaries and scripts
|
||||||
|
bin_prefix: "/usr/local/bin"
|
||||||
|
|
||||||
|
# Root directory for application data. Override in inventory if host
|
||||||
|
# uses a different path (e.g. external disk mounted elsewhere).
|
||||||
|
application_dir: "/srv/applications"
|
||||||
|
|
||||||
apprise_external_port: 8000
|
apprise_external_port: 8000
|
||||||
apprise_external_url: "http://127.0.0.1:{{ apprise_external_port }}"
|
apprise_external_url: "http://127.0.0.1:{{ apprise_external_port }}"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user