Compare commits
30 Commits
10e1e8187b
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
2930842e3f
|
|||
|
0f80e66b66
|
|||
|
2b22fde718
|
|||
|
c39de421e0
|
|||
|
a50b399a85
|
|||
|
94b09be53c
|
|||
|
b637fea882
|
|||
|
933a0b9570
|
|||
|
96710360d9
|
|||
|
d9f0d94e1f
|
|||
|
9b853d351c
|
|||
|
11744f776a
|
|||
|
0df5f358d0
|
|||
|
62e2a72e52
|
|||
|
7c91f4f355
|
|||
|
68d8bf6a68
|
|||
|
e585bfdca2
|
|||
|
41822e04e8
|
|||
|
21ccc7ac8c
|
|||
|
81478c2323
|
|||
|
313b1820be
|
|||
|
2f2c1b0754
|
|||
|
e45e1db002
|
|||
|
dc49b3497b
|
|||
|
02ea9a3735
|
|||
|
a3e53b21e6
|
|||
|
1b120e3ae6
|
|||
|
a22be7c7d1
|
|||
|
7d711425fd
|
|||
|
e03a4c417d
|
@@ -0,0 +1,81 @@
|
|||||||
|
---
|
||||||
|
name: adr
|
||||||
|
description: >-
|
||||||
|
Создаёт и сопровождает Architecture Decision Records (ADR) в docs/adr/.
|
||||||
|
Используй, когда уже сделанное архитектурное или инфраструктурное
|
||||||
|
изменение нужно зафиксировать постфактум: выбор инструмента/подхода,
|
||||||
|
структурное решение, намеренный отказ от очевидного варианта, либо
|
||||||
|
прямая просьба «записать решение / завести ADR». А также когда старую
|
||||||
|
ADR нужно пометить заменённой или устаревшей. ADR пишут ПОСТФАКТУМ;
|
||||||
|
идеи, планы и обсуждения — это drafts, а не ADR.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Работа с ADR
|
||||||
|
|
||||||
|
ADR живут в `docs/adr/`. Формат и соглашения — `docs/adr/README.md`,
|
||||||
|
шаблон — `docs/adr/template.md`. Читай README перед первой записью в
|
||||||
|
сессии: правила там — источник истины, эта инструкция лишь даёт порядок
|
||||||
|
действий.
|
||||||
|
|
||||||
|
Язык записей — **русский**, стиль — как в `docs/drafts/`: конкретно,
|
||||||
|
по делу, без воды. Калибровка под личный хобби-сервер: не раздувай
|
||||||
|
запись, не предлагай корпоративные процессы.
|
||||||
|
|
||||||
|
## Сначала реши, нужен ли ADR
|
||||||
|
|
||||||
|
Заводи, если изменение **уже сделано** и это: выбор технологии/
|
||||||
|
инструмента, структурное решение, решение с долгосрочными последствиями
|
||||||
|
или дорогим откатом, либо намеренный отказ от очевидного варианта.
|
||||||
|
|
||||||
|
Не заводи:
|
||||||
|
- для рутины (бамп версии образа, сервис по накатанной схеме) и того,
|
||||||
|
что видно из кода/git;
|
||||||
|
- для идей, планов и того, что ещё не реализовано — это `docs/drafts/`,
|
||||||
|
а не ADR.
|
||||||
|
|
||||||
|
Если сомневаешься — спроси пользователя, не плоди записи молча.
|
||||||
|
|
||||||
|
## Создание новой ADR
|
||||||
|
|
||||||
|
1. **Дата.** Когда изменение реально сделано. Обычно сегодня
|
||||||
|
(`date +%F`). Если оформляем задним числом — уточни дату у
|
||||||
|
пользователя, не подставляй сегодняшнюю вслепую.
|
||||||
|
2. **Идентификатор и имя файла:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`
|
||||||
|
(slug — латиницей). Если за эту дату уже есть ADR — slug просто
|
||||||
|
должен отличаться; проверь `ls docs/adr/`.
|
||||||
|
3. **Файл.** Возьми за основу `docs/adr/template.md`.
|
||||||
|
4. **Заполни** по шаблону:
|
||||||
|
- Заголовок `# Человеческий заголовок` (без даты/ID в тексте).
|
||||||
|
- Метаданные: только `- Дата: ГГГГ-ММ-ДД`. **Строку статуса не
|
||||||
|
добавляй** — у активной записи статуса нет.
|
||||||
|
- **Контекст** — какая проблема и ограничения вынудили это делать.
|
||||||
|
- **Рассмотренные варианты** — *опциональная* секция. **Спроси
|
||||||
|
пользователя, рассматривал ли он какие-то альтернативы.** Если да —
|
||||||
|
перечисли их с плюсами/минусами (особенно те, что отвергнуты); если
|
||||||
|
нет (решение было единственным очевидным) — удали секцию целиком.
|
||||||
|
- **Решение** — что сделано и, **главное, почему**: какое намерение и
|
||||||
|
причина за этим стоят.
|
||||||
|
- **Последствия** — `+`/`-` и что нужно сделать как следствие.
|
||||||
|
5. **Индекс.** Добавь строку в таблицу «Список записей» в
|
||||||
|
`docs/adr/README.md`, **сверху** (новые сверху). Статус — `—`.
|
||||||
|
|
||||||
|
Самое важное в ADR — сохранить **почему**: намерение и причинность, а не
|
||||||
|
просто «что сделали». Не придумывай причины и альтернативы за
|
||||||
|
пользователя — если их нет в контексте сессии, обязательно спроси: что
|
||||||
|
подтолкнуло к изменению и какие варианты рассматривались.
|
||||||
|
|
||||||
|
## Замена / устаревание решения
|
||||||
|
|
||||||
|
Старые ADR неизменяемы. Если решение пересмотрено:
|
||||||
|
|
||||||
|
1. Заведи новую ADR (шаги выше). В её «Контексте» — строка
|
||||||
|
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
|
||||||
|
2. В старой ADR добавь строку статуса сразу под датой:
|
||||||
|
`- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug` (или `- Статус: устарело`,
|
||||||
|
если замены нет). Тело не трогай.
|
||||||
|
3. Обнови статус старой записи в индексе `README.md`.
|
||||||
|
|
||||||
|
## После записи
|
||||||
|
|
||||||
|
Покажи пользователю путь к файлу и кратко содержание. Не коммить без
|
||||||
|
явной просьбы.
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
> В этом проекте не самые оптимальные решения.
|
> В этом проекте не самые оптимальные решения.
|
||||||
> Но они помогают мне поддерживать сервер для моих личных проектов уже много лет.
|
> Но они помогают мне поддерживать сервер для моих личных проектов уже много лет.
|
||||||
|
|
||||||
|
История и обоснования значимых решений — в [ADR-записях](docs/adr/)
|
||||||
|
(`docs/adr/`): *почему* приняты те или иные изменения, а не только что сделано.
|
||||||
|
|
||||||
## Требования
|
## Требования
|
||||||
|
|
||||||
- [uv](https://docs.astral.sh/uv/)
|
- [uv](https://docs.astral.sh/uv/)
|
||||||
@@ -40,11 +43,17 @@ uv run ansible-galaxy install --role-file requirements.yml
|
|||||||
Деплой приложения через ansible:
|
Деплой приложения через ansible:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run ansible-playbook ansible-playbook -i production.yml --diff playbook-gitea.yml
|
uv run ansible-playbook ansible-playbook -i timeweb.yml --diff playbook-gitea.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Или через таску invoke:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./inv pl -- gitea
|
||||||
```
|
```
|
||||||
|
|
||||||
## Удаление приложения <name>
|
## Удаление приложения <name>
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run ansible-playbook -i production.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
|
uv run ansible-playbook -i timeweb.yml --diff playbook-remove-user-and-app.yml --extra-vars user_name=<name>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Вести историю решений в виде ADR
|
||||||
|
|
||||||
|
- Дата: 0000-00-00
|
||||||
|
|
||||||
|
> Основополагающая запись о самом процессе ADR. Дата-сентинел
|
||||||
|
> `0000-00-00` (фактически создана 2026-05-24) — исключение: так запись
|
||||||
|
> всегда остаётся в самом низу списка и не путается с реальными
|
||||||
|
> изменениями.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Сервер развивается итеративно: меняются прокси, схема бэкапов, набор
|
||||||
|
сервисов, провайдер хостинга. Решения принимаются по одному, часто с
|
||||||
|
неочевидными компромиссами под ресурсы сервера и стоимость. Через
|
||||||
|
несколько месяцев мотивация забывается, и возникает риск «переоткрыть»
|
||||||
|
уже отвергнутый вариант или сломать то, что было сделано осознанно.
|
||||||
|
|
||||||
|
Журналы в `docs/drafts/` фиксируют хронологию и черновики, но не
|
||||||
|
обоснование выбора — по ним не видно, какие альтернативы отвергнуты и
|
||||||
|
почему.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **ADR (отдельный файл на решение)** — стандартный формат, каждая
|
||||||
|
запись неизменяема, видно эволюцию через накопление и замену записей.
|
||||||
|
- **Один changelog-файл** — проще вести, но правки затирают историю
|
||||||
|
рассуждений, и формат расплывается со временем.
|
||||||
|
- **Ничего, держать в голове / в git-сообщениях** — нулевые затраты,
|
||||||
|
но обоснование теряется, а git-история не отвечает на вопрос «почему».
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Заводим каталог `docs/adr/` с записями в формате ADR (Nygard + блок
|
||||||
|
«Рассмотренные варианты»). Идентификатор записи — датовый,
|
||||||
|
`ADR-ГГГГ-ММ-ДД-slug`: дата (когда изменение реально сделано) сразу
|
||||||
|
видна в списке и позволяет оформлять записи задним числом. ADR пишем
|
||||||
|
постфактум, поэтому жизненный цикл сведён к двум статусам у потерявших
|
||||||
|
силу записей — `заменено на` и `устарело`; идеи и планы остаются в
|
||||||
|
`docs/drafts/`.
|
||||||
|
|
||||||
|
Формат и процесс описаны в [`README.md`](README.md), шаблон — в
|
||||||
|
[`template.md`](template.md). Для единообразия заполнение
|
||||||
|
автоматизировано скиллом `adr` (`.claude/skills/adr/`).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Сохраняется обоснование решений и отвергнутые альтернативы.
|
||||||
|
- `+` Датовый ID даёт хронологию «из коробки» и не мешает оформлять
|
||||||
|
записи задним числом.
|
||||||
|
- `+` Единый формат: записи делает человек или агент по одному шаблону.
|
||||||
|
- `-` Небольшая дисциплина: сделав значимое изменение, нужно не забыть
|
||||||
|
оформить ADR.
|
||||||
|
- Скилл `adr` берёт на себя имя файла, шаблон и обновление индекса в
|
||||||
|
`README.md`, снижая трение.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Authelia вместо Keycloak для SSO
|
||||||
|
|
||||||
|
- Дата: 2025-05-07
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Для SSO/OIDC на сервере стоял Keycloak (заведён годом ранее,
|
||||||
|
2024-05-25). Проблема — ресурсы: Keycloak съедал больше 500 МБ RAM, что
|
||||||
|
тяжело для личного сервера с ограниченной оперативной памятью. При этом вся его мощь
|
||||||
|
избыточна: пользователей меньше десяти, realms / federation / тяжёлый
|
||||||
|
корпоративный стек не нужны. Изначально взял Keycloak, потому что нужен был
|
||||||
|
OIDC-сервер для настройки Outline; на тот момент было понятное
|
||||||
|
руководство по связке OIDC и Keycloak.
|
||||||
|
|
||||||
|
Требовался лёгкий по памяти SSO-провайдер с хорошей документацией,
|
||||||
|
желательно на Go/Rust.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **Оставить Keycloak.** Отвергнуто: > 500 МБ RAM ради < 10
|
||||||
|
пользователей, функционал избыточен для личного сервера.
|
||||||
|
- **Authelia** (выбран). Лёгкая (Go), малое потребление памяти, хорошая
|
||||||
|
документация. Умеет и OIDC, и forward-auth.
|
||||||
|
|
||||||
|
Критерии отбора замены: минимальный расход RAM, хорошая документация,
|
||||||
|
стек Go/Rust.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Заменили Keycloak на Authelia как провайдер аутентификации
|
||||||
|
(коммиты `a77fefc`, `d1500ea`, `3a23c08`). Authelia используется в трёх
|
||||||
|
режимах:
|
||||||
|
|
||||||
|
- **OIDC** для приложений, которым он нужен (например, Outline).
|
||||||
|
- **Forward-auth** агент в Caddy — удобно там, где полноценный OIDC
|
||||||
|
избыточен.
|
||||||
|
- **Закрытие чувствительных приложений** за единым логином. Раньше для
|
||||||
|
этого использовался basic auth в Caddy.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Резко меньше потребление RAM — критично для сервера с дефицитом
|
||||||
|
памяти.
|
||||||
|
- `+` Forward-auth закрывает приложения без OIDC проще, чем поднимать
|
||||||
|
отдельный OIDC-клиент под каждое.
|
||||||
|
- `+` Единая точка аутентификации вместо разрозненного basic auth в
|
||||||
|
Caddy.
|
||||||
|
- `-` Authelia беднее Keycloak по возможностям (нет полноценного интерфейса
|
||||||
|
управления пользователями, realms, federation) — но для < 10
|
||||||
|
пользователей это не нужно.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Данные приложений на отдельном диске
|
||||||
|
|
||||||
|
- Дата: 2025-12-07
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Исторически данные приложений лежали прямо в домашних директориях их
|
||||||
|
системных пользователей (`/home/<app-user>/…`), то есть на системном
|
||||||
|
диске рядом с ОС. В конце 2025 встал вопрос обновления ОС (Ubuntu 22.04
|
||||||
|
уже устарела), и стало ясно: пока данные привязаны к системному диску,
|
||||||
|
любое обновление или пересборка системы рискует этими данными и тяжело
|
||||||
|
откатывается.
|
||||||
|
|
||||||
|
Возникла мысль развязать данные приложений и жизненный цикл ОС.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **Данные на системном диске** (как было). Просто, но данные связаны с
|
||||||
|
ОС: обновление/пересборка системы затрагивает и их.
|
||||||
|
- **Отдельный диск под данные** (выбран). Данные переживают пересборку
|
||||||
|
ОС, диск можно отцепить от одного сервера и прицепить к другому.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Вынесли все данные приложений на отдельный диск, смонтированный в
|
||||||
|
`/mnt/applications`; каждое приложение держит там свои `data` / `config`
|
||||||
|
/ `backups`, а `base_dir`/`data_dir` указывают на этот путь
|
||||||
|
(коммиты `47a6320`, `7e67409`, `ae7c20a`, `8dfd061`).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Данные развязаны с жизненным циклом ОС — систему можно обновлять
|
||||||
|
и пересобирать, не трогая данные.
|
||||||
|
- `+` Диск можно отцепить от старого сервера и прицепить к новому. Это
|
||||||
|
легло в основу метода обновления ОС
|
||||||
|
([ADR-2025-12-13](ADR-2025-12-13-os-upgrade-via-server-rebuild.md)).
|
||||||
|
- `-` Появилась зависимость от монтирования внешнего диска (UUID,
|
||||||
|
mount-конфигурация): если диск не смонтирован, приложения не
|
||||||
|
поднимутся. Позже, при переезде в Timeweb, монтирование пришлось
|
||||||
|
сделать опциональным
|
||||||
|
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md), фаза 1 — один
|
||||||
|
диск, фаза 2 — отдельный «холодный» диск под крупные данные).
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Обновление ОС пересборкой на свежем сервере
|
||||||
|
|
||||||
|
- Дата: 2025-12-13
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
На сервере стояла Ubuntu 22.04, и к концу 2025 пора было обновляться.
|
||||||
|
Обновлять живую боевую систему «на месте» (`do-release-upgrade`) не
|
||||||
|
хотелось — это рискованно и тяжело откатывается, если что-то пойдёт не
|
||||||
|
так на работающем сервере.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **Обновление «на месте»** (`do-release-upgrade` на живой системе).
|
||||||
|
Отвергнуто: риск сломать рабочий сервер, нет простого отката.
|
||||||
|
- **Пересборка на свежем сервере** (выбран). Поднять новый сервер с
|
||||||
|
целевой ОС, накатать ansible, прицепить диск с данными, развернуть
|
||||||
|
приложения — старый сервер остаётся нетронутым как точка отката.
|
||||||
|
Заодно — почистить мусор, накопившийся за время работы прошлого сервера.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Обновляем ОС через пересборку на свежем сервере. Метод опирается на три
|
||||||
|
предпосылки:
|
||||||
|
|
||||||
|
- **Деплой без запуска контейнеров.** Сводные плейбуки
|
||||||
|
(`playbook-all-setup`, `playbook-all-applications`) и тег `run-app`
|
||||||
|
позволяют раскатать пользователей, каталоги и конфиги, но НЕ запускать
|
||||||
|
приложения (`--skip-tags run-app`) — данные переносятся в «тихую»
|
||||||
|
систему (коммиты `5b53cb3`, `48bb8c9`, `67df03e`).
|
||||||
|
- **Данные на отдельном диске**
|
||||||
|
([ADR-2025-12-07](ADR-2025-12-07-app-data-on-separate-disk.md)) — диск
|
||||||
|
с данными прицепляется к новому серверу.
|
||||||
|
- **Фиксированные uid/gid.** Заранее закрепили uid/gid всех
|
||||||
|
пользователей приложений (роль `owner`, коммит `c2ea2cd`). Это
|
||||||
|
критично: иначе при пересоздании пользователей на новом сервере
|
||||||
|
uid/gid могли бы сдвинуться, и данные приложений на отдельном диске
|
||||||
|
оказались бы с чужим владельцем.
|
||||||
|
|
||||||
|
Порядок: сначала вся подготовка (отдельный диск, перенос данных на него,
|
||||||
|
фиксация uid/gid), затем пересборка на новом обновлённом сервере. Перенос
|
||||||
|
прошёл без проблем.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Обновление ОС без риска для живой системы; откат = вернуться на
|
||||||
|
старый сервер.
|
||||||
|
- `+` Получился воспроизводимый процесс миграции — позже переиспользован
|
||||||
|
при переезде в Timeweb как «холодное переключение» (cold cutover)
|
||||||
|
([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
|
||||||
|
- `+` Фиксация uid/gid стала постоянным инвариантом проекта.
|
||||||
|
- `-` Метод требует заранее подготовленных предпосылок (фикс uid/gid +
|
||||||
|
данные на отдельном диске); без них он не работает.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Apprise как шлюз уведомлений
|
||||||
|
|
||||||
|
- Дата: 2026-04-04
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
В первую очередь нужны были уведомления о бэкапах — знать, что ночной
|
||||||
|
прогон отработал и не сломался. Уведомления слались напрямую в конкретный
|
||||||
|
канал, привязка была зашита в каждом источнике. Хотелось единый слой,
|
||||||
|
который абстрагирует каналы доставки — чтобы добавлять или менять канал в
|
||||||
|
одном месте, а не править каждый источник.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **Прямая интеграция с каждым каналом** (как было). Каждый источник знает про
|
||||||
|
конкретный канал; смена канала — правки во многих местах.
|
||||||
|
- **Apprise** (выбран). Смотрел разные self-hosted шлюзы уведомлений;
|
||||||
|
apprise выиграл зрелостью и числом готовых интеграций (десятки каналов
|
||||||
|
из коробки).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Подняли apprise отдельным сервисом-шлюзом: источники шлют уведомление по
|
||||||
|
HTTP в apprise, а он разводит его по настроенным каналам (коммиты
|
||||||
|
`a0543e1`, `5f619ea`, `6bfb362`). Под ограниченную память сервера apprise
|
||||||
|
запущен в один воркер (`5e6df11`).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Каналы доставки абстрагированы за единым шлюзом — добавить или
|
||||||
|
сменить канал можно в одном месте, не трогая источники.
|
||||||
|
- `+` Доступ к десяткам интеграций apprise без отдельного кода под
|
||||||
|
каждую.
|
||||||
|
- `-` Ещё один сервис в обслуживании (контейнер, память).
|
||||||
|
- Окупилось при переезде в Timeweb: провайдер заблокировал Telegram, и
|
||||||
|
переключение уведомлений (сейчас почта, в планах Matrix) локализовано в
|
||||||
|
шлюзе ([ADR-2026-05-23](ADR-2026-05-23-migrate-to-timeweb.md)).
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Переезд сервера с Yandex Cloud на Timeweb VPS
|
||||||
|
|
||||||
|
- Дата: 2026-05-23
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
`rivendell-v2` жил на виртуальной машине в Yandex Cloud. Одновременно копились три
|
||||||
|
проблемы:
|
||||||
|
|
||||||
|
- **Цена.** ≈ 2 887 ₽/мес за конфигурацию, которую другие провайдеры
|
||||||
|
дают дешевле и мощнее.
|
||||||
|
- **Потолок RAM.** 4 ГБ, ≈ 80 % заняты на штатной нагрузке. Любой
|
||||||
|
всплеск (миграции БД, индексация Outline, restic) — конкуренция за
|
||||||
|
память и риск OOM. Расти на этом тарифе дальше — только заметно
|
||||||
|
дороже.
|
||||||
|
- **Медленный диск.** Чтобы сдержать цену в YC, использовался дешёвый
|
||||||
|
HDD вместо SSD/NVMe — страдала отзывчивость (Gitea, Outline, тёплый
|
||||||
|
старт контейнеров, restic check/forget).
|
||||||
|
|
||||||
|
Это личный сервер — допустимы небольшие простои.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **Остаться в YC, поднять тариф** (больше RAM/SSD на месте). Отвергнуто:
|
||||||
|
YC уже дороже альтернатив, апгрейд поднимает цену непропорционально
|
||||||
|
приросту — те же три проблемы решаются дороже, чем переездом.
|
||||||
|
- **Свой / домашний сервер** (железо под контролем, без ежемесячной
|
||||||
|
аренды). Отвергнуто: дома нет надёжного аптайма 24/7 (питание,
|
||||||
|
интернет-канал, железо), а сервисы должны быть всегда доступны.
|
||||||
|
- **Переезд на Timeweb Cloud VPS** — выбранный вариант.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Переносим на Timeweb Cloud VPS: ≈ 1 980 ₽/мес, 8 ГБ RAM (×2), 4 ядра
|
||||||
|
(×2, гарантия CPU 100 % вместо 50 %), 80 ГБ NVMe вместо 120 ГБ HDD.
|
||||||
|
Один переезд закрывает все три причины сразу.
|
||||||
|
|
||||||
|
Рамки решения:
|
||||||
|
|
||||||
|
- Переезжает **только сам сервер с приложениями** (compute). S3 (restic, бэкапы),
|
||||||
|
Container Registry, Postbox SMTP и DNS-зона `vakhrushev.me` остаются
|
||||||
|
в Yandex и используются с новой машины.
|
||||||
|
- Стратегия — **холодное переключение** (cold cutover): погасить сервисы
|
||||||
|
на источнике, раскатать
|
||||||
|
ansible на новом сервере без запуска приложений (сохраняя uid/gid), перенести
|
||||||
|
данные `rsync`'ом, запустить, переключить DNS.
|
||||||
|
- Диск: фаза 1 — один 80 ГБ NVMe (всего 22 ГБ данных + 17 ГБ системных, влезает с
|
||||||
|
запасом). «Холодный» второй диск под крупные данные — отдельная
|
||||||
|
фаза 2, не на критическом пути.
|
||||||
|
- Источник не удаляется сразу после переключения: держим «холодным
|
||||||
|
запасным»
|
||||||
|
пару недель ради отката.
|
||||||
|
|
||||||
|
Детальный план — [`../drafts/timeweb.md`](../drafts/timeweb.md),
|
||||||
|
фактическое выполнение —
|
||||||
|
[`../drafts/timeweb-migration-log.md`](../drafts/timeweb-migration-log.md).
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` −907 ₽/мес (≈ −31 %) при вдвое большем RAM и CPU и NVMe-диске —
|
||||||
|
закрыты все три исходные проблемы.
|
||||||
|
- `+` Запас по RAM убирает OOM-риск при всплесках нагрузки.
|
||||||
|
- `+` Диверсификация по облакам: раньше сервер и данные были в одном
|
||||||
|
аккаунте Yandex Cloud, теперь сам сервер в Timeweb, а бэкапы (S3) — в
|
||||||
|
Yandex. Если заблокируют или потеряем доступ к одному провайдеру,
|
||||||
|
данные остаются доступны через другой.
|
||||||
|
- `-` Диск меньше (80 ГБ NVMe против 120 ГБ HDD), но сейчас занят
|
||||||
|
примерно наполовину — запас есть, фаза 2 с холодным диском не срочная.
|
||||||
|
- `-` Сохраняется зависимость от Yandex Cloud (S3, Container Registry,
|
||||||
|
Postbox SMTP, DNS) — переезд её не устраняет.
|
||||||
|
- `-` Timeweb активно блокирует Telegram (в отличие от YC) — интеграция
|
||||||
|
отвалилась. Затронуты `transcriber`, `remembos` и уведомления о
|
||||||
|
бэкапах. Ожидаемо; уведомления остались через почту, второй канал
|
||||||
|
рассматривается через Matrix.
|
||||||
|
- `-` Из-за тех же блокировок Timeweb перестали обновляться некоторые
|
||||||
|
RSS-фиды в `miniflux`.
|
||||||
|
- `-` Для доступа к `cr.yandex` вне YC появился долгоживущий OAuth-токен
|
||||||
|
Яндекса в vault (`yc_oauth_token`): при утечке он открывает доступ ко
|
||||||
|
всему аккаунту Яндекса. Сузить можно IAM-ключом сервисного аккаунта —
|
||||||
|
отдельной итерацией.
|
||||||
|
- Инвентарь временно раздвоен (`production.yml` + `timeweb.yml`); после
|
||||||
|
стабилизации источник удаляется, `timeweb.yml` → `production.yml`.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# Разнесение restic-операций на фазы под Intelligent Tiering
|
||||||
|
|
||||||
|
- Дата: 2026-06-22
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Бэкапы restic уже лежат в Yandex Object Storage на стандартном классе
|
||||||
|
хранения (STANDARD). Yandex выпустил класс «Умное хранилище» (Intelligent
|
||||||
|
Tiering, IT): объекты автоматически охлаждаются до архивного уровня
|
||||||
|
(примерно 0,63 ₽/ГБ против 2,38 ₽/ГБ у STANDARD) с сохранением мгновенного
|
||||||
|
доступа на всех уровнях (анонс:
|
||||||
|
<https://yandex.cloud/ru/blog/s3-intelligent-tiering>). Данные restic — это
|
||||||
|
профиль «записал один раз, читаю редко», то есть идеальный кандидат на
|
||||||
|
охлаждение. Цель — перевести уже лежащие бэкапы со STANDARD на IT и
|
||||||
|
сэкономить на хранении.
|
||||||
|
|
||||||
|
Проблема в том, что любой repack/recompress объекта создаёт *новый*
|
||||||
|
объект, который входит в IT как «Частый доступ» и заново стартует таймер
|
||||||
|
охлаждения (30 дней → «Нечастый», ещё 90 → «Архивный»). А наш оркестратор
|
||||||
|
`backup-all.py` гнал каждую ночь связку `backup → check → forget --prune →
|
||||||
|
check`. Ночной `prune` перепаковывает data-паки → постоянно сбрасывает
|
||||||
|
охлаждение → отменяет экономию IT. Нужно было перестроить обслуживание
|
||||||
|
так, чтобы не мешать охлаждению.
|
||||||
|
|
||||||
|
Дополнительное ограничение по миграции существующих данных: по докам
|
||||||
|
Yandex изменение класса бакета **по умолчанию** не трогает уже загруженные
|
||||||
|
объекты — они остаются в STANDARD, новый класс применяется только к новым
|
||||||
|
загрузкам. Перевести уже лежащие бэкапы в IT можно lifecycle-правилом или
|
||||||
|
copy-in-place (`aws s3 cp --storage-class INTELLIGENT_TIERING`); оба
|
||||||
|
тарифицируются как операция `TRANSITION`. Перезаливка объектов заново для
|
||||||
|
restic не годится — это churn, эквивалентный репаку. Управления бакетом
|
||||||
|
(terraform/aws-cli) в проекте нет, так что миграцию пришлось бы делать
|
||||||
|
вручную или заводить такое управление.
|
||||||
|
|
||||||
|
Идентификатор класса подтверждён доками — `INTELLIGENT_TIERING` (Yandex
|
||||||
|
поддерживает STANDARD, COLD, ICE, INTELLIGENT_TIERING). Явный min-retention
|
||||||
|
(12 месяцев со штрафом за раннее удаление) документирован **только для
|
||||||
|
класса ICE**; для архивного уровня внутри IT такого минимума в доках нет —
|
||||||
|
значит transition и последующий `prune` для IT низкорисковы. Финальная
|
||||||
|
проверка — по биллингу после первой реальной миграции.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
- **Отдельные скрипты на репозиторий** (`backup.sh`/`check.sh`/`prune.sh`/
|
||||||
|
`verify.sh` + свои cron-записи, как в исходном гайде). Ближе к гайду
|
||||||
|
дословно, но теряем оркестратор: авто-дискавери приложений, мультистор
|
||||||
|
и единые apprise-уведомления. Плюс 4 независимые cron-записи провоцируют
|
||||||
|
наложение операций (долгий `prune` налезает на ночной `backup` →
|
||||||
|
конфликт restic-локов). Отвергнут.
|
||||||
|
- **Адаптировать `backup-all.py`** — добавить фазы и расписание внутрь
|
||||||
|
оркестратора, один ночной триггер. Сохраняет всю существующую
|
||||||
|
инфраструктуру, фазы идут последовательно в одном процессе → локи не
|
||||||
|
конфликтуют. **Выбран.**
|
||||||
|
- **Расписание: простые knobs** (день недели/число/месяцы как поля
|
||||||
|
конфига) **vs cron-выражения через `croniter`**. Knobs — без
|
||||||
|
зависимости, но негибко (новая ось → правка кода). Выбран `croniter`:
|
||||||
|
пакет ставится из apt (`python3-croniter`) тем же механизмом, что и
|
||||||
|
остальное, а гибкость реальная — поменять «раз в квартал» на «раз в
|
||||||
|
месяц» = правка одной строки конфига.
|
||||||
|
- **Перевод существующих данных в IT: copy-in-place vs lifecycle vs
|
||||||
|
отложить.** Lifecycle в Yandex переводит только «на более холодный»
|
||||||
|
(STANDARD→COLD→ICE), переход именно в IT им не заявлен — отпал.
|
||||||
|
Перезаливка объектов для restic не годится (churn ≈ репак). Остаётся
|
||||||
|
**copy-in-place** (`aws s3 cp --recursive --storage-class
|
||||||
|
INTELLIGENT_TIERING`, серверная копия «на себя»). **Выбран copy-in-place**
|
||||||
|
— после того как доки сняли блокер по min-retention. Для будущих записей
|
||||||
|
отдельно — флип класса бакета по умолчанию на IT (вариант A с
|
||||||
|
`-o s3.storage-class` в коде не понадобился).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Операции restic в `files/backups/backup-all.py` разнесены на фазы с
|
||||||
|
разной частотой, потому что у них принципиально разная цена для IT:
|
||||||
|
|
||||||
|
- `backup` + `forget` — **каждый прогон**. `forget` теперь **без
|
||||||
|
`--prune`**: удаляет только метаданные снапшотов (операция DELETE не
|
||||||
|
тарифицируется), не репакует data-паки и не сбивает охлаждение.
|
||||||
|
- `check` (структурный) — еженедельно; `prune` — квартально; `verify`
|
||||||
|
(`check --read-data-subset`) — помесячно. Расписание задано
|
||||||
|
cron-выражениями в секции `[schedule]` конфига и вычисляется через
|
||||||
|
`croniter`. Триггер один ночной, фазы одного прогона идут
|
||||||
|
последовательно в одном процессе → restic-локи между ними не
|
||||||
|
конфликтуют. Наложение соседних прогонов гасится `flock -n` в cron.
|
||||||
|
|
||||||
|
`prune` тюнингован под IT (`--max-unused 20%`, `--max-repack-size 5G`):
|
||||||
|
чем меньше холодных паков переписываем, тем дольше держится охлаждение.
|
||||||
|
|
||||||
|
Перевод бакетов в IT идёт двумя действиями на каждый бакет: смена класса
|
||||||
|
**по умолчанию** на IT в консоли (будущие записи restic) + разовая
|
||||||
|
**copy-in-place** существующих объектов (`aws s3 cp s3://<bucket>/
|
||||||
|
s3://<bucket>/ --recursive --storage-class INTELLIGENT_TIERING
|
||||||
|
--metadata-directive COPY`). Класс отдаётся прямо в листинге
|
||||||
|
(`list-objects-v2 --query 'Contents[].[StorageClass,Key]'`) — им и
|
||||||
|
проверяем. Грабли: для проверки нельзя `--max-items 1` (клиентская
|
||||||
|
пагинация aws-cli дописывает в вывод токен `None`) — нужен серверный
|
||||||
|
`--max-keys`.
|
||||||
|
|
||||||
|
Статус миграции: **`rivendell` переведён 2026-06-23** (дефолт бакета = IT
|
||||||
|
со скриншота, все объекты `config`/`data/` показывают
|
||||||
|
`INTELLIGENT_TIERING`). `eos` (основная экономия) и `buckland` — следующими,
|
||||||
|
после нескольких дней наблюдения за биллингом `rivendell`.
|
||||||
|
|
||||||
|
Retention оставлен прежним (`--keep-daily 90 --keep-monthly 36`) — это
|
||||||
|
решение про охлаждение и частоту операций, а не про глубину истории.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` Ночной prune больше не сбрасывает охлаждение — IT реально экономит
|
||||||
|
на архивном уровне.
|
||||||
|
- `+` Нет наложения restic-операций: последовательные фазы + `flock`.
|
||||||
|
- `+` Расписание обслуживания меняется правкой конфига, без релиза кода.
|
||||||
|
- `-` Новая зависимость на сервере: `python3-croniter` (и явно
|
||||||
|
зафиксированный `python3-requests`).
|
||||||
|
- `-` Структурный `check` теперь еженедельный, а не каждую ночь: битый
|
||||||
|
бэкап может остаться незамеченным до недели. Для хобби-сервера приемлемо.
|
||||||
|
- `-` Подвох croniter: при суточном триггере поля минут/часов в
|
||||||
|
выражениях декоративны (держим `* *`) — фаза идёт в момент ночного
|
||||||
|
прогона, а не во время из выражения.
|
||||||
|
- `+` Миграция существующих объектов — разовая copy-in-place, без репака
|
||||||
|
restic: содержимое и ключи паков не меняются, restic остаётся рабочим.
|
||||||
|
- `-` После перевода объекты стартуют на уровне FREQUENT и охлаждаются
|
||||||
|
~120 дней — полка экономии устанавливается не сразу.
|
||||||
|
- Осталось сделать: несколько дней последить за биллингом и бэкапами
|
||||||
|
`rivendell` (убедиться, что за transition нет штрафа), затем повторить
|
||||||
|
пару «флип дефолта + copy-in-place» для `eos` и `buckland`.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Architecture Decision Records (ADR)
|
||||||
|
|
||||||
|
Журнал значимых архитектурных и инфраструктурных решений по серверу.
|
||||||
|
Одна запись — одно решение. ADR пишем **постфактум**, когда изменение
|
||||||
|
уже сделано: идеи, планы и обсуждения живут в [`../drafts/`](../drafts),
|
||||||
|
а в ADR попадает только то, что реализовано. Записи **неизменяемы**:
|
||||||
|
передумали → не правим старую, а заводим новую и помечаем старую.
|
||||||
|
|
||||||
|
Чем ADR отличается от журналов в `../drafts/`: drafts — оперативная
|
||||||
|
хроника и черновики («что делаю / собираюсь сделать»), ADR — фиксация
|
||||||
|
выбора и его обоснования («почему сделал так, а не иначе»).
|
||||||
|
|
||||||
|
Главная ценность записи — сохранить **почему**: намерение и причинность.
|
||||||
|
Это важнее аккуратности оформления и полноты остальных секций.
|
||||||
|
|
||||||
|
## Когда заводить ADR
|
||||||
|
|
||||||
|
- Выбор технологии или инструмента (Caddy vs Nginx, restic vs borg).
|
||||||
|
- Структурные решения (схема бэкапов, организация плейбуков, сеть).
|
||||||
|
- Решения с долгосрочными последствиями или дорогим откатом.
|
||||||
|
- **Намеренный отказ** от очевидного подхода — чтобы потом не
|
||||||
|
переоткрывать «а почему мы не сделали X».
|
||||||
|
|
||||||
|
Не заводить для рутины (бамп версии образа, добавление сервиса по
|
||||||
|
накатанной схеме) и того, что и так видно из кода и git.
|
||||||
|
|
||||||
|
## Соглашения
|
||||||
|
|
||||||
|
- **Имя файла = идентификатор:** `ADR-ГГГГ-ММ-ДД-kebab-slug.md`.
|
||||||
|
Идентификатор — имя без `.md` (например `ADR-2026-05-24-adr-process`).
|
||||||
|
Slug — латиницей.
|
||||||
|
- **Дата** — когда изменение реально сделано (можно задним числом, а не
|
||||||
|
дата оформления записи).
|
||||||
|
- Несколько ADR за один день различаются по slug.
|
||||||
|
- **Заголовок в файле:** `# Человеческий заголовок` (без даты и ID — они
|
||||||
|
в имени файла и в строке «Дата»).
|
||||||
|
- Секция **«Рассмотренные варианты» — опциональна**: оставляй её, только
|
||||||
|
если альтернативы реально рассматривались.
|
||||||
|
- Шаблон новой записи — [`template.md`](template.md).
|
||||||
|
- Исключение: основополагающая мета-запись о самом процессе ADR
|
||||||
|
использует дату-сентинел `0000-00-00`, чтобы всегда оставаться в самом
|
||||||
|
низу списка. Реальные записи такую дату не используют.
|
||||||
|
|
||||||
|
## Статусы
|
||||||
|
|
||||||
|
Активная запись статуса **не имеет**. Статус появляется, только когда
|
||||||
|
запись теряет силу, и значений всего два:
|
||||||
|
|
||||||
|
- `заменено на ADR-ГГГГ-ММ-ДД-slug` — решение пересмотрено новой ADR.
|
||||||
|
- `устарело` — решение потеряло смысл и замены нет.
|
||||||
|
|
||||||
|
## Замена и устаревание
|
||||||
|
|
||||||
|
1. Заводим новую ADR; в её «Контексте» — строка
|
||||||
|
«Заменяет ADR-ГГГГ-ММ-ДД-slug».
|
||||||
|
2. В старой ADR добавляем строку `- Статус: заменено на ADR-…` сразу под
|
||||||
|
датой. Тело не трогаем — это часть истории.
|
||||||
|
3. Обновляем статус старой записи в индексе ниже.
|
||||||
|
|
||||||
|
## Список записей
|
||||||
|
|
||||||
|
Новые сверху.
|
||||||
|
|
||||||
|
| Дата | Запись | Статус |
|
||||||
|
| ---------- | ---------------------------------------------------------------------------------------------- | ------ |
|
||||||
|
| 2026-06-22 | [Разнесение restic-операций на фазы под Intelligent Tiering](ADR-2026-06-22-restic-intelligent-tiering-phases.md) | — |
|
||||||
|
| 2026-05-23 | [Переезд сервера с Yandex Cloud на Timeweb VPS](ADR-2026-05-23-migrate-to-timeweb.md) | — |
|
||||||
|
| 2026-04-04 | [Apprise как шлюз уведомлений](ADR-2026-04-04-apprise-notifications.md) | — |
|
||||||
|
| 2025-12-13 | [Обновление ОС пересборкой на свежем сервере](ADR-2025-12-13-os-upgrade-via-server-rebuild.md) | — |
|
||||||
|
| 2025-12-07 | [Данные приложений на отдельном диске](ADR-2025-12-07-app-data-on-separate-disk.md) | — |
|
||||||
|
| 2025-05-07 | [Authelia вместо Keycloak для SSO](ADR-2025-05-07-authelia-sso.md) | — |
|
||||||
|
| 0000-00-00 | [Вести историю решений в виде ADR](ADR-0000-00-00-record-architecture-decisions.md) | — |
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Краткий заголовок решения
|
||||||
|
|
||||||
|
- Дата: ГГГГ-ММ-ДД
|
||||||
|
<!-- Строку статуса добавляют позже, только если запись потеряла силу:
|
||||||
|
- Статус: заменено на ADR-ГГГГ-ММ-ДД-slug
|
||||||
|
- Статус: устарело
|
||||||
|
У активной записи строки статуса нет. -->
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Что вынудило сделать изменение: проблема, силы и ограничения (ресурсы
|
||||||
|
сервера, стоимость, время на поддержку, существующая архитектура).
|
||||||
|
Пиши так, чтобы через год было понятно «почему это вообще делалось»
|
||||||
|
без чтения переписки.
|
||||||
|
|
||||||
|
## Рассмотренные варианты
|
||||||
|
|
||||||
|
<!-- Опциональная секция. Оставь, только если варианты реально
|
||||||
|
рассматривались. Если решение было единственным очевидным —
|
||||||
|
удали её, а причину объясни в «Решении». -->
|
||||||
|
|
||||||
|
- **Вариант A** — суть, плюсы и минусы.
|
||||||
|
- **Вариант B** — суть, плюсы и минусы.
|
||||||
|
- **Вариант C** — если отвергнут сразу, коротко почему.
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
|
||||||
|
Что именно сделано и — главное — **почему**: какое намерение и какая
|
||||||
|
причина за этим стоят. Если варианты рассматривались — почему выбран
|
||||||
|
этот, а не остальные.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
|
||||||
|
- `+` что стало лучше, какие возможности открылись.
|
||||||
|
- `-` чем платим: новые ограничения, риски, регулярная нагрузка на
|
||||||
|
поддержку.
|
||||||
|
- Что нужно сделать как следствие (если есть).
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# Ревью плейбуков: best practices и конвенции Ansible
|
||||||
|
|
||||||
|
Дата: 2026-05-25. Статус: черновик (заметки по итогам ревью, не план работ).
|
||||||
|
|
||||||
|
Проанализированы инвентарь, `ansible.cfg`, роли (`owner`, `eget`, `secrets`) и
|
||||||
|
репрезентативная выборка плейбуков: `gitea`, `memos`, `wanderer`, `backups`,
|
||||||
|
`system`, `caddyproxy`, `authelia`, `netdata`, `docker`, `eget`, `all-*`.
|
||||||
|
Находки отсортированы по влиянию.
|
||||||
|
|
||||||
|
## Договорённость по структуре (важно для контекста)
|
||||||
|
|
||||||
|
Изначальная рекомендация «вынести общий деплой в одну generic-роль `docker_app`»
|
||||||
|
**отклонена осознанно** и не должна предлагаться снова:
|
||||||
|
|
||||||
|
- приложения реально разные, мелкие отличия больно загонять в единую абстракцию;
|
||||||
|
- catch-all роль обрастает флагами `when:` и читается хуже, чем N честных плейбуков;
|
||||||
|
- per-playbook дублирование даёт locality of behavior и возможность обкатать новый
|
||||||
|
подход на одном сервисе, затем раскатать на остальные.
|
||||||
|
|
||||||
|
Правильное направление — **набор маленьких composable-ролей на инвариантных швах**
|
||||||
|
(как уже сделано с `owner`), а не одна роль на всё. Per-app конфиг остаётся локально
|
||||||
|
в плейбуке сервиса.
|
||||||
|
|
||||||
|
## 1. Extraction только на чистых швах (не мега-роль)
|
||||||
|
|
||||||
|
Per-app конфиг (каталоги, шаблоны, env, порты, особенности compose) — оставляем в
|
||||||
|
плейбуке сервиса. Выносим лишь то, что реально инвариантно и повторилось многократно:
|
||||||
|
|
||||||
|
- **Бэкап** — самый чистый шов: `gobackup.yml` + `backup.sh` + `backup-targets` +
|
||||||
|
интеграция с restic. Механизм одинаков у всех, различается только список целей.
|
||||||
|
Роль `backup` с параметром «список targets» не трогает индивидуальность сервиса.
|
||||||
|
- `owner` уже сделан как отдельная composable-роль — это правильный размер абстракции.
|
||||||
|
|
||||||
|
## 2. `vars_files` в каждом плейбуке → `group_vars/all/`
|
||||||
|
|
||||||
|
В каждом плейбуке повторяется:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
vars_files:
|
||||||
|
- vars/secrets.yml
|
||||||
|
- vars/vars.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Ansible автоматически подхватывает `group_vars/all.yml` и `group_vars/all/secrets.yml`
|
||||||
|
(vault) для группы `all`. Перенос `vars/vars.yml` → `group_vars/all/main.yml` и
|
||||||
|
`vars/secrets.yml` → `group_vars/all/vault.yml` убирает boilerplate из всех плейбуков.
|
||||||
|
Адаптируется по одному плейбуку за раз.
|
||||||
|
|
||||||
|
## 3. Нет handlers — `state: restarted` безусловный
|
||||||
|
|
||||||
|
Ни в одном плейбуке нет `handlers:`. Вместо этого:
|
||||||
|
|
||||||
|
- `playbook-caddyproxy.yml:106`, `playbook-netdata.yml:143`, `playbook-authelia.yml:92` —
|
||||||
|
задача `state: restarted` выполняется **всегда**, рестартит контейнер на каждом
|
||||||
|
прогоне даже без изменений (не идемпотентно, лишний downtime);
|
||||||
|
- `playbook-gitea.yml` — рестарта нет вовсе (несогласованность).
|
||||||
|
|
||||||
|
Канонический паттерн: шаблон конфига `notify`-ит handler, который рестартит только при
|
||||||
|
реальном изменении.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: "Copy docker compose file"
|
||||||
|
ansible.builtin.template: { ... }
|
||||||
|
notify: Restart app
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: Restart app
|
||||||
|
community.docker.docker_compose_v2:
|
||||||
|
project_src: "{{ base_dir }}"
|
||||||
|
state: restarted
|
||||||
|
```
|
||||||
|
|
||||||
|
Связанное: в `playbook-memos.yml:76` результат шаблона регистрируется в
|
||||||
|
`docker_compose_file_result`, но нигде не используется — задумывалось под `when`/`notify`,
|
||||||
|
не доведено.
|
||||||
|
|
||||||
|
Внедряется инкрементально, по одному сервису.
|
||||||
|
|
||||||
|
## 4. Идемпотентность и `changed_when`
|
||||||
|
|
||||||
|
- **`playbook-netdata.yml:118-125`** — `changed_when: netdata_docker_group_output.rc != 0`
|
||||||
|
для read-only запроса лишено смысла (помечает «changed» только при ошибке). Должно быть
|
||||||
|
`changed_when: false`. Лучше заменить `shell: grep docker /etc/group | cut ...` на модуль:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- ansible.builtin.getent:
|
||||||
|
database: group
|
||||||
|
key: docker
|
||||||
|
# далее: getent_group['docker'][1]
|
||||||
|
```
|
||||||
|
|
||||||
|
Уйдёт и `set -o pipefail`, и хрупкий парсинг.
|
||||||
|
|
||||||
|
- **`playbook-eget.yml:23-78`** — восемь `command` помечены `changed_when: false`, хотя
|
||||||
|
реально ставят/обновляют бинарники. Прогон всегда рапортует «ok» — теряется честность
|
||||||
|
`--diff`. Сама роль `eget` делает корректную проверку версии; те же инсталляции через
|
||||||
|
неё или через проверку версии были бы идемпотентны по-настоящему.
|
||||||
|
|
||||||
|
- **`playbook-memos.yml:57-67`** (и аналоги) — сборка `backup-targets` через `lineinfile`
|
||||||
|
в цикле не удаляет устаревшие строки при изменении списка, а `mode: "0750"` на
|
||||||
|
файле-списке выглядит как copy-paste. Чище — `template`/`copy: content` со всем списком.
|
||||||
|
|
||||||
|
## 5. Роль `owner` — несогласованность с ролью `eget`
|
||||||
|
|
||||||
|
- **`roles/owner/tasks/main.yml:2-10`** — валидация аргументов через `fail` + `when`,
|
||||||
|
причём две задачи с **идентичным именем**. Роль `eget` для того же делает `assert`
|
||||||
|
(`roles/eget/tasks/main.yml:15`). Привести к одному стилю — `assert` либо современный
|
||||||
|
`meta/argument_specs.yml` (декларативная валидация).
|
||||||
|
- **`roles/owner/tasks/main.yml:32,53`** — `with_items`/`with_dict` устарели; конвенция —
|
||||||
|
`loop`: `loop: "{{ owner_ssh_keys }}"`, `loop: "{{ owner_env_dict | dict2items }}"`.
|
||||||
|
- У `owner` нет `meta/main.yml` и README, тогда как у `eget` и `secrets` они есть.
|
||||||
|
- Имена задач в `owner` с точкой на конце (`"Prepare env variables."`), в остальных без —
|
||||||
|
ansible-lint в строгом профиле это ловит.
|
||||||
|
|
||||||
|
## 6. Инвентарь и `become`
|
||||||
|
|
||||||
|
- **`production.yml` и `timeweb.yml`** оба объявляют хост с именем `server` под ключом
|
||||||
|
`ungrouped:`. Хост-специфичные данные (`application_dir`, `mount_external_storage`,
|
||||||
|
`ansible_host`, `ansible_user`) вписаны инлайн. Конвенциональнее — `host_vars/server.yml`,
|
||||||
|
хосты в именованной группе. Два инвентаря с одинаковым именем хоста + `hosts: all` =
|
||||||
|
ошибка `-i` молча уедет не туда.
|
||||||
|
- `ansible_become: true` глобально в инвентаре — всё бежит под root. Для личного сервера
|
||||||
|
прагматично; точечный `become`/`become_user` ближе к наименьшим привилегиям. Низкий приоритет.
|
||||||
|
|
||||||
|
## 7. Конкретный баг
|
||||||
|
|
||||||
|
- **`playbook-wanderer.yml:2`** — `name: "Configure gramps application"`, хотя
|
||||||
|
`app_name: "wanderer"`. Копипаст из gramps, поправить имя play.
|
||||||
|
|
||||||
|
## 8. Мелочи стиля и конфигурации
|
||||||
|
|
||||||
|
- **sudoers**: `playbook-backups.yml:52-59` правит `/etc/sudoers` через `lineinfile`.
|
||||||
|
Конвенция — отдельный файл в `/etc/sudoers.d/` (через `copy`/`template` с
|
||||||
|
`validate: visudo -cf %s`), а не модификация центрального файла.
|
||||||
|
- **`.ansible-lint.yml`** содержит только `exclude_paths` — профиль не задан явно.
|
||||||
|
AGENTS.md утверждает «профиль production»; либо прописать `profile: production`, либо
|
||||||
|
поправить документацию.
|
||||||
|
- **`ansible.cfg`** минимален. Стоит добавить `stdout_callback = yaml`,
|
||||||
|
`interpreter_python = auto_silent`, `force_handlers = true`.
|
||||||
|
- Несогласованные кавычки и пути: `'directory'` vs `"directory"`, `src: "./files/..."` vs
|
||||||
|
`src: "files/..."`, одинарные кавычки в `playbook-all-setup.yml` против двойных в остальных.
|
||||||
|
- `playbook-system.yml:24` — `apt` без `cache_valid_time`, обновляет кэш каждый прогон.
|
||||||
|
|
||||||
|
## Приоритеты
|
||||||
|
|
||||||
|
1. **#3 handlers** — убирает безусловный рестарт; внедряется по одному сервису.
|
||||||
|
2. **#1 роль `backup`** — самый чистый шов для extraction; обкатать на одном сервисе.
|
||||||
|
3. **#4, #7** — быстрые точечные фиксы без структурных изменений.
|
||||||
|
4. **#2 group_vars** — убирает boilerplate; низкий риск.
|
||||||
|
5. **#5, #6, #8** — фоновая зачистка стиля и структуры.
|
||||||
@@ -8,6 +8,461 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Шаг 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, выполнено)
|
## Шаг 6 — `vars/vars.yml` загружается во всех плейбуках (2026-05-22, выполнено)
|
||||||
|
|
||||||
Сегодняшний коммит `8378f0e` («Migration: expose some public vars»)
|
Сегодняшний коммит `8378f0e` («Migration: expose some public vars»)
|
||||||
|
|||||||
+50
-29
@@ -284,28 +284,33 @@ sudo /usr/local/sbin/backup-all.py 2>&1 | tee /tmp/final-backup.log
|
|||||||
|
|
||||||
### Шаг 2. Остановить все приложения на источнике
|
### Шаг 2. Остановить все приложения на источнике
|
||||||
|
|
||||||
Аккуратно остановить контейнеры каждого приложения (через
|
Останавливаем docker-демон целиком — это атомарно гасит все
|
||||||
`docker compose down` от соответствующего пользователя или одним
|
контейнеры за один вызов, не зависит от текущего списка приложений
|
||||||
проходом):
|
и шлёт корректный SIGTERM (с грейс-периодом ~15 сек) каждому, что
|
||||||
|
функционально эквивалентно `docker compose down` по всем стекам.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
inv ssh
|
inv ssh
|
||||||
for user in caddyproxy authelia netdata miniflux rssbridge wakapi \
|
sudo systemctl stop docker.service docker.socket
|
||||||
dozzle transcriber wanderer memos gitea outline homepage gramps \
|
sudo systemctl disable docker.service docker.socket # страховка от автостарта при ребуте
|
||||||
calibre remembos apprise tuwunel goaccess; do
|
sudo systemctl stop cron # чтобы ночной backup-cron не побежал
|
||||||
sudo -iu "$user" bash -c "cd /mnt/applications/$user && docker compose down"
|
|
||||||
done
|
|
||||||
```
|
```
|
||||||
|
|
||||||
(Можно завести вспомогательный плейбук `playbook-shutdown-all.yml`,
|
Финальный бэкап (шаг 1) **обязательно** должен пройти до этого
|
||||||
если такое будет часто.)
|
момента — `backup-all.py` запускает скрипты приложений, которые
|
||||||
|
делают `docker compose exec ... pg_dump ...`; без работающего
|
||||||
|
daemon это сломается.
|
||||||
|
|
||||||
Проверить `docker ps`, что пусто. Снять флаги cron на бэкап (чтобы
|
`disable` — страховка: если по какой-то причине старая машина
|
||||||
финальный backup не побежал во время миграции):
|
перезагрузится во время rsync (или мы вернёмся на источник для
|
||||||
|
проверки/отката), docker не поднимется автоматически и сервисы
|
||||||
|
не начнут писать в данные, которые мы уже считаем «фиксированной
|
||||||
|
копией». В случае отката — `enable` + `start` обратно.
|
||||||
|
|
||||||
```bash
|
Проверить, что `docker ps` сейчас отвечает «daemon not running»
|
||||||
sudo systemctl stop cron
|
(или вернёт пустой список — зависит от того, как `inv ssh` пройдёт
|
||||||
```
|
до/после стопа). Если нужно убедиться, что контейнеры реально
|
||||||
|
ушли — `ps auxf | grep -E "containerd|docker" | grep -v grep`.
|
||||||
|
|
||||||
### Шаг 3. Раскатать инфраструктуру на target БЕЗ запуска приложений
|
### Шаг 3. Раскатать инфраструктуру на target БЕЗ запуска приложений
|
||||||
|
|
||||||
@@ -323,7 +328,9 @@ uv run ansible-playbook -i timeweb.yml --diff \
|
|||||||
Цель — после этого на target есть:
|
Цель — после этого на target есть:
|
||||||
|
|
||||||
- Корректные uid/gid для всех приложений.
|
- Корректные uid/gid для всех приложений.
|
||||||
- Каталоги `/mnt/applications/<app>/{data,config,backups}`.
|
- Каталоги `/srv/applications/<app>/{data,config,backups}` (на
|
||||||
|
Timeweb дефолт изменён с `/mnt/applications`; см.
|
||||||
|
[журнал шаг 5](timeweb-migration-log.md)).
|
||||||
- Шаблоны `docker-compose.yml` и application-конфиги — отрендерены
|
- Шаблоны `docker-compose.yml` и application-конфиги — отрендерены
|
||||||
и лежат на месте.
|
и лежат на месте.
|
||||||
- Docker и сети созданы.
|
- Docker и сети созданы.
|
||||||
@@ -331,34 +338,48 @@ uv run ansible-playbook -i timeweb.yml --diff \
|
|||||||
|
|
||||||
### Шаг 4. Перенос данных
|
### Шаг 4. Перенос данных
|
||||||
|
|
||||||
Два варианта.
|
Пути меняются: на YC данные лежат в `/mnt/applications/<app>`, на
|
||||||
|
Timeweb — в `/srv/applications/<app>`. Rsync делает remap сам
|
||||||
|
(потому что мы указываем источник и приёмник явно). Для трёх
|
||||||
|
приложений без backup-механизма (`caddyproxy`, `remembos`,
|
||||||
|
`transcriber`) rsync — **единственный** канал переноса, restic
|
||||||
|
для них не альтернатива.
|
||||||
|
|
||||||
**Вариант A — rsync напрямую (быстрее).** С target-машины тянем
|
**Вариант A — rsync напрямую (основной путь).** С target-машины
|
||||||
данные со старой:
|
тянем данные со старой:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo rsync -aAX --info=progress2 --delete \
|
sudo rsync -aAX --info=progress2 --delete \
|
||||||
--exclude='lost+found' \
|
--exclude='lost+found' \
|
||||||
major@158.160.46.255:/mnt/applications/ \
|
major@158.160.46.255:/mnt/applications/ \
|
||||||
/mnt/applications/
|
/srv/applications/
|
||||||
```
|
```
|
||||||
|
|
||||||
`-aAX` сохраняет ACL/xattrs и uid/gid (численные значения).
|
`-aAX` сохраняет ACL/xattrs и uid/gid (численные значения).
|
||||||
|
Численные uid/gid на target совпадают с источником, потому что
|
||||||
|
плейбуки на обеих машинах создают пользователей с одинаковыми
|
||||||
|
явно заданными `app_owner_uid`/`gid`.
|
||||||
|
|
||||||
Каждое приложение можно тянуть отдельно — удобнее наблюдать
|
Каждое приложение можно тянуть отдельно — удобнее наблюдать
|
||||||
прогресс и можно частично пересинхронизировать в случае ошибок.
|
прогресс и можно частично пересинхронизировать в случае ошибок:
|
||||||
|
|
||||||
**Вариант B — restore из restic.** Если по сети источник недоступен
|
|
||||||
(например, IP уже закрыли) или хочется проверить, что бэкапы вообще
|
|
||||||
рабочие — восстанавливаемся из YC S3:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo /usr/local/sbin/restic-shell.sh
|
sudo rsync -aAX --info=progress2 --delete \
|
||||||
restic restore latest --target /mnt/applications --path /mnt/applications
|
major@158.160.46.255:/mnt/applications/gitea/ \
|
||||||
|
/srv/applications/gitea/
|
||||||
```
|
```
|
||||||
|
|
||||||
Рекомендую **A с фолбэком на B**: rsync быстрее и точнее (с
|
**Вариант B — restore из restic (страховка).** Если по сети
|
||||||
точностью до секунды), restic держим как страховку.
|
источник недоступен или хочется проверить, что бэкапы вообще
|
||||||
|
рабочие. Подробный пример (с учётом смены `/mnt` → `/srv`) — в
|
||||||
|
[журнале миграции, шаг 5](timeweb-migration-log.md).
|
||||||
|
|
||||||
|
Для `caddyproxy`, `remembos`, `transcriber` использовать B
|
||||||
|
**нельзя** — у них нет архивации, в restic-снапшоте данных просто
|
||||||
|
нет. Только A.
|
||||||
|
|
||||||
|
Рекомендую **A как основной метод**, B держим как страховку
|
||||||
|
для приложений, у которых есть восстановимый снапшот.
|
||||||
|
|
||||||
### Шаг 5. Запуск приложений на target
|
### Шаг 5. Запуск приложений на target
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ roots = [
|
|||||||
"{{ application_dir }}"
|
"{{ application_dir }}"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Расписание обслуживающих фаз restic.
|
||||||
|
# Триггер — один ночной запуск (cron в playbook-backups.yml), поэтому в выражениях
|
||||||
|
# значимы ТОЛЬКО поля дня месяца / месяца / дня недели. Минуты и часы держим как
|
||||||
|
# "* *" — они декоративны: фаза всё равно выполняется в момент ночного прогона,
|
||||||
|
# а не во время, указанное в выражении.
|
||||||
|
# День недели по cron-конвенции: 0 = воскресенье.
|
||||||
|
# backup и forget выполняются КАЖДЫЙ прогон и в расписании не участвуют.
|
||||||
|
[schedule]
|
||||||
|
check = "* * * * 0" # структурный restic check — по воскресеньям
|
||||||
|
verify = "* * 5 * *" # check --read-data-subset — 5-го числа
|
||||||
|
prune = "* * 1 1,4,7,10 *" # restic prune — 1-го числа янв/апр/июл/окт
|
||||||
|
|
||||||
|
# Параметры обслуживания. Подобраны под Yandex Object Storage Intelligent Tiering:
|
||||||
|
# редкий prune с высоким --max-unused минимизирует репак и не сбивает охлаждение объектов.
|
||||||
|
[maintenance]
|
||||||
|
verify_subset = "1/12" # порция данных за один verify -> полное покрытие за год
|
||||||
|
prune_max_unused = "20%" # выше дефолтных 5% -> меньше репака -> дольше держится охлаждение
|
||||||
|
prune_max_repack = "5G" # потолок объёма перезаписи за один prune
|
||||||
|
|
||||||
[storage.yandex_cloud_s3]
|
[storage.yandex_cloud_s3]
|
||||||
type = "restic"
|
type = "restic"
|
||||||
restic_repository = "{{ restic_repository }}"
|
restic_repository = "{{ restic_repository }}"
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
caddyproxy:
|
caddyproxy:
|
||||||
image: caddy:2.11.2
|
image: caddy:2.11.3
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: "caddyproxy"
|
container_name: "caddyproxy"
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
dozzle_app:
|
dozzle_app:
|
||||||
image: amir20/dozzle:v10.6.0
|
image: amir20/dozzle:v10.6.2
|
||||||
container_name: dozzle_app
|
container_name: dozzle_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
gitea_app:
|
gitea_app:
|
||||||
image: gitea/gitea:1.26.1
|
image: gitea/gitea:1.26.4
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
container_name: gitea_app
|
container_name: gitea_app
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
goaccess_processor:
|
goaccess_processor:
|
||||||
build: .
|
build: .
|
||||||
image: local/goaccess-jq:1.10.2
|
image: local/goaccess-jq:1.10.2
|
||||||
@@ -26,7 +25,7 @@ services:
|
|||||||
- "web_proxy_network"
|
- "web_proxy_network"
|
||||||
|
|
||||||
goaccess_app:
|
goaccess_app:
|
||||||
image: caddy:2.11.2
|
image: caddy:2.11.3
|
||||||
container_name: goaccess_app
|
container_name: goaccess_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
|
user: "{{ app_owner_uid }}:{{ app_owner_gid }}"
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
|
# See versions: https://github.com/gramps-project/gramps-web/pkgs/container/grampsweb
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
gramps_app: &gramps_app
|
gramps_app: &gramps_app
|
||||||
image: ghcr.io/gramps-project/grampsweb:26.5.1
|
image: ghcr.io/gramps-project/grampsweb:26.6.0
|
||||||
container_name: gramps_app
|
container_name: gramps_app
|
||||||
depends_on:
|
depends_on:
|
||||||
- gramps_redis
|
- gramps_redis
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
memos_app:
|
memos_app:
|
||||||
image: neosmemo/memos:0.28.0
|
image: neosmemo/memos:0.29.1
|
||||||
container_name: memos_app
|
container_name: memos_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ services:
|
|||||||
# See sample https://github.com/outline/outline/blob/main/.env.sample
|
# See sample https://github.com/outline/outline/blob/main/.env.sample
|
||||||
|
|
||||||
outline_app:
|
outline_app:
|
||||||
image: outlinewiki/outline:1.7.1
|
image: outlinewiki/outline:1.8.1
|
||||||
container_name: outline_app
|
container_name: outline_app
|
||||||
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
user: "{{ owner_create_result.uid }}:{{ owner_create_result.group }}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
wakapi_app:
|
wakapi_app:
|
||||||
image: ghcr.io/muety/wakapi:2.17.3
|
image: ghcr.io/muety/wakapi:2.17.4
|
||||||
container_name: wakapi_app
|
container_name: wakapi_app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
user: '{{ owner_create_result.uid }}:{{ owner_create_result.group }}'
|
||||||
|
|||||||
@@ -1,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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
- name: "Login to Yandex Container Registry"
|
- name: "Login to Yandex Container Registry"
|
||||||
community.docker.docker_login:
|
community.docker.docker_login:
|
||||||
registry_url: "cr.yandex"
|
registry_url: "{{ yc_container_registry }}"
|
||||||
username: "oauth"
|
username: "oauth"
|
||||||
password: "{{ yc_oauth_token }}"
|
password: "{{ yc_oauth_token }}"
|
||||||
|
|
||||||
|
|||||||
@@ -77,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
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
- htop
|
- htop
|
||||||
- jq
|
- jq
|
||||||
- make
|
- make
|
||||||
|
- python3-croniter
|
||||||
- python3-pip
|
- python3-pip
|
||||||
|
- python3-requests
|
||||||
- sqlite3
|
- sqlite3
|
||||||
- tree
|
- tree
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
- name: "Login to Yandex Container Registry"
|
- name: "Login to Yandex Container Registry"
|
||||||
community.docker.docker_login:
|
community.docker.docker_login:
|
||||||
registry_url: "cr.yandex"
|
registry_url: "{{ yc_container_registry }}"
|
||||||
username: "oauth"
|
username: "oauth"
|
||||||
password: "{{ yc_oauth_token }}"
|
password: "{{ yc_oauth_token }}"
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ requires-python = ">=3.12"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"ansible>=13.2.0",
|
"ansible>=13.2.0",
|
||||||
"ansible-lint>=25.12.2",
|
"ansible-lint>=25.12.2",
|
||||||
|
"croniter>=6.0.0",
|
||||||
"invoke>=2.2.1",
|
"invoke>=2.2.1",
|
||||||
"mypy>=1.19.1",
|
"mypy>=1.19.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"ruff>=0.15.2",
|
"ruff>=0.15.2",
|
||||||
|
"types-croniter>=6.0.0",
|
||||||
"types-requests>=2.32.4.20260107",
|
"types-requests>=2.32.4.20260107",
|
||||||
"yamllint>=1.37.1",
|
"yamllint>=1.37.1",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from invoke.context import Context
|
|||||||
from invoke.exceptions import Exit
|
from invoke.exceptions import Exit
|
||||||
from invoke.tasks import task
|
from invoke.tasks import task
|
||||||
|
|
||||||
HOSTS_FILE = "production.yml"
|
HOSTS_FILE = "timeweb.yml"
|
||||||
VARS_FILE = "vars/vars.yml"
|
VARS_FILE = "vars/vars.yml"
|
||||||
AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia"
|
AUTHELIA_DOCKER = "docker run --rm -v $PWD:/data authelia/authelia:4.39.4 authelia"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
ungrouped:
|
||||||
|
hosts:
|
||||||
|
server:
|
||||||
|
ansible_host: "92.53.105.41"
|
||||||
|
ansible_user: "major"
|
||||||
|
ansible_become: true
|
||||||
@@ -272,6 +272,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "croniter"
|
||||||
|
version = "6.2.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "python-dateutil" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/df/de/5832661ed55107b8a09af3f0a2e71e0957226a59eb1dcf0a445cce6daf20/croniter-6.2.2.tar.gz", hash = "sha256:ba60832a5ec8e12e51b8691c3309a113d1cf6526bdf1a48150ce8ec7a532d0ab", size = 113762, upload-time = "2026-03-15T08:43:48.112Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/39/783980e78cb92c2d7bdb1fc7dbc86e94ccc6d58224d76a7f1f51b6c51e30/croniter-6.2.2-py3-none-any.whl", hash = "sha256:a5d17b1060974d36251ea4faf388233eca8acf0d09cbd92d35f4c4ac8f279960", size = 45422, upload-time = "2026-03-15T08:43:46.626Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "46.0.3"
|
version = "46.0.3"
|
||||||
@@ -593,10 +605,12 @@ source = { virtual = "." }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "ansible" },
|
{ name = "ansible" },
|
||||||
{ name = "ansible-lint" },
|
{ name = "ansible-lint" },
|
||||||
|
{ name = "croniter" },
|
||||||
{ name = "invoke" },
|
{ name = "invoke" },
|
||||||
{ name = "mypy" },
|
{ name = "mypy" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "ruff" },
|
{ name = "ruff" },
|
||||||
|
{ name = "types-croniter" },
|
||||||
{ name = "types-requests" },
|
{ name = "types-requests" },
|
||||||
{ name = "yamllint" },
|
{ name = "yamllint" },
|
||||||
]
|
]
|
||||||
@@ -605,10 +619,12 @@ dependencies = [
|
|||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "ansible", specifier = ">=13.2.0" },
|
{ name = "ansible", specifier = ">=13.2.0" },
|
||||||
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
{ name = "ansible-lint", specifier = ">=25.12.2" },
|
||||||
|
{ name = "croniter", specifier = ">=6.0.0" },
|
||||||
{ name = "invoke", specifier = ">=2.2.1" },
|
{ name = "invoke", specifier = ">=2.2.1" },
|
||||||
{ name = "mypy", specifier = ">=1.19.1" },
|
{ name = "mypy", specifier = ">=1.19.1" },
|
||||||
{ name = "requests", specifier = ">=2.32.5" },
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
{ name = "ruff", specifier = ">=0.15.2" },
|
{ name = "ruff", specifier = ">=0.15.2" },
|
||||||
|
{ name = "types-croniter", specifier = ">=6.0.0" },
|
||||||
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
|
{ name = "types-requests", specifier = ">=2.32.4.20260107" },
|
||||||
{ name = "yamllint", specifier = ">=1.37.1" },
|
{ name = "yamllint", specifier = ">=1.37.1" },
|
||||||
]
|
]
|
||||||
@@ -631,6 +647,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "python-dateutil"
|
||||||
|
version = "2.9.0.post0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "six" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytokens"
|
name = "pytokens"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -886,6 +914,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/78/097c0798b1dab9f8affe73da9642bb4500e098cb27fd8dc9724816ac747b/ruff-0.15.2-py3-none-win_arm64.whl", hash = "sha256:cabddc5822acdc8f7b5527b36ceac55cc51eec7b1946e60181de8fe83ca8876e", size = 10941649, upload-time = "2026-02-19T22:32:18.108Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "six"
|
||||||
|
version = "1.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "subprocess-tee"
|
name = "subprocess-tee"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -895,6 +932,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4d/ab/e3a3be062cd544b2803760ff707dee38f0b1cb5685b2446de0ec19be28d9/subprocess_tee-0.4.2-py3-none-any.whl", hash = "sha256:21942e976715af4a19a526918adb03a8a27a8edab959f2d075b777e3d78f532d", size = 5249, upload-time = "2024-06-17T19:51:15.949Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/ab/e3a3be062cd544b2803760ff707dee38f0b1cb5685b2446de0ec19be28d9/subprocess_tee-0.4.2-py3-none-any.whl", hash = "sha256:21942e976715af4a19a526918adb03a8a27a8edab959f2d075b777e3d78f532d", size = 5249, upload-time = "2024-06-17T19:51:15.949Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "types-croniter"
|
||||||
|
version = "6.2.2.20260518"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/20/02/f03a44ded34e6abb125c339647b070f2705a0583782f5638d62ab958cdc2/types_croniter-6.2.2.20260518.tar.gz", hash = "sha256:aceb426b9187bb9255b89d17713d07ac034a2b96b437bfdd5d3a56b46b4eb656", size = 12120, upload-time = "2026-05-18T06:03:11.885Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/3d/f12c417944d00c42db71de3e334f36a69cafa6767ff3fb705c9e1d101e53/types_croniter-6.2.2.20260518-py3-none-any.whl", hash = "sha256:85018c7ce091428d3643be239ad348e27f9a8fb77ca94335cc39ebeb9403b240", size = 9743, upload-time = "2026-05-18T06:03:10.608Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-requests"
|
name = "types-requests"
|
||||||
version = "2.32.4.20260107"
|
version = "2.32.4.20260107"
|
||||||
|
|||||||
Reference in New Issue
Block a user