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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
e0c53a1b1b
commit
646f2bbc8f
Generated
+36
@@ -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",
|
||||
]
|
||||
}
|
||||
+172
@@ -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 <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` наоборот коммитится — он закрепляет версию
|
||||
провайдера. Отдельного бэкенда нет; при переходе к боевому использованию
|
||||
состояние нужно куда-то бэкапить.
|
||||
@@ -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/<vmid>.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}"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
terraform {
|
||||
required_version = ">= 1.6"
|
||||
|
||||
required_providers {
|
||||
proxmox = {
|
||||
source = "bpg/proxmox"
|
||||
version = "~> 0.84"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user