diff --git a/tofu/.terraform.lock.hcl b/tofu/.terraform.lock.hcl new file mode 100644 index 0000000..74903c0 --- /dev/null +++ b/tofu/.terraform.lock.hcl @@ -0,0 +1,36 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/bpg/proxmox" { + version = "0.111.1" + constraints = "~> 0.84" + hashes = [ + "h1:0qABXcf7ulwKRhKGoK4LYZ7ObyoFkPxHmCrL6jRhMgE=", + "h1:0xM4HmXdu0hLrauA8ugcxjZvYHA+PuRybUxrXZEAdRI=", + "h1:ML2D3UUZTM99yrll/EBXj7wBYMb8xmQgomqFNybEoxY=", + "h1:PFGpaC7xcdC8w9jpxZPLJ/FFbVK5hd/CArUY+oXXTu8=", + "h1:XoYUWDsGWvxSlir/sYiGzx1MkcPVUtWIgc6OWEdf/C8=", + "h1:Zn7LyCWL/Xn0yCHiigXFYEp3MIv0MOVzUfL657QOplY=", + "h1:dIGBUSoC89e/PbpBEjbZ/YOzAkzrG1kF6EYcqk+8IqE=", + "h1:eERpiB94PyhN2SqE9E1+6sgpk0Kn6qjy555R2cjfpug=", + "h1:ejD4OSOL98W/SA4jLDvxEwCW8NSdVGKLyKmgfAUROK8=", + "h1:iTQv4FVFhMVl2juw6lgrVTpGFrdmdNPj+NFY2Dms0SE=", + "h1:jcqEv/zW+heFIPq5xwXxgS9EuBmbjIM1MriwQjx75WE=", + "h1:xF+AQJqpRf30WbrfHgSeuLqDBptWhTIdVoMyv7j6yVo=", + "h1:xWV1Y6ItiFXNCJO9OtyINMx8cqT5XiTOK2Rne7Jyu3w=", + "zh:18fb7c31a08dde6bffa1a4d4a211e604d6d17eec7092fd59331b3db3c6f3742c", + "zh:1cd60761538289d4dd2a1086b3ae62a7b0bdd4b1a2f824e9a44e243413168dba", + "zh:2eb76f6fc8299b6820ff678c8252332cc3366e226b5ae2e61748fd2449c1ed92", + "zh:45e6f7ebd0bf48911d37060359a4f359b5743b3092e985295733990e406d0416", + "zh:4aa8ba912eae37975d2e983394d173e595ca34fc76b5bf220b37d0e99d76e98c", + "zh:58e0789923103a77d502a0a9fc3eb920625e8eb935ec2d4ac0d006aebd1d186c", + "zh:6df8aa85fb8865915537e946c19b02538ad188018a629759c213c6f03730f642", + "zh:6ed47bc00d0913a1d0880618fa1376115e9edab6b4a658c081061a7f0e4ca360", + "zh:c5b10ff4f33df7e4c29e8f1127d49845b561b37b57517e844fb0954d7923d65e", + "zh:d016510e14b738499f0db9d9b3aafe82fc6877fb4ab4e9f831fb68a8d70a1385", + "zh:d941f394069bbf24351b363da1c64383f487067aaee0a84f9b96476d4912e212", + "zh:ddf271dbc2632ae8ffa8de3972f243ee47d260cb2ac90aa784f2746d98e21a0f", + "zh:ed0caa3501c42f611b7e9622c9b1df69fd85dc25a3cd88d3076381829688cd62", + "zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597", + ] +} diff --git a/tofu/README.md b/tofu/README.md new file mode 100644 index 0000000..9d008b6 --- /dev/null +++ b/tofu/README.md @@ -0,0 +1,172 @@ +# 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` наоборот коммитится — он закрепляет версию +провайдера. Отдельного бэкенда нет; при переходе к боевому использованию +состояние нужно куда-то бэкапить. diff --git a/tofu/pilot.tf.example b/tofu/pilot.tf.example new file mode 100644 index 0000000..84568dc --- /dev/null +++ b/tofu/pilot.tf.example @@ -0,0 +1,134 @@ +# ============================================================================ +# ОБРАЗЕЦ, НЕ ЗАГРУЖАЕТСЯ. Расширение .example выбрано намеренно: сам пилот +# (VMID 199) снесён 2026-09-02, и будь этот файл активным, каждый `tofu-plan` +# предлагал бы создать его заново. +# +# Держим как рабочий пример формы ресурса: по нему описываются реальные +# сервисы при переезде. См. docs/ai/migration-tofu.md, шаг 4.2. +# ============================================================================ +# +# Пилот: одноразовый LXC, созданный OpenTofu. +# ============================================================================ +# +# ЗАЧЕМ +# Проверить на живом кластере ровно те места, из-за которых переезд на Tofu +# выглядел рискованным, не трогая ни один боевой контейнер: +# +# 1. Проброс /dev/fuse. В pve-*.yml он сделан правкой /etc/pve/lxc/.conf +# (lxc.cgroup2.devices.allow + lxc.mount.entry), потому что Proxmox API +# сырые lxc.* ключи не принимает. У провайдера для этого есть +# features.fuse — штатный флаг PVE, а не обход. +# 2. Проброс /dev/net/tun — блок device_passthrough (dev0: в PVE 8.2+). +# 3. Mount point на storage вместо bind mount каталога хоста. Именно bind +# mount у gitea (mp0: /opt/data/gitea) требует прав root@pam и не +# создаётся API-токеном; volume на datastore — создаётся. +# +# Контейнер намеренно не входит в inventory и не автозапускается. +# Снести после проверки: make tofu-destroy CONFIRM=1 +# +# VMID 199 и 192.168.1.39 выбраны свободными на 2026-09-02 и лежат вне +# диапазона сервисов, но внутри маршрутов ru-vps (192.168.1.5-40). + +locals { + # Привилегированный режим = аутентификация root@pam по паролю. Makefile + # выбирает его, когда в корневом .env задан PROXMOX_ROOT_PASSWORD. + privileged = var.pve_password != "" +} + +resource "proxmox_virtual_environment_container" "pilot" { + node_name = "cloud-pc" + vm_id = 199 + unprivileged = true + start_on_boot = false + started = true + tags = ["tofu", "pilot"] + + initialization { + hostname = "tofu-pilot" + + ip_config { + ipv4 { + address = "192.168.1.39/24" + gateway = "192.168.1.1" + } + } + + dns { + servers = ["1.1.1.1"] + } + + user_account { + keys = [trimspace(file(pathexpand("~/.ssh/id_ed25519_homelab.pub")))] + } + } + + operating_system { + template_file_id = "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst" + type = "debian" + } + + cpu { + cores = 1 + } + + memory { + dedicated = 512 + swap = 512 + } + + disk { + datastore_id = "data" + size = 8 + } + + # Проверка №1 и №2: то, что в pve-*.yml делается правкой конфига на узле. + # Токену Proxmox разрешает только nesting; keyctl и fuse требуют root@pam. + # null означает «не задавать поле», а не «выключить». + features { + nesting = true + keyctl = local.privileged ? true : null + fuse = local.privileged ? true : null + } + + # Проброс устройства Proxmox разрешает только root@pam (403 для токена, + # проверено на пилоте 2026-09-02), поэтому блок появляется лишь в + # привилегированном режиме. + dynamic "device_passthrough" { + for_each = local.privileged ? [1] : [] + content { + path = "/dev/net/tun" + } + } + + # Проверка №3: volume на datastore, а не bind mount каталога хоста. + mount_point { + volume = "data" + size = "4G" + path = "/opt/pilot-data" + } + + network_interface { + name = "eth0" + bridge = "vmbr0" + firewall = true + } + + startup { + order = 999 + } + + # ignore_changes = [features] здесь НЕТ намеренно. Он нужен только в + # гибридной схеме, где features выставляет Ansible через `pct set`: иначе + # Tofu на следующем apply откатывает их и ломает Docker в контейнере + # (проверено на пилоте). В привилегированном режиме features описаны выше + # декларативно, и обход не требуется. +} + +output "pilot" { + description = "Что проверять на узле после apply" + value = { + vmid = proxmox_virtual_environment_container.pilot.vm_id + node = proxmox_virtual_environment_container.pilot.node_name + verify = "ssh cloud-pc sudo pct config ${proxmox_virtual_environment_container.pilot.vm_id}" + } +} diff --git a/tofu/providers.tf b/tofu/providers.tf new file mode 100644 index 0000000..c726ed5 --- /dev/null +++ b/tofu/providers.tf @@ -0,0 +1,11 @@ +provider "proxmox" { + endpoint = var.pve_endpoint + insecure = var.pve_insecure + + # null вместо пустой строки принципиален: провайдер выбирает способ + # аутентификации по тому, какие поля заданы, и пустая строка считалась бы + # заданным значением. + api_token = var.pve_api_token != "" ? var.pve_api_token : null + username = var.pve_username != "" ? var.pve_username : null + password = var.pve_password != "" ? var.pve_password : null +} diff --git a/tofu/variables.tf b/tofu/variables.tf new file mode 100644 index 0000000..87e98b8 --- /dev/null +++ b/tofu/variables.tf @@ -0,0 +1,42 @@ +# Значения приходят из корневого .env через Make (цели tofu-* в ansible/Makefile), +# который перекладывает PROXMOX_* в TF_VAR_*. Отдельного файла с секретами нет. + +variable "pve_endpoint" { + description = "URL Proxmox API. Цели tofu-* направляют его в локальный конец SSH-туннеля." + type = string +} + +# --- Аутентификация --------------------------------------------------------- +# Ровно один из двух способов, выбор делает Makefile: +# +# токен ansible@pve — обычный режим. Не может features кроме nesting, +# device passthrough и bind mount каталога хоста. +# root@pam + пароль — привилегированный режим. Проверка в Proxmox буквальная +# (`$authuser eq 'root@pam'`), поэтому токен, даже +# принадлежащий root, её не проходит — нужен именно пароль. + +variable "pve_api_token" { + description = "Токен в формате user@realm!tokenid=secret. Пустая строка — не использовать." + type = string + sensitive = true + default = "" +} + +variable "pve_username" { + description = "Пользователь для парольной аутентификации, обычно root@pam. Пустая строка — не использовать." + type = string + default = "" +} + +variable "pve_password" { + description = "Пароль root@pam. Пустая строка — не использовать." + type = string + sensitive = true + default = "" +} + +variable "pve_insecure" { + description = "Не проверять TLS-сертификат Proxmox" + type = bool + default = true +} diff --git a/tofu/versions.tf b/tofu/versions.tf new file mode 100644 index 0000000..921b434 --- /dev/null +++ b/tofu/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6" + + required_providers { + proxmox = { + source = "bpg/proxmox" + version = "~> 0.84" + } + } +}