Files
infra/tofu/README.md
T
DmitryandClaude Sonnet 5 646f2bbc8f feat(tofu): OpenTofu provisioning scaffold, auth modes and pilot notes
- providers.tf / variables.tf / versions.tf: bpg/proxmox ~> 0.84, endpoint and
  credentials from TF_VAR_* (set by the Makefile tofu-* targets from the
  repo-root .env). Two auth modes: root@pam by password (privileged: features
  beyond nesting, device passthrough, datastore mount points) or the
  ansible@pve token.
- README.md: pilot results on VMID 199 - what the token can and cannot do,
  why a root token still fails the literal `$authuser eq 'root@pam'` check,
  the cmode/console drift finding, and the chosen root@pam-by-password mode.
- pilot.tf.example: reference resource shape (features, device_passthrough,
  mount_point), not loaded (.example).
- .terraform.lock.hcl: pin the provider.

State has no backend yet; tofu/*.tfstate stays local and git-ignored.

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

10 KiB
Raw Blame History

OpenTofu — пилот provisioning LXC

Экспериментальный каталог. Ни один боевой контейнер здесь не описан: цель — выяснить, что провайдер bpg/proxmox реально может в этой инфраструктуре, прежде чем принимать решение о переезде.

Активных ресурсов сейчас нет: пилотный контейнер снесён, а его описание лежит в pilot.tf.example — расширение не даёт Tofu его загрузить. Это рабочий образец формы ресурса для реальных сервисов, см. ../docs/ai/migration-tofu.md.

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 <vmid> --features nesting=1,keyctl=1 по SSH под root, а /dev/fuse и /dev/net/tun пробрасываются правкой /etc/pve/lxc/<vmid>.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 <vmid> --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 `<user>@<realm>!<tokenname>` 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 <vmid> --features nesting=1,keyctl=1 по SSH больше не нужен — при переезде сервиса его можно удалять из соответствующего pve-*.yml;
  • правка /etc/pve/lxc/<vmid>.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 наоборот коммитится — он закрепляет версию провайдера. Отдельного бэкенда нет; при переходе к боевому использованию состояние нужно куда-то бэкапить.