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

173 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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` наоборот коммитится — он закрепляет версию
провайдера. Отдельного бэкенда нет; при переходе к боевому использованию
состояние нужно куда-то бэкапить.