# OpenTofu — пилот provisioning LXC Экспериментальный каталог. Ни один боевой контейнер здесь не описан: цель — выяснить, что провайдер `bpg/proxmox` реально может в этой инфраструктуре, прежде чем принимать решение о переезде. Активных ресурсов сейчас нет: пилотный контейнер снесён, а его описание лежит в `pilot.tf.example` — расширение не даёт Tofu его загрузить. Это рабочий образец формы ресурса для реальных сервисов, см. [`../docs/ai/migration-tofu.md`](../docs/ai/migration-tofu.md). ```bash make -C ansible tofu-init make -C ansible tofu-plan make -C ansible tofu-apply CONFIRM=1 make -C ansible tofu-destroy CONFIRM=1 ``` ## Результат пилота (2026-09-02, provider 0.84, PVE-кластер homelab) Всё проверено на живом кластере контейнером VMID 199 `tofu-pilot`. ### Что API-токен МОЖЕТ | Возможность | Проверено | |---|---| | Создание LXC: vmid, node, hostname, cores, memory, swap, unprivileged | да | | Сеть: bridge, статический IP, шлюз, firewall | да | | rootfs на datastore | да | | **Mount point как volume на datastore** | да — `mp0: data:199/vm-199-disk-1.raw,mp=/opt/pilot-data,size=4G` | | `features { nesting }` | да | | onboot, startup order, tags | да | | Идемпотентность (`plan` -> `No changes`) | да | Строка про mount point — ответ на вопрос про `mp0` у gitea. Bind mount каталога хоста (`mp0: /opt/data/gitea,mp=...`) токеном недоступен, а **volume на datastore — доступен**. То есть при пересоздании gitea хранилище можно перевести на volume и снять зависимость от root@pam. ### Чего API-токен НЕ МОЖЕТ Proxmox отвечает HTTP 403: ``` configuring device passthrough is only allowed for root@pam changing feature flags (except nesting) is only allowed for root@pam ``` То есть `keyctl`, `fuse`, `mount` и `device_passthrough` (`dev0:`) требуют root@pam. Это ограничение **Proxmox, а не Tofu**: Ansible упирается в ровно ту же стену, поэтому в `pve-monitoring.yml`, `pve-grimmory.yml` и `pve-hermes-ai.yml` есть отдельный шаг `pct set --features nesting=1,keyctl=1` по SSH под root, а `/dev/fuse` и `/dev/net/tun` пробрасываются правкой `/etc/pve/lxc/.conf`. Вывод: по этому пункту Tofu не хуже нынешнего состояния — ему нужен такой же дошаг. ### Ловушка гибридной схемы Если Tofu создаёт контейнер, а Ansible потом добавляет `keyctl` через `pct set`, Tofu видит это как дрейф: `plan` даёт `1 to change` и следующий `apply` откатил бы features обратно, лишив контейнер возможности запускать Docker. Проверено на пилоте. Обход — `lifecycle { ignore_changes = [features] }`, он в `pilot.tf`. После него `plan` снова даёт `No changes`, а `keyctl=1` на узле сохраняется. Плата: features перестают управляться декларативно. ### `cmode` — не в списке атрибутов ресурса, но провайдер его отслеживает через `console` Обнаружено 2026-09-02 при переезде `emergency-bot` (сервис №1), исправлено в тот же день. `roles/pve_lxc` всем создаваемым контейнерам ставит `cmode: shell` (`pct console` заходит сразу в root shell, а не в getty-логин) — `pve_lxc_cmode` в `roles/pve_lxc/defaults/main.yml`. У ресурса `proxmox_virtual_environment_container` провайдера `bpg/proxmox` нет top-level атрибута `cmode` — он скрыт в блоке `console { type = ... }`, который в plan/apply при создании не показывается, если не задан явно. Первая ошибка: контейнер создан без блока `console`, получил дефолт Proxmox (`cmode: tty`), исправлено одноразовым `pct set --cmode shell` по SSH. Это была ошибка: следующий `tofu-plan` (уже на шаге 4.5 этого же сервиса) показал `console { type = "shell" -> null }` — провайдер ЗАМЕТИЛ ручную правку через refresh state и на следующем apply откатил бы её обратно на `tty`. То есть `cmode` ведёт себя ровно как `keyctl` в гибридной схеме ниже: правится вручную — ловит drift. Правильное решение — объявить блок в ресурсе явно: console { type = "shell" } После этого `tofu-plan` не показывает `console` как diff, ручная правка не нужна. Добавлять этот блок в описание любого сервиса на шаге 4.2 сразу же, не как отдельный ручной пост-шаг. ### Рутовый токен не помогает — проверено по исходникам Проверка в `/usr/share/perl5/PVE/LXC.pm:1658` буквальная: return 1 if $authuser eq 'root@pam'; А `$authuser` при токенной аутентификации — это полный token-ID. Из `/usr/share/perl5/PVE/HTTPServer.pm:86`: # the token-ID `@!` is the user for token based authentication $username = eval { PVE::AccessControl::verify_token($api_token); }; `verify_token` возвращает `$tokenid`, то есть `user@realm!tokenname`. Строка `root@pam!mytoken` не равна `root@pam`, поэтому токен root проверку НЕ проходит. Следствия: - выдать токену больше прав бесполезно: это не проверка привилегий, а сравнение имени пользователя, ACL на неё не влияют; - единственный вариант «сделать всё через Tofu» — аутентификация root@pam по паролю. В realm `pam` это системный пароль root узла Proxmox: его нельзя ограничить по scope и нельзя отозвать отдельно от смены пароля root. ### Решение: root@pam по паролю (принято и проверено 2026-09-02) Выбран привилегированный режим. Проверено на пилоте — всё привилегированное выставляется декларативно, без единого шага Ansible: dev0: deny-write=0,path=/dev/net/tun,uid=0,gid=0,mode=0660 features: fuse=1,keyctl=1,nesting=1,mknod=0 mp0: data:199/vm-199-disk-1.raw,mp=/opt/pilot-data,size=4G Повторный `plan` даёт `No changes`. Следствия для миграции сервисов: - дошаг `pct set --features nesting=1,keyctl=1` по SSH больше не нужен — при переезде сервиса его можно удалять из соответствующего `pve-*.yml`; - правка `/etc/pve/lxc/.conf` через `lineinfile` (девять контейнеров) заменяется блоком `device_passthrough`; - `lifecycle { ignore_changes = [features] }` не нужен: он был обходом для гибридной схемы, от которой отказались; - apply одной фазой, без чередования Tofu и Ansible. Отвергнутая альтернатива — гибрид: Tofu создаёт то, что доступно токену, Ansible доводит привилегированное по SSH через существующий sudo. Не требует новых секретов, но оставляет декларативность частичной и apply двухфазным. ## Аутентификация Способ выбирает Makefile по корневому `.env`: | Условие | Режим | Что доступно | |---|---|---| | `PROXMOX_ROOT_PASSWORD` задан и не `replace-me` | root@pam по паролю | всё, включая features и device passthrough | | иначе | токен `ansible@pve` | всё кроме features (кроме nesting), device passthrough и bind mount | Выбранный режим печатается в stderr перед запуском, чтобы из вывода было видно, какими правами шёл apply. В `pilot.tf` привилегированные поля включаются через `local.privileged`, поэтому конфигурация валидна в обоих режимах. ## Сеть Tofu ходит в Proxmox API напрямую по HTTPS и **не умеет ProxyJump** из `ssh_config`. Вне LAN узлы недоступны, поэтому цели `tofu-*` в Makefile сами поднимают SSH-туннель через `ru-vps` на время команды и направляют провайдер в `127.0.0.1`. Отсюда же `insecure = true`: сертификат выписан на узел, а обращение идёт к localhost. ## State `tofu/*.tfstate*` в `.gitignore`: состояние содержит фактическую конфигурацию гостей. `.terraform.lock.hcl` наоборот коммитится — он закрепляет версию провайдера. Отдельного бэкенда нет; при переходе к боевому использованию состояние нужно куда-то бэкапить.