Files
infra/docs/ai/migration-tofu.md
DmitryandClaude Sonnet 5 d2e1e6876a docs: AI project context (docs/ai) and repository documentation refresh
- docs/ai/: stable, repo-verified context - README, architecture, tech-stack,
  edge-cases, plan (confirmed active work only), migration-tofu (the blue-green
  OpenTofu migration runbook and per-service findings), legacy-warning, links.
- AGENTS.md: slimmed to a working contract that points at docs/ai instead of
  restating it; CLAUDE.md is an adapter that @-includes it.
- README.md, ansible/README.md, ansible/roles/README.md,
  roles/lxc_docker_host/README.md: bring wording in line with the current
  control plane (Makefile entry point, registry, tofu, memoir-bot gone).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
2026-09-03 07:06:10 +03:00

59 KiB
Raw Permalink Blame History

Переход 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/<vmid>.conf через lineinfile (девять контейнеров) заменяется декларацией в Tofu, но КАКОЙ именно — зависит от устройства: /dev/fuse — штатным флагом features { fuse = true }, /dev/net/tun — блоком device_passthrough. Оба варианта видны в выводе пилота выше: features: fuse=1,... и dev0: ...path=/dev/net/tun;
  • шаг pct set <vmid> --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. Предпосылки перед первым переездом

  • Вывод memoir-bot — live-шаги оператора выполнены 2026-09-02: deploy key SecondBrain отозван, монитор в Uptime Kuma снят, pct stop 142 выполнен (подтверждено pct list на mini-pc). Это была репетиция удаления старого контейнера. pct destroy 142 сознательно отложен — см. plan.md и общий инвариант о паузе перед удалением.
  • make validate — зелёный (проверено 2026-09-02, 12 сервисов, drift нет).
  • make backup-audit — прогнан 2026-09-02, все хосты ok, без ошибок.
  • tofu/terraform.tfstate — решено оставить только локальным на время переезда сервиса №1. Осознанный риск: state небольшой, при потере можно re-import всё созданное заново. Постоянное решение (например, offsite restic-профиль) — отдельная задача, не блокирует старт.
  • Свободное место проверено. На 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 <node> sudo pct config OLD — сохранить вывод. Это источник правды для того, что нужно воспроизвести.
  3. Запустить свежий бэкап: ssh <node> 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/<OLD>'. Подробности — 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

  1. Завести ресурс в 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 = "<datastore>", size = "...", path = "..." }. Данные переносятся на шаге 4.4, а не монтированием того же каталога: два контейнера, пишущие в один каталог, повредят данные.
    • console { type = "shell" } обязателен. roles/pve_lxc всем контейнерам ставит cmode: shell; провайдер отслеживает это через блок console, и без явного объявления следующий tofu-plan предложит откатить его на дефолт Proxmox tty — тот же класс drift, что и keyctl в гибридной схеме. Подробности и история находки — tofu/README.md.
  2. make tofu-plan — убедиться, что план ровно 1 to add.
  3. make tofu-apply CONFIRM=1.
  4. Сверить: ssh <node> sudo pct config NEW против эталона из шага 2. Отличаться должны только vmid и адрес (плюс явно выписанные Proxmox-дефолты вида cpulimit, protection, template, tty — эталон их просто не показывал, реальное поведение то же самое). cmode: shell должен совпасть с эталоном уже на этом шаге, если блок console объявлен в шаге 4 — правкой постфактум через pct set не чинить, это создаёт drift (см. шаг 4 выше и tofu/README.md).

4.3 Настройка

  1. Добавить временную запись в ansible/inventory/hosts.yml: <name>-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.
  2. Прогнать конфигурационную часть существующего плейбука против нового контейнера:

    ansible-playbook playbooks/pve-<name>.yml \
      -e pve_config_target=<name>-new --limit <name>-new
    

    Одного --limit <name>-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('<name>') }}"
    

    Он есть в 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/<OLD>.conf на боевом VMID.

    Перед каждым прогоном сверяться с --list-hosts. Ожидание: у play создания и стражей hosts (0), у конфигурационного — ровно hosts (1): <name>-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 он удалён.
  3. Проверить, что сервис поднялся на TMPIP (его health-эндпоинт или порт). У сервисов с данными новый контейнер на этом шаге работает на ПУСТОМ состоянии — данные приезжают только на шаге 4.4. Пустой Gitea, пустой Vaultwarden с экраном создания учётки, пустая Uptime Kuma, свежая схема Flyway у grimmory — это ожидаемый результат шага 4.3, а не поломка. Поднимать новую копию на боевых данных до остановки старой НЕЛЬЗЯ: две живые копии над одной SQLite или одной MariaDB повредят состояние.

4.4 Перенос данных

  1. Остановить сервис на СТАРОМ контейнере (systemctl stop <unit>), сам контейнер пока не трогать.
  2. Финальная синхронизация. Способ зависит от сервиса — см. раздел 5.
  3. Запустить сервис на новом, проверить на TMPIP.

