# Общие Ansible-роли между серверными репозиториями ## Статус Черновик. Подход обсуждён, реализация не начата. ## Контекст Серверы управляются изолированными Ansible-репозиториями: по одному на сервер (pet-project-server, buckland, далее — новый торрент-бокс). Кастомные роли (`owner`, `eget`, `secrets`) концептуально общие, но физически продублированы, и копии дрейфуют: `eget` разошёлся косметически, `owner` в buckland превратился в мёртвый код, пока актуальная версия развивалась в pet-project-server. Нужен способ держать роли общими между репозиториями, при этом: - правка роли применяется мгновенно — без петли commit → push → install; - каждый репозиторий остаётся самодостаточным: клонируется и деплоится без внешних зависимостей, CI (yamllint + ansible-lint) работает как сейчас; - обновление роли на конкретном сервере — явное решение, а не незаметный сайд-эффект. ## Осознанный выбор для pets-серверов Это решение спроектировано под подход «pets, not cattle» и не претендует на масштаб. Серверы — питомцы: у каждого своё железо, свой набор приложений и свой темп жизни. Из этого следуют принципы, противоположные командной инженерии: - дублирование допустимо, принудительная консистентность — нет; - каждый сервер принимает обновление общей роли явно, в свой момент, через видимый дифф в собственной истории git; - никакой инфраструктуры публикации (registry, версии, релизы) — потребитель ролей один человек, который сам же их и пишет. Если серверов станет много или появятся другие потребители — решение нужно пересмотреть (см. «Путь миграции»). ## Решение Вендоринг копий плюс локальная синхронизация через файловую систему. Каноническая версия ролей живёт в отдельной директории `~/projects/private/ansible-shared/`. Каждый серверный репозиторий держит свою закоммиченную копию нужных ролей в `roles/` и синхронизируется с каноном командами invoke (rsync, git не участвует). ``` ~/projects/private/ ansible-shared/ roles/ owner/ eget/ secrets/ pet-project-server/ roles/owner/ # закоммиченная копия roles/eget/ roles/secrets/ buckland/ roles/eget/ # подписан только на eget <новый сервер>/ roles/... ``` Ключевое свойство: «пином версии» служит сама закоммиченная копия. Репозиторий всегда деплоит ровно то, что лежит в его git; обновление роли — это `roles-pull` + осознанный коммит с читаемым диффом. ### Подписка Каждый репозиторий объявляет в `tasks.py` список ролей, которые он синхронизирует: ```python SYNCED_ROLES = ["owner", "eget", "secrets"] ``` Подписка явная и пер-серверная: buckland может не подписываться на `owner`, пока живёт в старой архитектуре (всё под `primary_user`). Роли вне списка синхронизация не трогает — репозиторий может держать и сугубо локальные роли. ### Команды Три задачи invoke, одинаковый блок (~30 строк) в `tasks.py` каждого репозитория: - `inv roles-pull [-- ]` — канон → репозиторий. Для каждой роли из подписки (или одной указанной): `rsync -a --delete /roles// roles//`. Флаг `--delete` обязателен: удалённые в каноне файлы должны исчезать из копии, иначе дрейф через «хвосты». - `inv roles-push [-- ]` — репозиторий → канон, тот же rsync в обратную сторону. - `inv roles-status` — `diff -r -q` каждой подписанной роли против канона; выводит `ok` / `differs` / `missing in shared` по ролям, ненулевой код выхода при расхождении. Защита от ошибок: - обе команды печатают список затронутых файлов (`rsync -i`) — операция видна, а не молчалива; - `roles-push` при отличающемся каноне показывает дифф и просит подтверждение (защита от затирания более свежего канона устаревшей копией; типовой случай — правили роль в двух репозиториях параллельно); - `roles-pull` подтверждения не требует: затирается рабочая копия, её состояние защищено git — `git diff` покажет, что приехало, и любое изменение можно откатить до коммита. ### Рабочий цикл Правка роли: 1. Роль правится прямо в том репозитории, где идёт работа, — тестировать её всё равно можно только запуском реального плейбука этого сервера. Петля правка → запуск нулевая. 2. Когда правка устоялась: `inv roles-push` и коммит в серверном репозитории. 3. В остальных репозиториях — `inv roles-pull`, когда их сервер готов принять обновление. Дифф виден в `git diff`, коммитится в историю этого репозитория. Новый сервер: 1. Создать репозиторий, объявить `SYNCED_ROLES`. 2. `inv roles-pull` — роли приезжают из канона, коммитятся. Новая общая роль: написать в любом репозитории, добавить в его `SYNCED_ROLES`, `inv roles-push`; остальные подписываются по мере надобности. ### Предохранитель в lefthook Дрейф неизбежен (синхронизация — дисциплина, не механизм), поэтому он должен быть видимым. В pre-commit каждого репозитория — проверка: если в staged-файлах есть `roles/<подписанная роль>/` и роль отличается от канона, печатается предупреждение «роль X отличается от ansible-shared, не забудь roles-push / roles-pull». Именно предупреждение, не блокировка: коммит локальной правки до push в канон — нормальный рабочий момент. Если директории `ansible-shared` нет на машине, проверка молча пропускается (CI, чужая машина). ### Статус ansible-shared `ansible-shared` — рабочая директория, а не репозиторий-сервис. Можно сделать её git-репозиторием для бэкапа и истории канона, но этот git не стоит в рабочей петле: коммиты туда делаются когда угодно и ничего не блокируют. Серверные репозитории не знают о её git-статусе — они видят только файлы. ## Отвергнутые альтернативы - **Общий `roles_path` в ansible.cfg** (`roles_path = ./roles:../ansible-shared/roles`). Нулевая синхронизация, но репозитории теряют самодостаточность (CI и клон на другой машине ломаются), а главное — правка роли молча применяется ко всем серверам при следующем запуске. Для pets-подхода это анти-свойство. - **Симлинки на общую директорию, закоммиченные в git.** Та же семантика плюс ломкость: валидны только при конкретной раскладке директорий на диске. - **Git submodule / subtree.** Решают задачу для команд ценой петли commit → push → update и известной UX-боли; принудительная консистентность здесь не нужна, а петля — главное, от чего уходим. - **Galaxy-роли из git-репозитория** (`requirements.yml` с git-URL, версии тегами). Правильный инструмент при росте масштаба, но сейчас церемония tag → push → install — это и есть слишком большая петля обратной связи для одного человека. - **Монорепозиторий на все серверы.** Отвергнут на уровне организации проектов: серверы — pets, изолированные репозитории с локальной историей ценнее общей точки правды. ## Цена и риски - Синхронизация держится на дисциплине. Забытый `roles-push` — канон отстал; правка одной роли в двух репозиториях без push — конфликт, который разрешается руками через дифф. Митигация — предупреждение в lefthook и `roles-status`; при текущем темпе изменений (единицы в год) этого достаточно. - Канон существует в одном экземпляре на одной машине. Потеря машины — потеря только канона, не ролей: они восстановимы из любой свежей копии (`roles-push`). Опциональный git в `ansible-shared` закрывает и это. - Блок задач в `tasks.py` дублируется по репозиториям и сам может дрейфовать. Принимаем: это ~30 строк, меняются реже ролей. ## Путь миграции Если ролей станет десяток, серверов — больше трёх, или появится второй пользователь, `ansible-shared` уже имеет структуру стандартного galaxy-источника: повесить теги, перевести репозитории на `requirements.yml` с git-URL, удалить rsync-задачи. Вендоринг копий делает переход безопасным — в любой момент репозитории самодостаточны. ## План внедрения 1. Создать `~/projects/private/ansible-shared/roles/` из актуальных ролей pet-project-server (`owner`, `eget`, `secrets`). 2. Добавить задачи `roles-pull` / `roles-push` / `roles-status` в `tasks.py` pet-project-server; объявить подписку на все три роли. 3. То же в buckland; подписка только на `eget` (его копия сначала выравнивается через `roles-pull` — дифф косметический, стиль кавычек). Мёртвую `roles/owner` удалить. 4. Предупреждение о дрейфе в lefthook обоих репозиториев. 5. Новый сервер с самого начала строится на подписке.