Compare commits

39 Commits

Author SHA1 Message Date
av 2930842e3f Backups: update ADR after migration
Linting / YAML Lint (push) Waiting to run
Linting / Ansible Lint (push) Waiting to run
2026-06-23 09:38:09 +03:00
av 0f80e66b66 Backups: split restic operations into phases for Intelligent Tiering 2026-06-22 17:58:45 +03:00
av 2b22fde718 Gitea: update to 1.26.4 2026-06-22 17:44:21 +03:00
av c39de421e0 Backups: add restic errors
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-06-09 10:23:46 +03:00
av a50b399a85 Add ansible playbooks review 2026-06-09 10:17:32 +03:00
av 94b09be53c Outline: update to 1.8.1 2026-06-09 10:17:07 +03:00
av b637fea882 Memos: update to 0.29.1 2026-06-09 10:16:54 +03:00
av 933a0b9570 GoAccess: update Caddy to 2.11.3
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:43 +03:00
av 96710360d9 Dozzle: update to 10.6.2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:40 +03:00
av d9f0d94e1f Caddy: update to 2.11.3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:37 +03:00
av 9b853d351c Authelia: update to 4.39.20
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:46:33 +03:00
av 11744f776a Wakapi: update to 2.17.4
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:39 +03:00
av 0df5f358d0 Outline: update to 1.8.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:35 +03:00
av 62e2a72e52 Memos: update to 0.29.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:32 +03:00
av 7c91f4f355 Gramps: update to 26.6.0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 17:45:26 +03:00
av 68d8bf6a68 Gramps: update to 26.5.3 2026-05-25 09:39:27 +03:00
av e585bfdca2 Gramps: update to 26.5.2
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-24 16:49:08 +03:00
av 41822e04e8 Gitea: update to 1.26.2 2026-05-24 16:48:50 +03:00
av 21ccc7ac8c Fix style in ADR
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-24 15:56:53 +03:00
av 81478c2323 Add legacy ADR 2026-05-24 15:40:28 +03:00
av 313b1820be Update readme
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-24 14:55:23 +03:00
av 2f2c1b0754 Add ADR after migration to timeweb cloud 2026-05-24 14:51:03 +03:00
av e45e1db002 Add architecture decision record templates 2026-05-24 14:35:35 +03:00
av dc49b3497b Migration: stop yandex cloud server
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-23 17:55:58 +03:00
av 02ea9a3735 Migration: transfer data and run apps 2026-05-23 17:51:07 +03:00
av a3e53b21e6 Migration: fix application order
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-23 15:30:33 +03:00
av 1b120e3ae6 Migration: add new inventory 2026-05-23 15:04:17 +03:00
av a22be7c7d1 Migration: bootstrap new vds 2026-05-23 15:01:58 +03:00
av 7d711425fd Migration: update steps
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-22 21:06:16 +03:00
av e03a4c417d Migration: fix tags and docker registry vars 2026-05-22 21:01:11 +03:00
av 10e1e8187b Migration: fix vars in playbooks
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-22 20:52:47 +03:00
av 1ce168655d Migration: application directory as parameter 2026-05-22 20:45:52 +03:00
av fe024b3b12 Migration: fix inventory hardcode in tasks.py
Linting / YAML Lint (push) Has been cancelled
Linting / Ansible Lint (push) Has been cancelled
2026-05-22 20:21:36 +03:00
av 8378f0edb0 Migration: expose some public vars 2026-05-22 20:18:45 +03:00
av 48737c1b6d Migration: optional external hdd mount 2026-05-22 20:17:12 +03:00
av 600a30ec11 Migration: add migration log 2026-05-22 20:11:23 +03:00
av 670947fcdf Migration: login to docker registry with oauth key 2026-05-22 20:11:10 +03:00
av 3545905cbd Migration: add draft for timeweb migration 2026-05-22 19:55:54 +03:00
av 893996f0c9 Docs: add docs and drafts 2026-05-22 19:13:05 +03:00
63 changed files with 3779 additions and 1046 deletions
+81
View File
@@ -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`.
## После записи
Покажи пользователю путь к файлу и кратко содержание. Не коммить без
явной просьбы.
+11 -2
View File
@@ -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`, снижая трение.
+50
View File
@@ -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`.
+72
View File
@@ -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) | — |
+37
View File
@@ -0,0 +1,37 @@
# Краткий заголовок решения
- Дата: ГГГГ-ММ-ДД
<!-- Строку статуса добавляют позже, только если запись потеряла силу:
- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug
- Статус: устарело
У активной записи строки статуса нет. -->
## Контекст
Что вынудило сделать изменение: проблема, силы и ограничения (ресурсы
сервера, стоимость, время на поддержку, существующая архитектура).
Пиши так, чтобы через год было понятно «почему это вообще делалось»
без чтения переписки.
## Рассмотренные варианты
<!-- Опциональная секция. Оставь, только если варианты реально
рассматривались. Если решение было единственным очевидным —
удали её, а причину объясни в «Решении». -->
- **Вариант A** — суть, плюсы и минусы.
- **Вариант B** — суть, плюсы и минусы.
- **Вариант C** — если отвергнут сразу, коротко почему.
## Решение
Что именно сделано и — главное — **почему**: какое намерение и какая
причина за этим стоят. Если варианты рассматривались — почему выбран
этот, а не остальные.
## Последствия
- `+` что стало лучше, какие возможности открылись.
- `-` чем платим: новые ограничения, риски, регулярная нагрузка на
поддержку.
- Что нужно сделать как следствие (если есть).
+82
View File
@@ -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 сразу или потом.
+150
View File
@@ -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** — фоновая зачистка стиля и структуры.
+285
View File
@@ -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.
- **Перенос ВМ в отдельный фолдер** — когда в общем появится вторая
ВМ. Пока не критично.
+692
View File
@@ -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'а нужно сделать с большим
запасом — день в день не сработает.
+553
View File
@@ -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 до 60300 секунд **за
~2448 часов** до 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.
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -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"
+300 -82
View File
@@ -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,22 +454,33 @@ 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.
for app in applications: if PHASE_BACKUP in self.active_phases:
app_dir = str(app.path) for app in applications:
username = app.owner app_dir = str(app.path)
logger.info(f"Processing backup for app: {app_dir} (user {username})") username = app.owner
logger.info(f"Processing backup for app: {app_dir} (user {username})")
if app.backup_script is None: if app.backup_script is None:
warning_msg = ( warning_msg = (
f"No backup script found for app: {app_dir} (user {username})" f"No backup script found for app: {app_dir} (user {username})"
) )
logger.warning(warning_msg) logger.warning(warning_msg)
self.warnings.append(warning_msg) self.warnings.append(warning_msg)
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)
+19
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -1,7 +1,6 @@
services: services:
dozzle_app: dozzle_app:
image: amir20/dozzle:v10.6.0 image: amir20/dozzle:v10.6.2
container_name: dozzle_app container_name: dozzle_app
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+1 -2
View File
@@ -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 -2
View File
@@ -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 }}"
+10 -11
View File
@@ -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
@@ -12,15 +11,15 @@ services:
- "gramps_network" - "gramps_network"
- "web_proxy_network" - "web_proxy_network"
volumes: volumes:
- "{{ (data_dir, 'gramps_db') | path_join }}:/root/.gramps/grampsdb" # persist Gramps database - "{{ (data_dir, 'gramps_db') | path_join }}:/root/.gramps/grampsdb" # persist Gramps database
- "{{ (data_dir, 'gramps_users') | path_join }}:/app/users" # persist user database - "{{ (data_dir, 'gramps_users') | path_join }}:/app/users" # persist user database
- "{{ (data_dir, 'gramps_index') | path_join }}:/app/indexdir" # persist search index - "{{ (data_dir, 'gramps_index') | path_join }}:/app/indexdir" # persist search index
- "{{ (data_dir, 'gramps_secret') | path_join }}:/app/secret" # persist flask secret - "{{ (data_dir, 'gramps_secret') | path_join }}:/app/secret" # persist flask secret
- "{{ (cache_dir, 'gramps_thumb_cache') | path_join }}:/app/thumbnail_cache" # persist thumbnails - "{{ (cache_dir, 'gramps_thumb_cache') | path_join }}:/app/thumbnail_cache" # persist thumbnails
- "{{ (cache_dir, 'gramps_cache') | path_join }}:/app/cache" # persist export and report caches - "{{ (cache_dir, 'gramps_cache') | path_join }}:/app/cache" # persist export and report caches
- "{{ media_dir }}:/app/media" # persist media files - "{{ media_dir }}:/app/media" # persist media files
environment: environment:
GRAMPSWEB_TREE: "Gramps" # will create a new tree if not exists GRAMPSWEB_TREE: "Gramps" # will create a new tree if not exists
GRAMPSWEB_SECRET_KEY: "{{ gramps_secret_key }}" GRAMPSWEB_SECRET_KEY: "{{ gramps_secret_key }}"
GRAMPSWEB_BASE_URL: "https://gramps.vakhrushev.me" GRAMPSWEB_BASE_URL: "https://gramps.vakhrushev.me"
GRAMPSWEB_REGISTRATION_DISABLED: "true" GRAMPSWEB_REGISTRATION_DISABLED: "true"
@@ -41,7 +40,7 @@ services:
GRAMPSWEB_MEDIA_BASE_DIR: "/app/media" GRAMPSWEB_MEDIA_BASE_DIR: "/app/media"
gramps_celery: gramps_celery:
<<: *gramps_app # YAML merge key copying the entire grampsweb service config <<: *gramps_app # YAML merge key copying the entire grampsweb service config
container_name: gramps_celery container_name: gramps_celery
depends_on: depends_on:
- gramps_redis - gramps_redis
+1 -1
View File
@@ -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 }}"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}'
-12
View File
@@ -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
+21 -21
View File
@@ -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
+1
View File
@@ -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:
+3 -1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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
+1
View File
@@ -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"
+9 -2
View File
@@ -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:
+1
View File
@@ -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"
+1
View File
@@ -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:
+6 -3
View File
@@ -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:
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+3
View File
@@ -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
+2 -1
View File
@@ -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"
+1
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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
+6 -3
View File
@@ -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:
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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
+1
View File
@@ -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"
+1
View File
@@ -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"
+2
View File
@@ -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
+2
View File
@@ -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",
] ]
+24 -3
View File
@@ -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,
) )
+7
View File
@@ -0,0 +1,7 @@
---
ungrouped:
hosts:
server:
ansible_host: "92.53.105.41"
ansible_user: "major"
ansible_become: true
Generated
+46
View File
@@ -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
View File
@@ -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
+12
View File
@@ -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 }}"