Blue-green: a new container is created beside the old one, data is copied, the
IP is moved onto it, and the old container is kept stopped as rollback for at
least a week. Keeping the IP means only the VMID changes, and its consumers
(backup jobs, backup audit) already derive it from the registry.
Batch 1 (2026-09-02): emergency-bot 148->151, docker-test 145->152,
gitea 141->153, vaultwarden 140->154, monitoring 146->155, gyro 150->156,
grimmory 149->157.
Batch 2 (2026-09-03): adguard 144->158, mihomo 143->159, ovpn-mini 132->160.
All migratable LXC are now provisioner: tofu. hermes-ai (frozen) and pbs stay.
- tofu/services.tf + tofu/svc-*.tf: one resource per service, reproducing the
pct-config etalon. /dev/fuse -> features.fuse; /dev/net/tun ->
device_passthrough (first live use on mihomo and ovpn-mini); gitea bind mount
-> datastore volume (data finally reaches PBS); console { type = "shell" }
declared explicitly (provider tracks cmode there).
- services.yml: vmid + provisioner: tofu for every migrated service; features
strings and device notes updated to the tofu representation; also drops the
memoir-bot entry and adds homelab_reverse_proxy_image/_unit.
- pve-*.yml: configuration play target is `{{ pve_config_target | default(...) }}`
so it can run against <name>-new on a temp address (a bare --limit zeroes the
play instead of retargeting it). Container-creation plays are gated behind
`provisioner != 'tofu'` / `pve_provisioning_enabled` (meta: end_play), so a
stray run cannot pct start a stopped OLD VMID on a live IP. Override for
intentional legacy rollback: -e pve_<svc>_legacy_provisioning_enabled=true.
- ssh_config: drop memoir-bot; ovpn-mini gets ProxyJump none (a jump via ru-vps
would route through the very tunnel ovpn-mini terminates).
- gyro.yml / uptime-kuma.yml: same pve_config_target override.
- roles/uptime_kuma: only freeze homelab-monitoring when the unit actually
exists (a fresh blue-green container never had it).
- offsite-restic-yadisk.yml: the gitea restic profile now runs inside the LXC
(hosts: gitea), since the bind-mount host path is gone after the volume move;
lost+found excluded (unreadable in an unprivileged LXC, restic exit 3).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uoq5AVK8mkBgg83Mq6o5V
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 наоборот коммитится — он закрепляет версию
провайдера. Отдельного бэкенда нет; при переходе к боевому использованию
состояние нужно куда-то бэкапить.