4.5 Переключение

  1. ssh <node> sudo pct stop OLD.
  2. В Tofu поменять адрес нового контейнера с TMPIP на IP, make tofu-apply CONFIRM=1.
  3. Перезапустить контейнер, если адрес не подхватился на живую.
    • Если конфигурация сервиса содержит его собственный адрес — прогнать плейбук заново сразу после смены адреса. Из пакета 2026-09-02 это касается только grimmory (ports: <ip>:6060:6060 и правила GRIMMORY-FILTER): его compose всё ещё содержит TMPIP, и сервис не поднимется, пока файл не перегенерируют. Проверять просто: ss -ltn на новом контейнере — если сокет висит на конкретном адресе, а не на 0.0.0.0/*, повторный прогон обязателен. У gitea, vaultwarden и uptime-kuma сокеты wildcard, им это не нужно. Прогон уже без -e pve_config_target: адрес стал боевым, и имя хоста само резолвится в новый контейнер.
  4. Health-check на боевом IP.
  5. Если сервис публичный (proxy в реестре) — make reverse-proxy и проверить домен снаружи. При сохранённом IP апстрим не меняется, но прогон подтвердит.
  6. Обновить ~/.ssh/known_hosts — host key нового контейнера другой: ssh-keygen -R <IP> и ssh-keygen -R <name>. В ssh_config стоит StrictHostKeyChecking accept-new, который принимает НОВЫЕ ключи, но не изменившиеся, поэтому без чистки подключение будет отвергнуто.
    • Заодно закрыть ControlMaster: ssh -F ansible/ssh_config -O exit <host>. Мастер переживает cutover живым, но его туннель ведёт в уже остановленный контейнер; новые сессии мультиплексируются поверх мёртвого соединения и виснут с Connection timed out during banner exchange. Симптом выглядит как сетевая проблема, хотя контейнер полностью доступен с ноды. Разовый обход для диагностики — -o ControlPath=none. Найдено 2026-09-02 на docker-test.
    • У gitea SSH host-ключи git-over-ssh лежат в переносимых данных (<data>/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_urlgit@github.com:.... Gyro клонирует с GitHub, к gitea отношения не имеет. Никаких дополнительных действий после переезда gitea не нужно.

4.5a Offsite restic: профиль надо переустановить на новом контейнере

Шаг, которого в плане не было. Найден 2026-09-02 на vaultwarden.

Если offsite-профиль restic выполняется ВНУТРИ контейнера сервиса (а не на ноде), то на новом контейнере его нет: там нет ни restic, ни rclone, ни systemd-таймера. Бэкап тихо перестаёт выполняться, и make backup-audit этого не ловит — он ставит только свой таймер аудита.

Проверять так:

ssh <name> 'command -v restic rclone; systemctl list-timers --all | grep restic'

Чинить так:

make offsite-restic EXTRA="--limit <name>"

Затем обязательно проверить всю цепочку, а не только наличие таймера:

ssh <name> 'systemctl start homelab-restic-offsite-<name>.service'
ssh <name> 'journalctl -u homelab-restic-offsite-<name>.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 Реестр и потребители

  1. В ansible/inventory/group_vars/all/services.yml поменять vmid: OLD на vmid: NEW. Если менялся provisioner — поправить и его на tofu.
  2. Убрать временные записи <name>-new из hosts.yml и ssh_config.
  3. Прогнать зависящие цели: make backup-jobs (списки VMID выведутся заново), make backup-audit (перерендерит скрипт аудита с новым VMID).
  4. make validate — должен быть зелёный.
  5. make status — контейнер UP, юниты active.

4.7 Завершение

  1. Оставить OLD остановленным минимум неделю.
  2. Затем pct destroy OLD и решить судьбу его цепочки бэкапов в PBS.
  3. Удалить из playbooks/pve-<name>.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 <name>-new на существующем плейбуке) для этого сервиса не работает: hosts: там — жёсткие имена, не группа, и полный прогон против mini-pc/ru-vps до cutover преждевременно переключил бы доверие. Кроме того, emergency_bot.py.j2 использует Telegram getUpdates (не webhook) — запуск второй копии с тем же EMERGENCY_BOT_TOKEN, что и боевая, ловит 409 conflict. Решение для шага 4.3 при следующем сервисе с похожей архитектурой: деплоить только play(-и), таргетящие сам новый контейнер, через одноразовый playbook с hosts: <name>-new и заведомо нерабочим, но валидным по формату токеном/credential — так проверяется факт деплоя (юнит стартует, зависимости стоят, egress работает) без конфликта с боевым инстансом. Полный прогон с реальными credentials — уже после cutover, когда исходный playbook с жёстким hosts: <name> сам начинает резолвиться в новый контейнер (адрес не менялся, значит --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).

Образы совпали с боевым по digestgrimmory/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: <name>-new роль openvpn_gateway), туннель встаёт с TMPIP-адреса → make tofu-apply снова работает → сменить адрес на боевой → повторный openvpn-vps-mini.yml (идемпотентен, changed=0 на обоих концах). Прямой доступ к нодам во время провала — ssh -o ProxyJump=none <ansible-user>@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 (неделя ожидания) не сокращается.