# Переход provisioning LXC на OpenTofu Пошаговый план миграции. Рассчитан на исполнителя, который этот разговор не видел: всё нужное либо здесь, либо по ссылкам на файлы репозитория. Стратегия — **blue-green**: боевые контейнеры не импортируются в Tofu и не переконфигурируются на месте. Вместо этого рядом создаётся новый контейнер, туда переносятся данные, затем переключается адрес, а старый контейнер некоторое время стоит остановленным как откат. --- ## 0. Как начать сессию по этому плану Открыть Claude Code в корне репозитория и дать примерно такой промпт, подставив нужный сервис: Работаем по docs/ai/migration-tofu.md — переход provisioning LXC на OpenTofu по схеме blue-green. Прочитай его целиком, а также tofu/README.md и AGENTS.md. Делаем сервис №1 из раздела 5 (emergency-bot, CT 148). Идём строго по процедуре раздела 4, по шагам, не забегая вперёд. Правила: - разрушающие шаги (pct stop, pct destroy, tofu-destroy, смена адреса) выполняешь только после моего явного подтверждения; - старый контейнер не удаляем, он остаётся откатом; - make validate должен быть зелёным до и после; - если реальность разошлась с планом — останавливайся и говори, не придумывай обход. Между сервисами сессию имеет смысл начинать заново: контекст одного переезда следующему не нужен, а свежий контекст надёжнее. --- ## 1. Что уже сделано — не переделывать - `tofu/` с провайдером `bpg/proxmox`, цели `make tofu-init | tofu-plan | tofu-apply CONFIRM=1 | tofu-destroy CONFIRM=1`. - Аутентификация: `root@pam` по паролю из корневого `.env` (`PROXMOX_ROOT_USER`, `PROXMOX_ROOT_PASSWORD`). Выбор режима автоматический, печатается в stderr. Подробности и матрица возможностей — `tofu/README.md`. - Цели `tofu-*` сами поднимают SSH-туннель через `ru-vps`: провайдер ходит в API по HTTPS и ProxyJump не умеет. - Реестр `homelab_services` программно потребляют: `reverse-proxy.yml`, `ru-vps-base.yml`, `pve-backup-jobs.yml` (списки VMID по `backup.job`), `roles/backup_audit` (VMID по `monitoring.backup_audit_vmid`), `validate.yml`. - `make validate` — read-only gate, сверяет реестр с `pct config` по hostname, IP, cores, memory, swap. Падает при расхождении. - `make lint` воспроизводит CI (линтеры из корня + syntax-check всех плейбуков). ### Что проверено на пилоте (VMID 199, снесён) В режиме `root@pam` Tofu выставляет декларативно всё, что нужно этой инфраструктуре: dev0: deny-write=0,path=/dev/net/tun,uid=0,gid=0,mode=0660 features: fuse=1,keyctl=1,nesting=1 mp0: data:199/vm-199-disk-1.raw,mp=/opt/pilot-data,size=4G Повторный `plan` даёт `No changes`. Следствия: - правка `/etc/pve/lxc/.conf` через `lineinfile` (девять контейнеров) заменяется декларацией в Tofu, но КАКОЙ именно — зависит от устройства: `/dev/fuse` — штатным флагом `features { fuse = true }`, `/dev/net/tun` — блоком `device_passthrough`. Оба варианта видны в выводе пилота выше: `features: fuse=1,...` и `dev0: ...path=/dev/net/tun`; - шаг `pct set --features nesting=1,keyctl=1` больше не нужен; - `lifecycle { ignore_changes = [features] }` не нужен; - apply одной фазой. **Важно:** всё это работает ТОЛЬКО под `root@pam` по паролю. API-токен, даже принадлежащий root, проверку не проходит — в Proxmox она буквальная (`$authuser eq 'root@pam'`, `PVE/LXC.pm:1658`), а при токенной аутентификации `$authuser` равен полному `user@realm!tokenname` (`PVE/HTTPServer.pm:86`). ### Ключевое упрощение **Если при переезде сохранить IP, меняется только VMID.** А все потребители VMID уже выводят его из реестра автоматически (backup jobs, backup audit). Поэтому cutover — это правка одного поля `vmid` в `services.yml`, а не синхронная правка шести файлов. Ради этого и делалась генерация. --- ## 2. Инварианты 1. **Последовательным обязан быть только cutover.** Инвариант изначально звучал как «один сервис за раз»; 2026-09-02 он уточнён после параллельного прогона шести сервисов. Разделение такое: - **Параллелится безопасно:** подготовка (снятие эталона, описание в Tofu), создание новых контейнеров (один пакетный `apply`, Tofu сам разводит ресурсы) и конфигурация на временных адресах (шаг 4.3). Всё это не трогает боевые контейнеры и полностью откатывается точечным `tofu destroy` нужного ресурса. - **Остаётся строго последовательным:** перенос данных (4.4), переключение адреса (4.5) и правка реестра (4.6) — по одному сервису, с явным подтверждением человека на каждый разрушающий шаг. Причины, по которым `apply` физически не параллелится: состояние Tofu одно и локальное, а цели `make tofu-*` поднимают SSH-туннель на фиксированный порт 18006 с фиксированным control-сокетом. Два одновременных прогона столкнутся. 2. **Старый контейнер останавливается, но не удаляется** — минимум неделю. Это единственный быстрый откат. 3. `make validate` зелёный до и после каждого переезда. 4. Перед стартом каждого сервиса — свежий бэкап PBS этого VMID. 5. VMID и IP выведенных контейнеров не переиспользовать сразу: в PBS остаются цепочки по VMID, в кэшах — адреса. 6. **CT 120 `pbs` не мигрировать.** Это цель бэкапов, на которую опирается план отката всех остальных. Он `unmanaged` и таким остаётся. 7. Не менять одновременно provisioning и рантайм сервиса без необходимости. Если переводишь `docker run` на `compose_service` — это отдельное осознанное решение по конкретному сервису, а не часть переезда по умолчанию. --- ## 3. Предпосылки перед первым переездом - [x] Вывод `memoir-bot` — live-шаги оператора выполнены 2026-09-02: deploy key `SecondBrain` отозван, монитор в Uptime Kuma снят, `pct stop 142` выполнен (подтверждено `pct list` на mini-pc). Это была репетиция удаления старого контейнера. `pct destroy 142` сознательно отложен — см. [`plan.md`](plan.md) и общий инвариант о паузе перед удалением. - [x] `make validate` — зелёный (проверено 2026-09-02, 12 сервисов, drift нет). - [x] `make backup-audit` — прогнан 2026-09-02, все хосты `ok`, без ошибок. - [x] `tofu/terraform.tfstate` — решено оставить только локальным на время переезда сервиса №1. Осознанный риск: state небольшой, при потере можно re-import всё созданное заново. Постоянное решение (например, offsite restic-профиль) — отдельная задача, не блокирует старт. - [x] Свободное место проверено. На 2026-09-02: cloud-pc `data` — 816 ГиБ свободно, mini-pc `local-lvm` — 116 ГиБ. Запаса хватает на любой сервис, включая grimmory (64 ГиБ). ### Пул временных адресов и VMID Свободны на 2026-09-02: IP `192.168.1.6-9, 11-19, 21, 22, 26, 33, 36-40`, VMID `151+` и `199`. Временный адрес обязан лежать в `192.168.1.5-40` — только этот диапазон ru-vps маршрутизирует в LAN через `tun0`, иначе новый контейнер будет недоступен по ProxyJump. Шаблон `local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst` присутствует на обеих нодах — проверено. --- ## 4. Процедура переезда одного сервиса Обозначения: `OLD` — текущий VMID, `NEW` — временный VMID, `IP` — боевой адрес, `TMPIP` — временный адрес. ### 4.1 Подготовка 1. `make validate` и `make backup-audit` — оба зелёные. 2. Снять эталон: `ssh sudo pct config OLD` — сохранить вывод. Это источник правды для того, что нужно воспроизвести. 3. Запустить свежий бэкап: `ssh sudo vzdump OLD --storage pbs --mode snapshot`. - **Успех проверять наличием снапшота, а не кодом возврата.** Исторически на 2026-09-02 client-side prune давал `missing Datastore.Modify|Datastore.Prune`, из-за чего `vzdump` печатал `Backup of VM ... failed` и возвращал ненулевой код после успешной выгрузки. Это объяснение для старых логов; сейчас client-side prune из плейбуков убран, поэтому любой новый non-zero надо считать проблемой. Проверять так: `ssh cloud-pc 'sudo pvesm list pbs | grep ct/'`. Подробности — [`plan.md`](plan.md), раздел про prune. - **Если у сервиса Docker с fuse-overlayfs, а rootfs на каталоговом хранилище (`data`) — сначала остановить сервис.** Такой rootfs не поддерживает снапшоты, vzdump уходит в режим `suspend` с rsync и падает на `var/lib/docker/fuse-overlayfs/.../merged`: Permission denied. Проверено на gitea 2026-09-02: с остановленным сервисом бэкап проходит. У сервисов на `local-lvm` (vaultwarden) проблемы нет — там снапшот. - **У сервиса с bind mount в бэкап попадает только rootfs.** vzdump пишет `excluding bind mount point mpN (...) from backup (not a volume)`. До переезда это касалось gitea: его репозитории и БД в PBS не попадали вообще. После перехода на volume попадают. ### 4.2 Описание в Tofu 4. Завести ресурс в `tofu/` (например `tofu/services.tf`), воспроизведя эталон: `vm_id = NEW`, hostname как у боевого, `TMPIP`, cores, memory, swap, rootfs на том же datastore и того же размера, `features`, декларацию для каждого устройства из `lxc.mount.entry`, `mount_point` для каждого `mpN`, `startup.order`, `start_on_boot`, `unprivileged`, `console { type = "shell" }` (эталон `cmode` — см. ниже). - **`/dev/fuse` — это `features.fuse`, а НЕ `device_passthrough`.** Ловушка: в эталоне `/dev/fuse` выглядит как пара сырых строк (`lxc.cgroup2.devices.allow: c 10:229 rwm` + `lxc.mount.entry`), и её хочется механически перевести в `device_passthrough`. Так делать не надо: `device_passthrough` (`dev0:` в PVE 8.2+) предназначен для сырых character-устройств вида `/dev/net/tun`, а у `/dev/fuse` есть штатный флаг PVE, проверенный на пилоте. См. `tofu/pilot.tf.example:17-21`. Следствие: `pct config` нового контейнера покажет `features: fuse=1,...`, то есть будет текстуально отличаться от эталона при том же эффекте — это ожидаемо, а не drift. - **Bind mount заменять на volume.** Если у сервиса `mpN: /host/path,mp=...` (сейчас так только у gitea), в новом контейнере это должен быть `mount_point { volume = "", size = "...", path = "..." }`. Данные переносятся на шаге 4.4, а не монтированием того же каталога: два контейнера, пишущие в один каталог, повредят данные. - **`console { type = "shell" }` обязателен.** `roles/pve_lxc` всем контейнерам ставит `cmode: shell`; провайдер отслеживает это через блок `console`, и без явного объявления следующий `tofu-plan` предложит откатить его на дефолт Proxmox `tty` — тот же класс drift, что и `keyctl` в гибридной схеме. Подробности и история находки — `tofu/README.md`. 5. `make tofu-plan` — убедиться, что план ровно `1 to add`. 6. `make tofu-apply CONFIRM=1`. 7. Сверить: `ssh sudo pct config NEW` против эталона из шага 2. Отличаться должны только `vmid` и адрес (плюс явно выписанные Proxmox-дефолты вида `cpulimit`, `protection`, `template`, `tty` — эталон их просто не показывал, реальное поведение то же самое). `cmode: shell` должен совпасть с эталоном уже на этом шаге, если блок `console` объявлен в шаге 4 — правкой постфактум через `pct set` не чинить, это создаёт drift (см. шаг 4 выше и `tofu/README.md`). ### 4.3 Настройка 8. Добавить временную запись в `ansible/inventory/hosts.yml`: `-new` с `ansible_host: TMPIP` и `expected_lan_ip: TMPIP`, и такую же запись в `ansible/ssh_config` (Host-блок + в агрегированные строки ProxyJump, root, общие опции). - Во время cutover использовалась временная группа `lxc_migration_new`; она не входила в `servers`, поэтому незавершённый переезд не краснил `make check` и `make status`. - **Транспорт задавался только в `group_vars/lxc_migration_new/main.yml`, а не блоком `vars:` в самом `hosts.yml`.** У Ansible inline-переменные группы из inventory-файла имеют приоритет НИЖЕ, чем `group_vars/all/`, поэтому `ansible_user: ansible` оттуда перебивает inline `root`, и подключение падает с `Permission denied`. Каталог `group_vars/<группа>/` — наоборот, выше `all`. Найдено 2026-09-02. 9. Прогнать конфигурационную часть существующего плейбука против нового контейнера: ansible-playbook playbooks/pve-.yml \ -e pve_config_target=-new --limit -new **Одного `--limit -new` НЕДОСТАТОЧНО, и это не особенность отдельного сервиса.** Ansible пересекает лимит с паттерном play, а не подменяет его. Паттерн конфигурационного play — литеральное имя боевого хоста (`hosts: gitea`), пересечение с `gitea-new` пусто, и play молча получает `hosts (0)`. Проверено 2026-09-02 через `--list-hosts` на всех плейбуках сразу: ноль хостов во ВСЕХ play, а не только в создающих. Прошлая сессия (emergency-bot) сочла это особенностью того сервиса — на самом деле это общее свойство. Поэтому у конфигурационных play заведён переопределяемый таргет: hosts: "{{ pve_config_target | default('') }}" Он есть в `pve-docker-test.yml`, `pve-gitea.yml`, `pve-vaultwarden.yml`, `pve-grimmory.yml`, `gyro.yml` и `uptime-kuma.yml`. По умолчанию поведение не меняется — без `-e` паттерн равен прежнему литералу. `--limit` при этом всё равно обязателен: он отсекает play создания контейнера и play-стражи, которые таргетят ноду (`cloud-pc`/`mini-pc`) или `localhost`. Без него плейбук пойдёт делать `pct create` и править `/etc/pve/lxc/.conf` на боевом VMID. **Перед каждым прогоном сверяться с `--list-hosts`.** Ожидание: у play создания и стражей `hosts (0)`, у конфигурационного — ровно `hosts (1): -new`. Это и есть предохранитель, а не формальность. Особые случаи: - `monitoring` — конфигурации в `pve-monitoring.yml` нет вообще, там только создание. Настраивает `playbooks/uptime-kuma.yml` (роль `uptime_kuma`). `playbooks/monitoring.yml` (замороженный Prometheus) НЕ запускать. - `gyro` — конфигурация в `playbooks/gyro.yml`, нужен пароль Ansible Vault (`make gyro` / `--ask-vault-pass`). Роль читает `host_vars/gyro/`. Во время cutover использовался временный host_vars shim; после перевода CT 156 на боевой IP он удалён. 10. Проверить, что сервис поднялся на `TMPIP` (его health-эндпоинт или порт). У сервисов с данными новый контейнер на этом шаге работает на ПУСТОМ состоянии — данные приезжают только на шаге 4.4. Пустой Gitea, пустой Vaultwarden с экраном создания учётки, пустая Uptime Kuma, свежая схема Flyway у grimmory — это ожидаемый результат шага 4.3, а не поломка. Поднимать новую копию на боевых данных до остановки старой НЕЛЬЗЯ: две живые копии над одной SQLite или одной MariaDB повредят состояние. ### 4.4 Перенос данных 11. Остановить сервис на СТАРОМ контейнере (`systemctl stop `), сам контейнер пока не трогать. 12. Финальная синхронизация. Способ зависит от сервиса — см. раздел 5. 13. Запустить сервис на новом, проверить на `TMPIP`. ### 4.5 Переключение 14. `ssh sudo pct stop OLD`. 15. В Tofu поменять адрес нового контейнера с `TMPIP` на `IP`, `make tofu-apply CONFIRM=1`. 16. Перезапустить контейнер, если адрес не подхватился на живую. - **Если конфигурация сервиса содержит его собственный адрес — прогнать плейбук заново сразу после смены адреса.** Из пакета 2026-09-02 это касается только `grimmory` (`ports: :6060:6060` и правила `GRIMMORY-FILTER`): его compose всё ещё содержит TMPIP, и сервис не поднимется, пока файл не перегенерируют. Проверять просто: `ss -ltn` на новом контейнере — если сокет висит на конкретном адресе, а не на `0.0.0.0`/`*`, повторный прогон обязателен. У gitea, vaultwarden и uptime-kuma сокеты wildcard, им это не нужно. Прогон уже без `-e pve_config_target`: адрес стал боевым, и имя хоста само резолвится в новый контейнер. 17. Health-check на боевом `IP`. 18. Если сервис публичный (`proxy` в реестре) — `make reverse-proxy` и проверить домен снаружи. При сохранённом IP апстрим не меняется, но прогон подтвердит. 19. Обновить `~/.ssh/known_hosts` — host key нового контейнера другой: `ssh-keygen -R ` и `ssh-keygen -R `. В `ssh_config` стоит `StrictHostKeyChecking accept-new`, который принимает НОВЫЕ ключи, но не изменившиеся, поэтому без чистки подключение будет отвергнуто. - **Заодно закрыть ControlMaster: `ssh -F ansible/ssh_config -O exit `.** Мастер переживает cutover живым, но его туннель ведёт в уже остановленный контейнер; новые сессии мультиплексируются поверх мёртвого соединения и виснут с `Connection timed out during banner exchange`. Симптом выглядит как сетевая проблема, хотя контейнер полностью доступен с ноды. Разовый обход для диагностики — `-o ControlPath=none`. Найдено 2026-09-02 на docker-test. - У gitea SSH host-ключи git-over-ssh лежат в переносимых данных (`/ssh/ssh_host_*`), поэтому порт 2222 после переезда отдаёт ТОТ ЖЕ ключ и `known_hosts` git-клиентов остаётся валидным. Менять нужно только ключ sshd самого контейнера (порт 22). - **Про gyro и gitea: ранее записанное предупреждение неверно, проверено 2026-09-02.** Утверждалось, что `roles/gyro` пинит host key `[192.168.1.25]:2222` и после переезда gitea сломается. На самом деле задача `Remove obsolete Gitea known host` стоит с `state: absent` — она УДАЛЯЕТ устаревшую запись, а не создаёт её. Пинится `gyro_git_known_hosts_name: github.com`, и `gyro_repo_url` — `git@github.com:...`. Gyro клонирует с GitHub, к gitea отношения не имеет. Никаких дополнительных действий после переезда gitea не нужно. ### 4.5a Offsite restic: профиль надо переустановить на новом контейнере **Шаг, которого в плане не было. Найден 2026-09-02 на vaultwarden.** Если offsite-профиль restic выполняется ВНУТРИ контейнера сервиса (а не на ноде), то на новом контейнере его нет: там нет ни `restic`, ни `rclone`, ни systemd-таймера. Бэкап тихо перестаёт выполняться, и `make backup-audit` этого не ловит — он ставит только свой таймер аудита. Проверять так: ssh 'command -v restic rclone; systemctl list-timers --all | grep restic' Чинить так: make offsite-restic EXTRA="--limit " Затем обязательно проверить всю цепочку, а не только наличие таймера: ssh 'systemctl start homelab-restic-offsite-.service' ssh 'journalctl -u homelab-restic-offsite-.service -n 30 --no-pager' Успех выглядит как подключение к репозиторию с уже существующими снапшотами и `Finished ... Deactivated successfully`. Кого это касается: - `vaultwarden` — профиль внутри LXC. Сделано и проверено 2026-09-02. - `grimmory` — профиль тоже внутри LXC (`hosts: grimmory`). Понадобится то же. - `gitea` — профиль выполняется на cloud-pc и указывает на `/opt/data/gitea`. Там задача другая: после переезда на volume этот путь исчезает, профиль надо переписать на выполнение внутри контейнера, по образцу vaultwarden. ### 4.6 Реестр и потребители 20. В `ansible/inventory/group_vars/all/services.yml` поменять `vmid: OLD` на `vmid: NEW`. Если менялся `provisioner` — поправить и его на `tofu`. 21. Убрать временные записи `-new` из `hosts.yml` и `ssh_config`. 22. Прогнать зависящие цели: `make backup-jobs` (списки VMID выведутся заново), `make backup-audit` (перерендерит скрипт аудита с новым VMID). 23. `make validate` — должен быть зелёный. 24. `make status` — контейнер UP, юниты active. ### 4.7 Завершение 25. Оставить `OLD` остановленным минимум неделю. 26. Затем `pct destroy OLD` и решить судьбу его цепочки бэкапов в PBS. 27. Удалить из `playbooks/pve-.yml` часть, создающую контейнер (первый play с `pct create` / ролью `pve_lxc`, правками `/etc/pve/lxc/*.conf` и `pct set --features`). Оставить конфигурационную часть. --- ## 5. Порядок сервисов и специфика Порядок выбран по возрастанию риска. Не менять без причины. | # | Сервис | VMID | Узел | Почему здесь | |---|---|---|---|---| | 1 | `emergency-bot` | ~~148~~ **151** | mini-pc | ПЕРЕЕХАЛ 2026-09-02. Полностью шаблонный, персистентных данных нет | | 2 | `docker-test` | ~~145~~ **152** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02 | | 3 | `gitea` | ~~141~~ **153** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02; bind mount → volume, данные впервые попали в PBS | | 4 | `vaultwarden` | ~~140~~ **154** | mini-pc | ПЕРЕЕХАЛ 2026-09-02 | | 5 | `monitoring` | ~~146~~ **155** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02; замороженный стек не переносился | | 6 | `gyro` | ~~150~~ **156** | mini-pc | CUTOVER COMPLETED 2026-09-02; CT156 live on 192.168.1.35, CT150 held as rollback | | 7 | `grimmory` | ~~149~~ **157** | cloud-pc | ПЕРЕЕХАЛ 2026-09-02; MariaDB через dump/restore | | 8 | `adguard` | ~~144~~ **158** | mini-pc | ПЕРЕЕХАЛ 2026-09-03; данные /opt/adguard, DNS-провал ~2 мин | | 9 | `mihomo` | ~~143~~ **159** | mini-pc | ПЕРЕЕХАЛ 2026-09-03; первый боевой device_passthrough /dev/net/tun | | 10 | `ovpn-mini` | ~~132~~ **160** | mini-pc | ПЕРЕЕХАЛ 2026-09-03; туннель-петля при cutover, см. 5.10 | | — | `hermes-ai` | 147 | cloud-pc | заморожен и недостижим по SSH, см. раздел 7 | | — | `pbs` | 120 | cloud-pc | не мигрировать | **Все мигрируемые сервисы (1-10) переехали на OpenTofu.** Осталось: `hermes-ai` (заморожен), `pbs` (не мигрируется). Пост-миграционная уборка раздела 6 (удаление `roles/pve_lxc`, стрип creation plays у 8 плейбуков, правки `architecture.md`) — отдельная задача, ещё не сделана; частично разблокирована. ### Специфика по сервисам **1. `emergency-bot`.** ПЕРЕЕХАЛ 2026-09-02: OLD 148 остановлен, NEW 151 боевой на 192.168.1.32. Данных нет вообще: `roles/emergency_bot` разворачивает `emergency_bot.py.j2`, `.env.j2` и юнит из шаблонов. Шаг 4.4 пропущен целиком. Бэкапа у него нет (`backup: none`) и это не мешает — он воспроизводим из репозитория. Нужны `EMERGENCY_*` в корневом `.env`. Хороший первый заход именно потому, что ошибиться почти негде — тем не менее, реальность разошлась с планом в двух местах, оба задокументированы там, где их искать: - **`playbooks/pve-emergency-bot.yml` не содержит конфигурационной части.** У этого сервиса она в отдельном `playbooks/emergency-access.yml`, причём два из четырёх play там таргетят `mini-pc` и `ru-vps`, а не сам контейнер (доверие reverse-SSH через `authorized_key` с `exclusive: true` — полная замена ключа, не добавление). Шаг 4.3.9 в его буквальном виде (`--limit -new` на существующем плейбуке) для этого сервиса не работает: `hosts:` там — жёсткие имена, не группа, и полный прогон против `mini-pc`/`ru-vps` до cutover преждевременно переключил бы доверие. Кроме того, `emergency_bot.py.j2` использует Telegram `getUpdates` (не webhook) — запуск второй копии с тем же `EMERGENCY_BOT_TOKEN`, что и боевая, ловит 409 conflict. Решение для шага 4.3 при следующем сервисе с похожей архитектурой: деплоить только play(-и), таргетящие сам новый контейнер, через одноразовый playbook с `hosts: -new` и заведомо нерабочим, но валидным по формату токеном/credential — так проверяется факт деплоя (юнит стартует, зависимости стоят, egress работает) без конфликта с боевым инстансом. Полный прогон с реальными credentials — уже после cutover, когда исходный playbook с жёстким `hosts: ` сам начинает резолвиться в новый контейнер (адрес не менялся, значит `--limit` не нужен вообще). - **`cmode` — провайдер отслеживает его через блок `console`, но по умолчанию блок не объявлен.** Подробности и правильная декларация — `tofu/README.md` и шаг 4.2 выше. **2. `docker-test` (145).** Тоже без данных. Проверяет проброс `/dev/fuse` через `features.fuse` на реальном сервисе (не через `device_passthrough` — см. уточнение в шаге 4.2). **3. `gitea` (141 -> 153).** Главный приз и самый содержательный шаг. - Было: `mp0: /opt/data/gitea,mp=/opt/gitea/data` — bind mount каталога ноды. **Стало и подтверждено на живом контейнере 2026-09-02:** `mp0: data:153/vm-153-disk-1.raw,mp=/opt/gitea/data,size=32G` — независимый volume. Внутри виден как отдельная ФС `/dev/loop10`, ext4, 32 ГиБ. Ради этого миграция и затевалась. - Данные: `/opt/data/gitea` на cloud-pc (2.3 ГБ) → внутрь нового контейнера. Каталоги `git/` (репозитории), `gitea/` (в т.ч. SQLite `gitea/gitea.db`, ~37 МиБ) и `ssh/`. На ноде всё принадлежит `101000:101000` — это host-side отображение uid 1000 внутри unprivileged LXC. Последовательность составлена по итогам разведки, но НЕ исполнялась: ssh gitea 'systemctl stop gitea' # OLD, адрес ещё боевой ssh cloud-pc "sudo sqlite3 /opt/data/gitea/gitea/gitea.db \ 'PRAGMA integrity_check;'" # ожидается ok # tar по SSH: rsync не годится, его нет на свежем контейнере, # а tar есть в любом Debian ssh cloud-pc 'sudo tar -C /opt/data/gitea -cf - .' \ | ssh gitea-new 'tar -C /opt/gitea/data -xf -' # volume создан с нуля, поэтому владельца выставить явно ИЗНУТРИ # контейнера — так результат не зависит от offset idmap ssh gitea-new 'chown -R 1000:1000 /opt/gitea/data' ssh gitea-new "sqlite3 /opt/gitea/data/gitea/gitea.db 'PRAGMA integrity_check;'" ssh gitea-new 'systemctl start gitea' Копирование внутри одного узла упирается в скорость диска, не сети: порядка 1-3 минут на 2.3 ГБ. Общее окно простоя `git.ada-dev.ru` — 5-10 минут. - Offsite restic-профиль `gitea` выполняется **на cloud-pc**, а не внутри LXC, и указывает на `/opt/data/gitea`. После переезда на volume этот путь исчезнет — профиль в `playbooks/offsite-restic-yadisk.yml` придётся переписать на новый источник. **Не забыть**, иначе offsite-бэкап Gitea тихо перестанет работать. - ~~`roles/gyro` пинит SSH host key gitea — обновить.~~ НЕВЕРНО, снято 2026-09-02: там `state: absent` (удаление устаревшей записи), а пинится github.com. Подробности — в шаге 4.5.19. - Публичный домен `git.ada-dev.ru` — после cutover `make reverse-proxy`. **4. `vaultwarden` (140 -> 154).** SQLite `/opt/vaultwarden/data/db.sqlite3`, публичный домен `pass.ada-dev.ru`. Разведка 2026-09-02: каталог данных 2.9 МБ; `db.sqlite3` с живыми `-wal`/`-shm`, `icon_cache`, `rsa_key.pem`, `config.json`. Каталогов `attachments/` и `sends/` нет, эти возможности не используются. **`config.json` содержит ADMIN_TOKEN и SMTP**, Ansible ими не управляет; они переедут вместе с каталогом. Это ожидаемо, но файл не должен попасть в репозиторий. Offsite-профиль restic здесь выполняется ВНУТРИ контейнера (`hosts: vaultwarden`, `offsite_source_path: /opt/vaultwarden/data`), а не на ноде, и адрес при переезде не меняется, поэтому **правки профиля не требуется** в отличие от gitea. Оба контейнера на mini-pc, поэтому копирование идёт через `pct exec` без промежуточного файла. Последовательность составлена по итогам разведки, но НЕ исполнялась: ssh mini-pc 'sudo pgrep -x vzdump' # ожидается пусто ssh mini-pc 'sudo pct exec 140 -- systemctl stop vaultwarden' # .backup сливает WAL в один файл и заодно проверяет источник ssh mini-pc "sudo pct exec 140 -- sqlite3 -cmd 'PRAGMA busy_timeout=30000;' \ /opt/vaultwarden/data/db.sqlite3 \".backup '/opt/vaultwarden/data/db.sqlite3.migrate'\"" ssh mini-pc 'sudo pct exec 140 -- sqlite3 \ /opt/vaultwarden/data/db.sqlite3.migrate "PRAGMA integrity_check;"' ssh mini-pc 'sudo sh -c "pct exec 140 -- tar --exclude=./db.sqlite3 \ --exclude=./db.sqlite3-wal --exclude=./db.sqlite3-shm \ -cf - -C /opt/vaultwarden/data . | pct exec 154 -- tar -xf - -C /opt/vaultwarden/data"' ssh mini-pc 'sudo pct exec 154 -- mv /opt/vaultwarden/data/db.sqlite3.migrate \ /opt/vaultwarden/data/db.sqlite3' ssh mini-pc 'sudo pct exec 154 -- sqlite3 /opt/vaultwarden/data/db.sqlite3 \ "PRAGMA integrity_check;"' ssh mini-pc 'sudo pct exec 140 -- rm /opt/vaultwarden/data/db.sqlite3.migrate' ssh mini-pc 'sudo pct exec 154 -- systemctl restart vaultwarden' Объём крошечный, простой на данных порядка полуминуты: время съедают stop/restart и проверки, а не копирование. **5. `monitoring` (146 -> 155).** Замороженный Prometheus-стек в новый контейнер не разворачивать. **Данные Uptime Kuma лежат НЕ в `/opt/monitoring`**, как утверждалось раньше. Разведка 2026-09-02: это два независимых каталога верхнего уровня. | Путь | Размер | Что это | |---|---|---| | `/opt/uptime-kuma` | 27 МБ | активный сервис, переносить целиком | | `/opt/uptime-kuma/data/kuma.db` | 25.6 МиБ | мониторы и уведомления | | `/opt/monitoring` | 1.7 ГБ | замороженный стек, почти всё — TSDB Prometheus | Переносить нужно только `/opt/uptime-kuma`. TSDB замороженного стека переносить незачем: читать его в новом контейнере будет нечему. Конфигурация нового контейнера — `playbooks/uptime-kuma.yml`. У `pve-monitoring.yml` конфигурационной части нет вообще, только создание. `playbooks/monitoring.yml` не запускать. Последовательность составлена по итогам разведки, но НЕ исполнялась: ssh cloud-pc 'sudo pct exec 146 -- sqlite3 /opt/uptime-kuma/data/kuma.db \ "PRAGMA wal_checkpoint(TRUNCATE);"' ssh cloud-pc 'sudo pct exec 146 -- systemctl stop uptime-kuma' ssh cloud-pc 'sudo pct exec 146 -- sqlite3 /opt/uptime-kuma/data/kuma.db \ "PRAGMA integrity_check;"' ssh cloud-pc 'sudo sh -c "pct exec 146 -- tar -C /opt -cpf - uptime-kuma \ | pct exec 155 -- tar -C /opt -xpf -"' ssh cloud-pc 'sudo pct exec 155 -- sqlite3 /opt/uptime-kuma/data/kuma.db \ "PRAGMA integrity_check;"' ssh cloud-pc 'sudo pct exec 155 -- systemctl start uptime-kuma' **Вторую копию нельзя поднимать на боевых мониторах до остановки первой:** Uptime Kuma активно пробит сервисы и шлёт уведомления, две копии дадут дубликаты алертов. Уменьшение контейнера — отдельное решение, не часть переезда. Цифры для него, снятые 2026-09-02: боевой CT 146 занимает 173 МБ RAM из 4096 и 4.7 ГБ из 24; новый CT 155 без Prometheus-стека — 184 МБ RAM и 1.8 ГБ диска. В контейнере нет `curl` — для ручных HTTP-проверок использовать `wget`. **6. `gyro` (150 -> 156).** CUTOVER COMPLETED 2026-09-02. CT 156 живёт на неизменном production IP `192.168.1.35`, provisioned by Tofu. CT 150 остановлен и удерживается как rollback минимум на неделю; его PBS chain не удалялся. Файл фаервола `156.fw` уже копия `150.fw`, cluster firewall по- прежнему disabled. Timer `gyro.timer` active, last oneshot succeeded. Данных для переноса не было, и TMPIP-specific bind/re-run не требовались. **7. `grimmory` (149 -> 157).** Самый болезненный, и единственный, где конфигурационный play пришлось чинить. **Найденный баг и его исправление (2026-09-02).** В конфигурационном play боевой адрес был зашит в трёх местах, из-за чего play невозможно было прогнать против любого другого контейнера: - `ports: "192.168.1.34:6060:6060"` в compose — Docker не биндит чужой адрес и роняет `grimmory.service` ещё до старта приложения; - правила `GRIMMORY-FILTER` (`--ctorigdst 192.168.1.34`) — фильтровали бы не тот адрес; - URL задачи `Wait for Grimmory health endpoint`. **Это было опаснее всего:** задача выполняется на самом целевом хосте, поэтому даже после починки биндинга она уходила бы по сети в БОЕВОЙ grimmory, получала 200 и давала ложно-положительный результат, ничего не проверив в новом контейнере. Исправлено через `grimmory_bind_ip: "{{ expected_lan_ip }}"` — ту же идиому, что использует `roles/uptime_kuma`. На боевом хосте переменная равна 192.168.1.34, поэтому рендер не изменился: `--check --diff` с `--limit grimmory` даёт `changed=0`, включая обе задачи, которые пишут адрес в файлы. **Следствие для шага 4.5, которого в плане не было.** Grimmory — единственный из шести сервисов пакета, кто биндится на конкретный адрес (`ss -ltn` на новых контейнерах: gitea `0.0.0.0:3000`, vaultwarden `0.0.0.0:80`, uptime-kuma `*:3001`, grimmory `192.168.1.16:6060`). После смены адреса на боевой compose всё ещё будет содержать TMPIP, и сервис не поднимется. Плейбук ОБЯЗАН быть прогнан заново сразу после cutover — см. шаг 4.5.16. **Состояние после конфигурации на TMPIP (проверено):** оба юнита active, оба контейнера healthy, `health=200` на 192.168.1.16, прогон идемпотентен (`changed=0`). **Образы совпали с боевым по digest** — `grimmory/grimmory:v3.2.4@sha256:dfa7afdf…` и `lscr.io/linuxserver/mariadb:11.4.8@sha256:91de7f70…`. Это требование `legacy-warning.md`: code-only downgrade после Flyway-миграции запрещён. **Flyway: схемы сошлись.** На боевом и на новом контейнере одинаково — `MAX(version)=144`, 142 миграции, все успешны. То есть тот же образ на пустой базе приходит ровно к боевой версии схемы, и restore дампа новых миграций не вызовет. Холодный старт с нуля занимает около 5 минут — health-check ретраится, это нормально. **Перенос данных.** `/opt/grimmory` — 311 МБ: `books/` 139 МБ (библиотека), `data/` 5.6 МБ (обложки), `mariadb/` 167 МБ. Файлы MariaDB копировать НЕЛЬЗЯ, нужен dump/restore. Рабочий рецепт дампа уже есть в `tasks/offsite-restic-profile.yml` (`mariadb-dump --single-transaction --routines --events`). Последовательность составлена по итогам разведки, но НЕ исполнялась: # приложение стоп, MariaDB оставить живой ssh grimmory 'cd /opt/grimmory && docker compose stop grimmory' ssh grimmory 'set -a; . /opt/grimmory/.env; set +a; \ docker exec -e MYSQL_PWD="$DB_PASSWORD" grimmory-mariadb mariadb-dump \ --user=grimmory --single-transaction --routines --events \ --databases grimmory > /opt/grimmory/backup-staging/grimmory.sql' # библиотека и обложки ssh cloud-pc 'sudo sh -c "pct exec 149 -- tar -C /opt/grimmory -cpf - books data \ | pct exec 157 -- tar -C /opt/grimmory -xpf -"' # дамп на новый и restore ssh cloud-pc 'sudo sh -c "pct exec 149 -- cat /opt/grimmory/backup-staging/grimmory.sql \ | pct exec 157 -- tee /opt/grimmory/backup-staging/grimmory.sql >/dev/null"' ssh grimmory-new 'cd /opt/grimmory && docker compose stop grimmory' ssh grimmory-new 'set -a; . /opt/grimmory/.env; set +a; \ docker exec -i -e MYSQL_PWD="$DB_PASSWORD" grimmory-mariadb mariadb \ --user=grimmory grimmory < /opt/grimmory/backup-staging/grimmory.sql' ssh grimmory-new 'systemctl restart grimmory' **Дамп не содержит `--add-drop-database`.** На новом контейнере схема уже создана Flyway с нуля, поэтому перед restore её нужно либо очистить, либо добавить эту опцию в дамп. Отдельно стоит знать, что restore-путь в этом репозитории НИКОГДА не проверялся: `edge-cases.md` отмечает, что аудит проверяет непустоту дампа, но не импортирует его. **Эталон для сверки после restore** (снят с боевого 2026-09-02): `book 31`, `author 30`, `book_file 40`, `book_metadata 31`, `category 87`, `reading_sessions 68`, `shelf 3`, `library 1`, `users 1`, `opds_user_v2 2`, `koreader_user 1`, `flyway_schema_history 142`. **После cutover — проверка OPDS на живой читалке, а не только curl'ом.** Быстрая проверка заголовков: curl -sS -D- -o /dev/null https://books.ada-dev.ru/api/v1/opds # atom+xml, без Content-Encoding curl -sS -D- -o /dev/null https://books.ada-dev.ru/api/v1/opds/search.opds curl -sS https://books.ada-dev.ru/api/v1/healthcheck Затем в KOReader: добавить каталог, увидеть список полок, скачать книгу целиком, проверить синхронизацию прогресса. **8. `adguard` (144 → 158).** ПЕРЕЕХАЛ 2026-09-03. OLD 144 остановлен (откат ≥ неделя, до ~2026-09-10). Данные `/opt/adguard/{conf,work}` (~313 МБ, `conf/AdGuardHome.yaml` несёт хэш пароля, DNS rewrites, upstream, клиентов) перенесены целиком через `pct exec … tar` (оба контейнера на mini-pc). - **Свежий AdGuard на пустом `conf/` не проходит health-гейт плейбука.** Он уходит в setup-wizard: порт 3000 отдаёт 302, порт 80 — connection reset, а `pve-adguard.yml` ждёт `http://127.0.0.1/` `[200,302]` и падает после 24 ретраев. Поэтому для adguard данные пред-заливаются ДО шага 4.3 (config play), а не после. После пред-заливки прогон идемпотентен (`ok=13 changed=0`), фильтрация подтверждена: `doubleclick.net → 0.0.0.0`. - Ноды используют DNS роутера (192.168.1.1), контейнеры — 1.1.1.1 (из `pct config` `nameserver`). Провал .28 задел только DHCP-клиентов LAN и рабочую станцию (у неё fallback на роутер). Окно ~2 мин, ночью. Роутер не трогали. - Сокеты wildcard (`0.0.0.0`), повторный прогон после смены адреса не нужен. **9. `mihomo` (143 → 159).** ПЕРЕЕХАЛ 2026-09-03. OLD 143 остановлен (откат ≥ неделя). Первый боевой сервис с **двумя механизмами проброса устройств**: `/dev/fuse` через `features.fuse`, `/dev/net/tun` через блок `device_passthrough` (`dev0: path=/dev/net/tun,mode=0660`) — оба под root@pam, проверены здесь на живом сервисе (до этого `device_passthrough` был только на пилоте). - Данные `/opt/mihomo` (~80 КБ): `config/config.yaml`, `config/cache.db`, `config/providers/main.yaml`. `config.yaml` содержит URL подписки прокси- провайдера — секрет, копируется `pct exec`, в репозиторий не попадает. Задача «Install default mihomo config if missing» идёт с `force: false`, перенесённый конфиг не перетирается. - Все сокеты wildcard (`0.0.0.0`) — повторный прогон после смены адреса не нужен. Прокси реально проверен: `curl -x .27:7890 …/generate_204 → 204`. - `ru-vps-mihomo-harden.yml` повторного прогона НЕ требует: он таргетит `hosts: ru-vps`, работает со скриптом `/usr/local/sbin/ru-vps-mihomo-harden` на ru-vps, адрес mihomo нигде в нём не зашит, и он за `CONFIRM`-гейтом. - Зависимые (`bash_config_proxy_*` у hermes-ai, `emergency_telegram_proxy`, `uptime_kuma_http_proxy`, правило gyro-фаервола `OUT ACCEPT 192.168.1.27:7890`, `prometheus.yml.j2`) все ссылаются на .27 — адрес сохранён, правок не нужно. **10. `ovpn-mini` (132 → 160).** ПЕРЕЕХАЛ 2026-09-03 из локальной сети. OLD 132 остановлен (откат ≥ неделя). `/dev/net/tun` через `device_passthrough`. `features` — только `nesting` (как в эталоне). Конфигурации в `pve-ovpn-mini.yml` нет — шлюз настраивает `openvpn-vps-mini.yml` (роль `openvpn_gateway`, группа `vpn_openvpn`). Единственные данные — `/etc/openvpn/homelab/static.key`, общий с ru-vps; LAN-адрес ovpn-mini ни в одном шаблоне роли не фигурирует (masquerade по `-o eth0`), поэтому туннель не зависит от смены адреса. - **Петля транспорта при cutover.** `-F ansible/ssh_config` до нод PVE идёт ProxyJump через ru-vps, а ru-vps достаёт LAN ЧЕРЕЗ туннель, который терминирует ovpn-mini. `make tofu-*` строит SSH-туннель к PVE API тем же путём. `pct stop 132` кладёт туннель → `make tofu-apply` больше не достаёт API. Разрыв: сначала поднять шлюз на НОВОМ контейнере ещё на TMPIP (одноразовый плейбук `hosts: ru-vps` slurp ключа + `hosts: -new` роль `openvpn_gateway`), туннель встаёт с TMPIP-адреса → `make tofu-apply` снова работает → сменить адрес на боевой → повторный `openvpn-vps-mini.yml` (идемпотентен, `changed=0` на обоих концах). Прямой доступ к нодам во время провала — `ssh -o ProxyJump=none @192.168.1.{5,10}` из LAN. - **`ssh_config`: у `ovpn-mini` теперь `ProxyJump none`** (в индивидуальном Host-блоке, побеждает по «первое значение опции»). Путь через ru-vps закольцовывался бы на его же туннель. Из LAN хост доступен напрямую; вне LAN управление — консоль ноды (`pct exec`) или заранее поднятый туннель. - **НЕ запускать OLD 132 как диагностику.** Его `pct config` всё ещё держит `ip=192.168.1.23/24`; параллельный старт с CT 160 даёт конфликт .23 и роняет туннель на 1-2 мин (проверено случайно 2026-09-03, восстановилось само). - `make openvpn-check` — 7/7 ok. Кворум кластера не затронут (qdevice ходит напрямую нода → ru-vps:5403, не через туннель). - Косметика: `polkit.service` на CT 160 в `failed` (`status=217/USER` — минимальный LXC-шаблон без нужного окружения polkit). На OpenVPN не влияет, не чинилось. --- ## 6. После завершения всех переездов - Удалить `roles/pve_lxc` — он станет не нужен. - Убрать из `services.yml` поле `provisioner` со значениями `pct_ssh`/`pve_lxc` либо заменить на `tofu`. - Обновить `docs/ai/architecture.md`: раздел «Provisioning Flow» описывает два пути через `pct` и API — оба исчезнут. - Обновить `legacy-warning.md`: пункт про два provisioner-пути потеряет смысл. - Расширить `validate.yml`: сейчас он сверяет hostname, IP, cores, memory, swap. После миграции имеет смысл добавить features, устройства и mount points — ровно те поля, которыми теперь управляет Tofu. - Решить, нужен ли `roles/lxc_docker_host` — он до сих пор ни к чему не подключён. --- ## 7. Известные проблемы вне миграции Не блокируют переезд, но про них надо знать. - **`hermes-ai` (147) недостижим по SSH.** Прозрачный прокси заворачивает в `hermes-tun` всё, что не пришло с `lo`, включая ответные пакеты входящих соединений (`ip rule` 9002). Ansible до хоста не достучится, управление — только через `pct exec`. Сервис заморожен, `make check` показывает его DOWN ожидаемо. Мигрировать не раньше, чем починится сеть. - **`resticprofile-check@profile-default` на ru-vps падает еженедельно**: профиль `default` без репозитория. Реальный `profile-services` работает. Шум в секции FAILED отчёта `make status`. - **Gitea runner** зарегистрирован и опрашивает Gitea, но метка `ru-vps` не совпадает с `runs-on: ubuntu-latest` в workflow, поэтому CI не выполняется. Раннер монтирует `/var/run/docker.sock` и `/opt/services` на запись — перед включением CI это стоит пересмотреть. - **`homelab_pve_egress_ip` динамический.** При смене домашнего адреса qdevice замолчит. Видно в секции CLUSTER QUORUM отчёта `make status`. - **`bootstrap-pve-api-token.yml` перезаписывает корневой `.env` целиком** — вместе с `PROXMOX_ROOT_PASSWORD`, `MONITORING_*` и `EMERGENCY_*`. У задачи есть `backup: true`, но восстанавливать придётся руками. - Устаревшие правила UFW на ru-vps (`3128`, `1080`, `993`, `7892`) — за переключателем `zt_cleanup_unrelated_stale_rules` в `playbooks/ru-vps-zerotier-decommission.yml`, по умолчанию выключен. --- ## 8. Откат **До шага 4.5.14** (пока старый контейнер работает): просто не переключаться. Удалить новый контейнер `make tofu-destroy CONFIRM=1` или точечно. **После переключения, но до `pct destroy`:** остановить новый, вернуть адрес старому не нужно — он не менялся, `pct start OLD` возвращает всё как было. Затем откатить `vmid` в `services.yml` и прогнать `make backup-jobs`, `make backup-audit`, `make validate`. **После `pct destroy OLD`:** только восстановление из PBS. Именно поэтому шаг 4.1.3 (свежий бэкап) обязателен, а шаг 4.7.25 (неделя ожидания) не сокращается.