Automatización: API REST, Terraform/OpenTofu, Ansible y Packer
Automatización: API REST, Terraform/OpenTofu, Ansible y Packer
Hasta aquí has construido el datacenter a mano. En el capítulo 5 creaste plantillas y las clonaste con qm clone, en el capítulo 11 montaste el cluster nodo a nodo y en el capítulo 13 definiste los jobs de backup desde la interfaz. Todo funciona, y todo depende de que alguien recuerde los clics exactos.
El problema llega la tercera vez que reconstruyes el mismo entorno: la plantilla se hizo con otra versión de la cloud image, la VM de staging tiene 4 GB y la de producción 8 porque alguien la tocó, y nadie sabe si el snippet de cloud-init que hay en local es el bueno. La interfaz web de Proxmox VE es excelente para operar y pésima para reproducir.
La buena noticia: la interfaz web no tiene ningún atajo privado. Es un cliente más de la misma API REST que vas a usar tú, y cada botón se traduce en una llamada a /api2/json/.... Al terminar tendrás un usuario automation@pve con rol de privilegios mínimos y tokens separados por herramienta, scripts que lanzan tareas y esperan su resultado, Terraform clonando plantillas de forma idempotente, playbooks con inventario dinámico y un pipeline de Packer que construye la plantilla desde cero. La referencia es Proxmox VE 9.2 sobre Debian 13 Trixie.
La API REST: estructura de URL, formatos y política de estabilidad
Toda la API vive en un único patrón, siempre HTTPS en el puerto 8006/tcp (Proxmox Backup Server usa el 8007): https://<host>:8006/api2/<formato>/<ruta>.
Hay cuatro formatos. json es el estándar para programar y el que usarás siempre; extjs devuelve el mismo JSON envuelto en un objeto con campo data y es el que consume la interfaz web; html da texto formateado, cómodo para depurar desde el navegador; y text da texto plano para depurar en consola. La ruta reproduce el árbol que ves en la interfaz: datacenter, nodos, guests, storages. El explorador oficial de endpoints está en https://pve.proxmox.com/pve-docs/api-viewer/. La política de estabilidad importa cuando escribes automatización que va a vivir años. Dentro de una misma versión major (de 9.0 a 9.4, por ejemplo) los endpoints se mantienen compatibles. Son cambios breaking eliminar endpoints, eliminar parámetros o cambiar los tipos de retorno; son cambios no breaking añadir endpoints, parámetros o propiedades. La consecuencia práctica: tu código no puede asumir que el JSON tiene exactamente las claves de ayer, puede tener más. Si parseas con un esquema estricto que rechaza campos desconocidos, un minor de PVE te romperá el pipeline sin haber incumplido nada.
Autenticación por ticket: cookie, CSRF y caducidad
Es el método de la interfaz web: se pide un ticket con usuario y contraseña y luego se navega con una cookie.
curl -k -d 'username=root@pam' --data-urlencode 'password=xxxxxxxxx' \
https://10.0.0.1:8006/api2/json/access/ticket
# -> data.ticket = "PVE:root@pam:4EEC61E2::rsKoApx..."
# -> data.CSRFPreventionToken = "4EEC61E2:lwk7od06fa1+DcPUwBTXCcndyAY"
# GET: basta la cookie
curl -k -b "PVEAuthCookie=PVE:root@pam:4EEC61E2::rsKoApx..." \
https://10.0.0.1:8006/api2/json/nodes
# POST, PUT y DELETE: cookie + cabecera CSRF
curl -k -XDELETE -H "CSRFPreventionToken: 4EEC61E2:lwk7od06fa1+DcPUwBTXCcndyAY" \
-b "PVEAuthCookie=PVE:root@pam:4EEC61E2::rsKoApx..." \
https://10.0.0.1:8006/api2/json/nodes/pve1/qemu/101
Tres detalles definen este método. Uno. El ticket caduca a las 2 horas: un cron que corra cada seis horas y guarde el ticket en un fichero fallará en silencio a partir del segundo intento. Dos. Se renueva enviando el ticket viejo como valor del parámetro password a /access/ticket, sin volver a mandar la contraseña real, pero alguien tiene que programar esa renovación. Tres. Las escrituras exigen la cabecera CSRFPreventionToken; si falta, el error parece de permisos y se confunde con un problema de ACL. El ticket tiene sentido cuando necesitas 2FA o un realm externo, o cuando emulas a un usuario humano. Para todo lo demás hay algo mejor.
Autenticación por API token: por qué es la buena para automatizar
Un API token es una credencial independiente colgada de un usuario, con identificador y secreto propios, que viaja en una sola cabecera con el formato PVEAPIToken=USER@REALM!TOKENID=UUID:
curl -k -H 'Authorization: PVEAPIToken=automation@pve!terraform=aaaaaaaa-bbbb-cccc-dddd-ef0123456789' \
https://10.0.0.1:8006/api2/json/nodes
La diferencia crucial está en la documentación oficial con estas palabras: “API tokens do not need CSRF values for POST, PUT or DELETE.” Sin ticket, sin cookie, sin CSRF y sin renovación: cada petición es autónoma.
flowchart TD
A["Cliente HTTP"] --> B{"Metodo de autenticacion"}
B -->|Ticket| C["POST /access/ticket"]
C --> D["Respuesta con ticket y CSRFPreventionToken"]
D --> E["GET usa solo la cookie PVEAuthCookie"]
D --> F["POST PUT DELETE usan cookie mas cabecera CSRF"]
D -.->|"caduca a las 2 horas"| C
B -->|"API token"| G["Cabecera Authorization con PVEAPIToken"]
G --> H["Cualquier metodo sin CSRF"]
H --> I["Stateless y sin caducidad de sesion"]
Dos límites que conviene fijar antes de diseñar nada. El secreto se muestra una sola vez, al crear el token, y no es recuperable. Y los tokens no sirven para la consola de VM ni para acceso al sistema: sirven para la API, lo que tiene consecuencias directas en Terraform que verás más abajo. Desde PVE 9.2 puedes rotar el secreto sin destruir el token, conservando sus ACL. El roadmap lo enuncia como “API token secret regeneration now possible in-place (preserving ACL entries)”.
# --privsep 1 es el DEFAULT: el token nace SIN permisos
pveum user token add automation@pve lectura --privsep 1
pveum acl modify /vms --token 'automation@pve!lectura' --role PVEAuditor
# --privsep 0: el token hereda TODOS los permisos del usuario
pveum user token add automation@pve terraform --privsep 0
pveum user token permissions automation@pve terraform --path /vms/101
pveum user token modify automation@pve terraform --regenerate 1 # rotación in situ, PVE 9.2
pveum user token add ci@pve deploy --expire 1798761600 # epoch UNIX
pveum user token remove automation@pve terraform
Con --privsep 1 los permisos efectivos son la intersección de los del usuario y los ACL asignados al token: si el usuario tiene PVEAuditor sobre /vms y el token PVEVMAdmin sobre /vms, el token solo puede auditar. Es el modo correcto para acotar un token dentro de un usuario más potente. Gotcha: con --privsep 1 y sin ninguna ACL sobre el token, el token autentica bien pero no puede hacer nada, y la API responde con errores de permisos. Es la causa número uno de “mi token funciona con /version pero falla con todo lo demás”: /version no exige privilegios.
Crear el usuario, el rol mínimo y el token para IaC
root@pam sirve para todo y por eso mismo no debe estar en tu pipeline. El patrón correcto es un usuario en el realm interno pve, un rol a medida y tokens separados por herramienta.
pveum user add automation@pve --comment "IaC / Terraform / Ansible"
pveum role add IaC -privs "\
VM.Allocate VM.Clone VM.Config.CDROM VM.Config.CPU VM.Config.Cloudinit \
VM.Config.Disk VM.Config.HWType VM.Config.Memory VM.Config.Network \
VM.Config.Options VM.Monitor VM.Audit VM.PowerMgmt \
Datastore.AllocateSpace Datastore.Audit \
SDN.Use Sys.Audit Sys.Console Sys.Modify \
Pool.Allocate Pool.Audit"
pveum acl modify / --user automation@pve --role IaC
pveum user token add automation@pve terraform --privsep=0
Dos privilegios de esa lista son consecuencia directa del endurecimiento de PVE 9.2. Sys.Console hace falta para añadir VMs o contenedores como recursos HA durante la creación o la restauración: sin él, un apply que cree la VM y la registre en HA falla en el segundo paso, con la VM ya creada. VM.PowerMgmt hace falta para arrancar una VM justo después de crearla, restaurarla o hacer rollback de un snapshot, así que Terraform con started = true lo necesita siempre. En la misma línea, 9.2 exige VM.Config.Cloudinit para volcar la contraseña de cloud-init. Con --privsep 1 hay que duplicar el ACL sobre el token: pveum acl modify / --tokens 'automation@pve!terraform' --roles IaC. Y para comprobar qué puede hacer una credencial antes de descubrirlo a mitad de un apply: pveum user permissions automation@pve --path /vms/101, pveum acl list y pveum role list. El modelo completo de permisos y herencia por rutas está en el capítulo 10.
Recetas de curl: inventario, tareas, backup, clonado y cloud-init
TOKEN='PVEAPIToken=automation@pve!terraform=aaaaaaaa-bbbb-cccc-dddd-ef0123456789'
H="Authorization: $TOKEN"
BASE='https://10.0.0.1:8006/api2/json'
curl -sk -H "$H" "$BASE/version" | jq
# Inventario de TODO el cluster en una sola llamada; /cluster/nextid da el VMID libre
curl -sk -H "$H" "$BASE/cluster/resources?type=vm" \
| jq -r '.data[] | "\(.vmid)\t\(.name)\t\(.node)\t\(.status)"'
/cluster/resources es el endpoint que más se rentabiliza: devuelve VMs, contenedores, storages y nodos de todo el cluster sin iterar nodo por nodo.
# Lanzar un backup y seguirlo y capturar el UPID
UPID=$(curl -sk -H "$H" -X POST "$BASE/nodes/pve1/vzdump" \
-d 'vmid=101' -d 'storage=pbs01' -d 'mode=snapshot' -d 'compress=zstd' \
-d 'notes-template=api {{guestname}}' | jq -r '.data')
# Seguir la tarea hasta el final
curl -sk -H "$H" "$BASE/nodes/pve1/tasks/$UPID/status" | jq
curl -sk -H "$H" "$BASE/nodes/pve1/tasks/$UPID/log" | jq -r '.data[].t'
Todas las operaciones largas de PVE son asíncronas. El POST no devuelve el resultado, devuelve un UPID. Quien no lo entienda escribirá scripts que “funcionan” porque el POST respondió 200 sin comprobar nunca si el backup terminó bien.
sequenceDiagram
participant C as Cliente
participant P as pveproxy en el puerto 8006
participant T as Tarea vzdump
C->>P: POST /nodes/pve1/vzdump con vmid y storage
P->>T: arranca la tarea en segundo plano
P-->>C: 200 con el UPID dentro de data
loop sondeo cada 5 segundos hasta que status deje de ser running
C->>P: GET /nodes/pve1/tasks/UPID/status
P-->>C: status running o bien stopped con exitstatus
end
alt exitstatus distinto de OK
C->>P: GET /nodes/pve1/tasks/UPID/log
P-->>C: lineas del log para diagnosticar
end
CRUD de jobs de backup, clonado, cloud-init y restauración:
curl -sk -H "$H" -X POST "$BASE/cluster/backup" -d 'id=nocturno' -d 'all=1' \
-d 'schedule=02:30' -d 'storage=pbs01' -d 'mode=snapshot' -d 'enabled=1' \
-d 'compress=zstd' -d 'prune-backups=keep-daily=7,keep-weekly=4'
curl -sk -H "$H" "$BASE/cluster/backup/nocturno/included_volumes" | jq
curl -sk -H "$H" -X DELETE "$BASE/cluster/backup/nocturno"
curl -sk -H "$H" -X POST "$BASE/nodes/pve1/qemu/9000/clone" \
-d 'newid=150' -d 'name=web-01' -d 'full=1' -d 'storage=local-lvm'
curl -sk -H "$H" -X PUT "$BASE/nodes/pve1/qemu/150/config" -d 'ciuser=ubuntu' \
--data-urlencode 'sshkeys=ssh-ed25519 AAAAC3... admin@laptop' \
-d 'ipconfig0=ip=10.0.10.150/24,gw=10.0.10.1' -d 'nameserver=10.0.10.1'
# Restaurar a un VMID nuevo desde un backup de PBS
curl -sk -H "$H" -X POST "$BASE/nodes/pve1/qemu" -d 'vmid=999' -d 'unique=1' \
-d 'archive=pbs01:backup/vm/101/2026-08-09T01:00:12Z' -d 'storage=local-lvm'
Advertencia: el error número uno al automatizar cloud-init por API es sshkeys. El valor debe ir URL-encoded, lo que con curl significa --data-urlencode y no -d. Con -d, la clave llega truncada en el primer espacio: se queda en ssh-ed25519 sin material criptográfico y la VM arranca sin acceso SSH, con el agravante de que qm config 150 muestra un sshkeys con contenido. Sobre -k: sirve en el laboratorio; en producción exporta el CA de PVE y usa --cacert. Las rutas que vas a usar de verdad:
/version /access/ticket /access/users /access/acl
/cluster/resources inventario global del cluster
/cluster/nextid siguiente VMID libre
/cluster/backup jobs de backup
/cluster/status /cluster/tasks /nodes /nodes/{node}/status
/nodes/{node}/qemu GET lista, POST crea o restaura
/nodes/{node}/qemu/{vmid}/config /clone /snapshot /agent/...
/nodes/{node}/qemu/{vmid}/status/{start|stop|shutdown|reboot|suspend|resume}
/nodes/{node}/lxc lo mismo para contenedores
/nodes/{node}/vzdump POST: lanzar backup
/nodes/{node}/storage/{storage}/content y /identity (nuevo en PVE 9.2)
/nodes/{node}/tasks/{upid}/status y /log
/storage CRUD de storages a nivel de cluster
/nodes/{node}/storage/{storage}/identity es nuevo en PVE 9.2 y expone la identidad del datastore de PBS asociado, con lo que puedes mapear de forma fiable qué storage de PVE apunta a qué datastore remoto. Los backends y el modelo de plugins de storage están en el capítulo 7.
pvesh como cliente local y explorador de esquemas
pvesh invoca las funciones de la API directamente, sin pasar por HTTPS ni por el proxy. Requiere ser root en un nodo del cluster. Para scripts que corren en el propio host es lo más rápido; para aprender la API es insustituible. Los subcomandos son get, create, set y delete (que corresponden a GET, POST, PUT y DELETE), más ls para listar los objetos hijo de una ruta y usage para imprimir su esquema. Las opciones comunes son --output-format (text por defecto; también json, json-pretty y yaml), --human-readable (activo por defecto), --noborder, --noheader, --quiet y --noproxy, que evita reenviar la petición al nodo propietario del recurso.
pvesh get /version
pvesh ls /nodes/pve1/qemu
pvesh get /cluster/resources --type vm --output-format json-pretty
pvesh create /nodes/pve1/lxc -vmid 100 -hostname test --storage local \
--ostemplate local:vztmpl/debian-13-standard_13.0-1_amd64.tar.zst
pvesh set /nodes/pve1/qemu/101/config --memory 4096 --cores 4
pvesh delete /access/users/testuser@pve
pvesh usage /nodes/{node}/vzdump --verbose # el comando clave
pvesh usage /cluster/backup -v
pvesh usage <ruta> -v imprime tipos, defaults y descripciones, exactamente los mismos que acepta la API por HTTP. Ese flujo (explorar con pvesh usage, probar con pvesh get, portar a curl o Python) evita el ciclo de prueba y error contra la API remota. El resto de herramientas CLI del sistema, qm, pct, pvesm, pvecm y compañía, están en el capítulo 2.
proxmoxer: backends, mapeo mágico de rutas y espera de tareas
Cuando el script deja de caber en bash, la librería de referencia es proxmoxer, versión 2.3.0 (4 de marzo de 2026), que soporta PVE, PMG y PBS con la misma interfaz.
Tiene cuatro backends. https es el default y habla REST usando requests: es lo normal. ssh_paramiko (con paramiko) y openssh (con openssh_wrapper) ejecutan los binarios CLI por SSH, el primero en puro Python y el segundo apoyándose en el ssh del sistema. Y local funciona solo dentro del propio nodo, llamando a pvesh directamente. El diseño de la librería es un mapeo directo: cada segmento de la ruta es un atributo Python y el verbo HTTP es el método final, de modo que prox.nodes.get() es GET /nodes y prox.nodes("pve1").storage("pbs01").content.get(content="backup") consulta el contenido de un storage.
#!/usr/bin/env python3
# pip install proxmoxer requests (opcionales: paramiko openssh_wrapper)
import os, sys, time
from proxmoxer import ProxmoxAPI
# Otras formas: ProxmoxAPI(backend="local") dentro del nodo; backend="ssh_paramiko"
# con private_key_file; port=8007 y service="PBS" para hablar con PBS.
prox = ProxmoxAPI(os.environ["PVE_HOST"], user=os.environ["PVE_USER"],
token_name=os.environ["PVE_TOKEN_NAME"],
token_value=os.environ["PVE_TOKEN_VALUE"], verify_ssl=False)
def esperar(nodo: str, upid: str, timeout: int = 7200) -> dict:
inicio = time.time()
while True:
st = prox.nodes(nodo).tasks(upid).status.get()
if st["status"] == "stopped":
return st
if time.time() - inicio > timeout:
raise TimeoutError(f"tarea {upid} excedio {timeout}s")
time.sleep(5)
criticas = [r for r in prox.cluster.resources.get(type="vm")
if "critico" in (r.get("tags") or "").split(";")]
for vm in criticas:
nodo, vmid = vm["node"], vm["vmid"]
upid = prox.nodes(nodo).vzdump.post(
vmid=vmid, storage="pbs01", mode="snapshot", compress="zstd",
**{"notes-template": "auto {{guestname}} @ {{node}}"})
st = esperar(nodo, upid)
if st["exitstatus"] != "OK":
print(f"FALLO en {vmid}: {st['exitstatus']}", file=sys.stderr)
for l in prox.nodes(nodo).tasks(upid).log.get()[-30:]:
print(" " + l["t"], file=sys.stderr)
Cuatro gotchas que cuestan una tarde cada uno. Uno. Los parámetros con guion (notes-template, prune-backups, pbs-change-detection-mode) no son identificadores Python válidos: hay que desempaquetar un diccionario, **{"notes-template": valor}. Dos. .post() sobre una tarea asíncrona devuelve el UPID como string, no el resultado; sin polling no sabes si terminó. Tres. verify_ssl=False emite avisos de urllib3 en cada llamada, así que en producción apunta al CA con verify_ssl="/etc/ssl/certs/pve-ca.pem". Cuatro. El backend https no cubre todo lo que cubre pvesh local, porque algunas operaciones exigen root@pam por diseño de PVE, no por limitación de la librería.
La cadena completa y qué provider de Terraform está vivo hoy
Antes de entrar en cada herramienta conviene ver dónde encaja, porque el error habitual es que una haga el trabajo de las tres.
flowchart LR
ISO["ISO o cloud image"] --> PK["Packer con proxmox-iso o proxmox-clone"]
PK --> LIMP["Provisioner de limpieza del guest"]
LIMP --> TPL["Plantilla registrada en PVE"]
TPL --> TF["Terraform bpg clona la plantilla"]
TF --> SNIP["Snippet de cloud-init subido por SSH"]
SNIP --> VM["VM arrancada con IP usuario y clave"]
VM --> INV["Inventario dinamico de Ansible"]
INV --> ANS["Ansible configura el servicio dentro del guest"]
Packer produce artefactos inmutables: la plantilla. Terraform gestiona el estado de la infraestructura: cuántas VMs hay, con qué recursos y en qué nodo. Ansible gestiona lo que pasa dentro del sistema operativo. Cuando alguien mete la configuración de nginx en el snippet de cloud-init de Terraform, el resultado es que cambiar una línea de nginx recrea la VM entera.
La elección de provider es la decisión con más material desactualizado en internet.
| Provider | Última versión | Fecha | Estado |
|---|---|---|---|
bpg/proxmox | 0.111.1 | 3 de julio de 2026 | El recomendado. Desarrollo activo y cobertura amplia |
Telmate/proxmox | 3.0.2-rc08 | 5 de julio de 2026 | Vivo pero en RC perpetuo y de alcance limitado. No está formalmente deprecated |
Telmate cubre VMs, LXC, pools y discos cloud-init, y poco más. bpg cubre todo eso más cluster, hosts, security groups, ACLs, configuración de red, SDN, usuarios, roles, certificados, ficheros y snippets, descarga de imágenes, hardware mappings y HA. Ambos funcionan igual con Terraform y con OpenTofu. Si heredas código de Telmate, tres diferencias muerden al migrar. Uno. El recurso pasa de proxmox_vm_qemu a proxmox_virtual_environment_vm. Dos. El endpoint de Telmate incluye /api2/json; el de bpg no lo lleva, y el fallo es silencioso porque los errores de ruta no mencionan el problema real. Tres. Telmate usa atributos planos (cores = 2) y bpg bloques anidados (cpu { ... }).
Configurar bpg/proxmox y por qué el bloque ssh no es opcional
terraform {
required_version = ">= 1.6"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.111"
}
}
}
provider "proxmox" {
endpoint = "https://10.0.0.1:8006/" # SIN /api2/json
api_token = "automation@pve!terraform=aaaaaaaa-bbbb-cccc-dddd-ef0123456789"
insecure = true # en producción: false y CA de confianza
ssh {
agent = true
username = "root"
}
}
El bloque ssh es el detalle que más tiempo hace perder. La documentación del provider avisa literalmente: “Not all Proxmox API operations are supported via API Token.” Algunas exigen root@pam, y el provider lo resuelve cayendo a SSH contra el nodo para subir snippets e importar discos. Sin ese bloque el plan funciona y el apply revienta con ssh: handshake failed en cuanto tocas un proxmox_virtual_environment_file. Las alternativas al token son username más password, o auth_ticket más csrf_prevention_token cuando el ticket lo emite otro sistema.
| Argumento | Default | Descripción |
|---|---|---|
endpoint | — (requerido) | URL de la API de PVE, sin /api2/json |
insecure | false | Saltar la verificación TLS |
min_tls | 1.3 | 1.0, 1.1, 1.2 o 1.3 |
api_token | — | Formato user@realm!tokenid=secret |
ssh.username | — | Requerido con API token o auth no PAM |
ssh.agent / ssh.private_key | false / — | Agente SSH del entorno, o clave privada en PEM |
ssh.node | puerto 22 | Override de name, address y port por nodo; ssh.node_address_source acepta api o dns |
tmp_dir | — | Directorio temporal para las subidas |
random_vm_ids | false | VMIDs aleatorios; el rango sale de random_vm_id_start y _end, por defecto 10000 y 99999 |
Todo tiene equivalente en variables de entorno, que es como debe entrar el secreto en CI: PROXMOX_VE_ENDPOINT, PROXMOX_VE_API_TOKEN, PROXMOX_VE_INSECURE, PROXMOX_VE_SSH_USERNAME y PROXMOX_VE_SSH_AGENT son las cinco habituales. Existen además PROXMOX_VE_USERNAME, PROXMOX_VE_PASSWORD, PROXMOX_VE_AUTH_TICKET, PROXMOX_VE_CSRF_PREVENTION_TOKEN, PROXMOX_VE_MIN_TLS, PROXMOX_VE_TMPDIR, PROXMOX_VE_SSH_PASSWORD, PROXMOX_VE_SSH_PRIVATE_KEY, PROXMOX_VE_SSH_AUTH_SOCK, PROXMOX_VE_SSH_AGENT_FORWARDING y las tres de SOCKS5. Nunca pongas el token en el .tf: además del repositorio, el estado de Terraform guarda la configuración del provider y terraform show la imprime.
Crear una VM desde cero con download_file, snippet y cloud-init
El caso sin plantilla previa: el provider descarga la cloud image, sube un snippet de cloud-init y crea la VM importando el disco.
resource "proxmox_virtual_environment_download_file" "ubuntu_noble" {
content_type = "import"
datastore_id = "local"
node_name = "pve1"
url = "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
file_name = "noble-server-cloudimg-amd64.qcow2"
}
resource "proxmox_virtual_environment_file" "cloud_config" {
content_type = "snippets"
datastore_id = "local"
node_name = "pve1"
source_raw {
file_name = "web-user-data.yaml"
data = <<-EOT
#cloud-config
hostname: web-01
package_update: true
packages: [qemu-guest-agent, nginx]
users:
- name: ubuntu
groups: [sudo]
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ${trimspace(file("~/.ssh/id_ed25519.pub"))}
runcmd: [systemctl enable --now qemu-guest-agent nginx]
EOT
}
}
resource "proxmox_virtual_environment_vm" "web" {
name = "web-01"
tags = ["terraform", "ubuntu", "web"]
node_name = "pve1"
vm_id = 150
started = true
agent {
enabled = true
}
cpu {
cores = 2
type = "x86-64-v2-AES"
}
memory {
dedicated = 2048
floating = 2048 # floating activa ballooning
}
disk {
datastore_id = "local-lvm"
import_from = proxmox_virtual_environment_download_file.ubuntu_noble.id
interface = "scsi0"
size = 32
iothread = true
}
network_device {
bridge = "vmbr0"
model = "virtio"
}
initialization {
datastore_id = "local-lvm"
ip_config {
ipv4 {
address = "10.0.10.150/24"
gateway = "10.0.10.1"
}
}
user_account {
username = "ubuntu"
keys = [trimspace(file("~/.ssh/id_ed25519.pub"))]
}
user_data_file_id = proxmox_virtual_environment_file.cloud_config.id
}
serial_device {} # las cloud images esperan consola serie
}
Requisito previo: el storage destino del snippet debe tener el content type snippets habilitado, o el proxmox_virtual_environment_file falla con content type 'snippets' is not supported; se arregla con pvesm set local --content iso,vztmpl,backup,snippets. Dos avisos más sobre el snippet. El primero: user_data_file_id sustituye por completo al user-data que PVE genera a partir de user_account, así que los usuarios y las claves tienen que estar dentro del YAML aunque también los declares en initialization, que admite además dns, meta_data_file_id, vendor_data_file_id y network_data_file_id. El segundo: serial_device {} no es decorativo, las cloud images no traen consola gráfica configurada y sin él no verás nada en la consola de la interfaz web.
Clonar plantillas con count y evitar recreaciones en cada apply
En producción el patrón dominante no es crear desde la imagen sino clonar una plantilla ya construida, idealmente por Packer. Los bloques cpu, memory, network_device y agent funcionan igual que en el ejemplo anterior y aquí se omiten por brevedad.
resource "proxmox_virtual_environment_vm" "nodos" {
count = 3
name = "k8s-worker-${count.index + 1}"
node_name = "pve1"
vm_id = 200 + count.index
tags = ["terraform", "k8s"]
clone {
vm_id = 9000 # VMID de la plantilla
node_name = "pve1" # nodo donde vive la plantilla
full = true # false = linked clone, rápido pero atado al origen
retries = 3 # reintentos si la plantilla está bloqueada
}
disk { # amplía el disco heredado de la plantilla
datastore_id = "local-lvm"
interface = "scsi0"
size = 64
}
initialization {
ip_config {
ipv4 {
address = "10.0.10.${200 + count.index}/24"
gateway = "10.0.10.1"
}
}
user_account {
username = "ubuntu"
keys = [trimspace(file("~/.ssh/id_ed25519.pub"))]
}
}
lifecycle {
ignore_changes = [initialization[0].user_account] # no recrear al rotar claves
}
}
Gotcha: la VM que se recrea en cada apply sin que hayas cambiado nada. La causa casi siempre es que PVE normaliza algún campo al guardarlo, así que lo escrito y lo leído no coinciden y el plan marca diferencia. Los sospechosos habituales son initialization (sobre todo user_account, porque las claves se reordenan o se recortan) y cpu.flags. La solución es acotar lifecycle { ignore_changes = [...] } a esos campos concretos, nunca a la VM entera. Sobre full: un clon completo copia los discos y es independiente; un linked clone comparte la base con la plantilla y depende de ella para siempre, así que no puedes borrar la plantilla ni mover el clon de storage sin convertirlo. La comparación completa está en el capítulo 5. Los recursos auxiliares que completan el catálogo son proxmox_virtual_environment_download_file (ISOs y cloud images), proxmox_virtual_environment_file (snippets y plantillas de CT), proxmox_virtual_environment_container (LXC), _pool, y _user, _role y _acl para gestionar el acceso como código. Los bloques más usados de proxmox_virtual_environment_vm son clone, agent, cpu, memory, disk, network_device, initialization, operating_system, tpm_state, serial_device, startup, hostpci y usb; los argumentos sueltos que importan son node_name (requerido), vm_id, template, started y on_boot (ambos true por defecto) y stop_on_destroy.
Dos errores más del provider que no salen en la tabla final: unable to authenticate user ... over SSH significa que el ssh.username no corresponde a un usuario real del host, y 500 unable to parse directory volume name significa que el datastore_id es incorrecto o que el disco importado no existe.
Ansible: la mudanza a community.proxmox y el mapa de módulos
Cambio reciente y con fecha de caducidad: los módulos de Proxmox se mudaron de colección. Antes vivían en community.general.proxmox*; ahora están en community.proxmox, versión 2.0.0. Los community.general.proxmox_kvm y compañía están deprecados y se eliminarán en community.general 15.0.0. Un playbook heredado funciona hoy y dejará de funcionar sin más aviso que el deprecation warning.
ansible-galaxy collection install community.proxmox
pip install proxmoxer requests # en el intérprete que ejecuta los módulos
Ansible habla con PVE a través de proxmoxer: la librería del apartado anterior hace el trabajo por debajo. Si no está instalada en el intérprete que usa Ansible, los módulos fallan con un error de import que no menciona a Proxmox. La colección trae más de 70 módulos agrupados así:
| Área | Módulos |
|---|---|
| VMs y contenedores | proxmox (LXC), proxmox_kvm, proxmox_vm_info, proxmox_template, proxmox_snap, proxmox_disk, proxmox_nic, proxmox_sendkey |
| Backup | proxmox_backup, proxmox_backup_info, proxmox_backup_schedule |
| Storage | proxmox_storage, proxmox_storage_info, proxmox_storage_contents_info |
| Red, SDN y firewall | proxmox_node_network, proxmox_vnet, proxmox_subnet, proxmox_zone, proxmox_ipam_info, proxmox_firewall, proxmox_cluster_firewall, proxmox_node_firewall y sus *_info |
| Acceso | proxmox_user, proxmox_group, proxmox_role, proxmox_access_acl, proxmox_domain, proxmox_domain_sync |
| Cluster y HA | proxmox_cluster, proxmox_cluster_status_info, proxmox_cluster_ha_groups, proxmox_cluster_ha_resources, proxmox_cluster_ha_rules |
| Nodos, ACME, Ceph y pools | proxmox_node, proxmox_tasks_info, proxmox_acme_certificate, proxmox_ceph_osd, proxmox_ceph_pool, proxmox_pool, proxmox_pool_member |
Que existan proxmox_cluster_ha_rules y los módulos de Ceph significa que puedes gestionar como código lo que en el capítulo 11 y en el capítulo 12 hiciste desde la interfaz.
Playbooks de aprovisionamiento y de backup
---
- name: Aprovisionar y respaldar VMs en Proxmox
hosts: localhost
gather_facts: false
vars: &pve
api_host: 10.0.0.1
api_user: automation@pve
api_token_id: ansible
api_token_secret: "{{ lookup('env', 'PVE_TOKEN_SECRET') }}"
validate_certs: false
tasks:
- name: Clonar la plantilla
community.proxmox.proxmox_kvm:
<<: *pve
node: pve1
clone: ubuntu-2404-template
newid: 160
name: app-01
full: true
storage: local-lvm
timeout: 300
state: present
- name: Configurar hardware y cloud-init
community.proxmox.proxmox_kvm:
<<: *pve
node: pve1
vmid: 160
cores: 4
memory: 8192
agent: enabled=1
ciuser: ubuntu
sshkeys: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
ipconfig:
ipconfig0: 'ip=10.0.10.160/24,gw=10.0.10.1'
nameservers: [10.0.10.1]
update: true # sin esto el módulo se queja de que la VM ya existe
state: present
- name: Backup inmediato antes de una actualización
community.proxmox.proxmox_backup:
<<: *pve
node: pve1
vmids: [101, 102, 105]
storage: pbs01
mode: snapshot
wait: true
wait_timeout: 7200
- name: Asegurar un job de backup nocturno
community.proxmox.proxmox_backup_schedule:
<<: *pve
node: pve1
vmid: 101
storage: pbs01
schedule: "02:30"
enabled: true
state: present
community.proxmox.proxmox_backup_info completa el trío: devuelve los jobs programados para registrarlos con register y decidir en función de ellos.
Verificación obligatoria antes de llevar esto a producción. Los nombres exactos de algunos parámetros de proxmox_backup, proxmox_backup_info y proxmox_backup_schedule en community.proxmox 2.0.0 (bandwidth, wait, wait_timeout, vmids frente a vmid) conviene confirmarlos contra tu versión instalada, porque la colección es reciente y todavía se mueve: ansible-doc community.proxmox.proxmox_backup y ansible-galaxy collection list community.proxmox. Ese backup bajo demanda es el complemento natural de los jobs programados del capítulo 13: el job nocturno cubre el caso general y esta tarea se ejecuta como paso previo dentro del pipeline de actualización.
Inventario dinámico y los connection plugins para LXC y VMs
Mantener un inventario estático de VMs que Terraform crea y destruye no tiene sentido: el plugin de inventario consulta el cluster en cada ejecución.
# inventory/proxmox.yml
plugin: community.proxmox.proxmox
url: https://10.0.0.1:8006
user: automation@pve
token_id: ansible
token_secret: "{{ lookup('env', 'PVE_TOKEN_SECRET') }}"
validate_certs: false
want_facts: true
keyed_groups:
- key: proxmox_tags_parsed
separator: ""
prefix: tag
groups:
running: "proxmox_status == 'running'"
lxc: "proxmox_type == 'lxc'"
compose:
ansible_host: proxmox_agent_interfaces[1].ip-addresses[0] | default(proxmox_name)
ansible-inventory -i inventory/proxmox.yml --graph
ansible -i inventory/proxmox.yml tag_web -m ping
Los keyed_groups sobre proxmox_tags_parsed son lo que hace encajar el sistema: si en Terraform pusiste tags = ["terraform", "k8s"], en Ansible tienes automáticamente los grupos tag_terraform y tag_k8s, y las etiquetas se convierten en el contrato entre las dos herramientas sin ningún fichero compartido. El compose de ansible_host depende del guest agent: sin agente arrancado no existe proxmox_agent_interfaces y el inventario cae al nombre de la VM, que solo resuelve si tu DNS lo conoce. Para los casos sin red hacia el guest hay dos connection plugins. community.proxmox.proxmox_pct_remote ejecuta las tareas dentro de un LXC usando pct por SSH contra el nodo, de modo que el contenedor no necesita servidor SSH ni siquiera red. community.proxmox.proxmox_qemu_api alcanza la VM a través de la API del guest agent.
- hosts: contenedores
vars:
ansible_connection: community.proxmox.proxmox_pct_remote
ansible_host: pve1 # el NODO, no el contenedor
ansible_user: root
tasks:
- name: Actualizar paquetes dentro del CT
ansible.builtin.apt:
update_cache: true
upgrade: dist
El detalle que se escapa: ansible_host apunta al nodo Proxmox, no al contenedor; el identificador del contenedor sale del inventory_hostname. Es la forma de gestionar los contenedores no privilegiados del capítulo 6 sin abrirles un puerto SSH.
Packer: proxmox-iso y proxmox-clone para construir plantillas
El plugin oficial es hashicorp/proxmox, versión v1.2.4 (julio de 2026), con dos builders: proxmox-iso, que instala el sistema desde una ISO con preseed, kickstart o autoinstall y produce una plantilla, y proxmox-clone, que parte de una plantilla cloud-init existente, la provisiona y produce otra plantilla. Nota histórica: antes de v1.1.0 los builders estaban mal registrados como proxmox-promox-iso y proxmox-proxmox-clone, así que si ves esos nombres el ejemplo tiene años.
# debian13.pkr.hcl — variables proxmox_token y ssh_password declaradas como sensitive
packer {
required_plugins {
proxmox = {
source = "github.com/hashicorp/proxmox"
version = "~> 1.2.4"
}
}
}
source "proxmox-iso" "debian13" {
proxmox_url = "https://10.0.0.1:8006/api2/json"
username = "packer@pve!builder"
token = var.proxmox_token
insecure_skip_tls_verify = true
node = "pve1"
boot_iso {
type = "scsi"
iso_file = "local:iso/debian-13.0.0-amd64-netinst.iso"
unmount = true
iso_checksum = "sha512:33c08e56c83d13007e4a5511b9bf2c4926c4aa12..."
}
vm_name = "debian13-template"
vm_id = 9100
memory = 2048
cores = 2
cpu_type = "x86-64-v2-AES"
os = "l26"
machine = "q35"
bios = "ovmf"
scsi_controller = "virtio-scsi-single"
efi_config { # obligatorio con bios = ovmf
efi_storage_pool = "local-lvm"
pre_enrolled_keys = false
}
disks {
type = "scsi"
storage_pool = "local-lvm"
disk_size = "20G"
format = "raw"
discard = true
}
network_adapters {
model = "virtio"
bridge = "vmbr0"
}
http_directory = "http" # sirve http/preseed.cfg al instalador
qemu_agent = true
template_name = "debian-13-base"
cloud_init = true
cloud_init_storage_pool = "local-lvm"
boot_wait = "10s"
boot_command = [
"<esc><wait>", "auto ",
"preseed/url=http://{{ .HTTPIP }}:{{ .HTTPPort }}/preseed.cfg ",
"debian-installer=es_ES locale=es_ES.UTF-8 keymap=es <enter>"
]
ssh_username = "root"
ssh_password = var.ssh_password
ssh_timeout = "20m"
}
build {
sources = ["source.proxmox-iso.debian13"]
# Un primer provisioner instala qemu-guest-agent y cloud-init con apt-get.
# El segundo es el que deja la plantilla en estado limpio:
provisioner "shell" {
inline = [
"cloud-init clean --logs --seed",
"rm -f /etc/machine-id && touch /etc/machine-id",
"rm -f /var/lib/dbus/machine-id",
"rm -f /etc/ssh/ssh_host_*",
"truncate -s 0 /etc/hostname",
"apt-get clean",
"rm -rf /tmp/* /var/tmp/*",
"find /var/log -type f -exec truncate -s 0 {} \;",
"history -c",
]
}
}
packer init debian13.pkr.hcl
packer validate -var "proxmox_token=$PVE_TOKEN" -var "ssh_password=$ROOT_PW" debian13.pkr.hcl
packer build -var "proxmox_token=$PVE_TOKEN" -var "ssh_password=$ROOT_PW" debian13.pkr.hcl
El segundo provisioner no es opcional: es la diferencia entre una plantilla y un desastre. Cada línea corrige un fallo que se manifiesta más tarde y en otro sitio. Uno. cloud-init clean --logs --seed borra el estado de cloud-init, que se ejecuta una vez por instance-id; sin limpiarlo, los clones creen que ya se configuraron y no aplican nada. Dos. rm -f /etc/machine-id && touch /etc/machine-id deja el fichero vacío para que systemd genere uno nuevo en el primer arranque: si no lo haces, todos los clones piden la misma IP por DHCP, porque el cliente DHCP moderno usa el machine-id como identificador, y es el fallo más desconcertante de todos porque aparece cuando ya tienes veinte máquinas desplegadas. Tres. rm -f /var/lib/dbus/machine-id cubre el mismo problema por la vía de D-Bus en distribuciones donde no es un enlace al anterior. Cuatro. rm -f /etc/ssh/ssh_host_* fuerza la regeneración de las claves de host: sin esto todas tus VMs comparten identidad SSH y cualquier verificación de host key es teatro.
El builder proxmox-clone sirve para el segundo nivel: partir de una plantilla base y añadirle una capa de aplicación. Comparte casi toda la configuración con proxmox-iso y cambia el origen por clone_vm = "ubuntu-2404-cloudinit" más full_clone = true, con ssh_username = "ubuntu" y ssh_private_key_file en vez de contraseña, porque el clon ya arranca con cloud-init aplicado. Dentro del build puedes encadenar directamente un provisioner "ansible" con playbook_file = "./playbooks/app.yml": el mismo playbook que usarías contra una VM viva sirve para hornear la plantilla, y ahí es donde la cadena se cierra.
Novedades relevantes de la serie v1.2.x: skip_convert_to_template (v1.2.4) deja la VM sin convertir, imprescindible para depurar una build que falla en el provisioner; cloud_init_disable_upgrade_packages (v1.2.4) evita el apt upgrade inicial, que en PVE está activo por defecto porque ciupgrade vale 1 y alarga cada arranque; v1.2.0 añadió soporte de TPM, tipo de disco cloud-init, formatos EFI y AsyncIO; y v1.2.4 sumó variables de proxy HTTP para la API y el uso del nombre de la VM como fallback al buscar la plantilla con -force. Nota sobre la versión: la página de releases muestra fechas inconsistentes entre v1.2.3 y v1.2.4, pero lo verificable es que v1.2.4 es la última; comprueba la tuya con packer plugins installed antes de copiar flags de una versión que no tienes.
Permisos mínimos por herramienta y buenas prácticas de secretos
Un único token Administrator para las tres herramientas funciona, y es exactamente lo que un atacante espera encontrar; el modelo correcto separa por función.
flowchart TD
U["Usuario automation@pve"] --> R["Rol IaC a medida"]
R --> ACL["ACL en la raiz o acotada a un pool"]
ACL --> T1["Token terraform"]
ACL --> T2["Token ansible"]
P["Usuario packer@pve"] --> RP["Rol Packer con AllocateTemplate"]
RP --> T3["Token builder"]
B["Usuario backupbot@pve"] --> RB["Rol BackupOnly"]
RB --> ACLB["ACL en /vms y en /storage/pbs01"]
ACLB --> T4["Token cron"]
El rol de Packer se diferencia del de Terraform en dos privilegios: Datastore.AllocateTemplate, porque su producto final es una plantilla, y VM.Console, porque el builder necesita la consola para enviar el boot_command al instalador.
pveum role add Packer -privs "VM.Config.Disk VM.Config.CPU VM.Config.Memory \
VM.Config.Network VM.Config.HWType VM.Config.Options VM.Config.CDROM VM.Config.Cloudinit \
VM.Allocate VM.Audit VM.Clone VM.Console VM.Monitor VM.PowerMgmt \
Datastore.AllocateSpace Datastore.Audit Datastore.AllocateTemplate Sys.Modify SDN.Use"
pveum user add packer@pve
pveum acl modify / --user packer@pve --role Packer
pveum user token add packer@pve builder --privsep=0
# Agente que solo hace backups, acotado además por ruta
pveum role add BackupOnly -privs "VM.Backup VM.Audit Datastore.AllocateSpace Datastore.Audit VM.PowerMgmt"
pveum user add backupbot@pve
pveum acl modify /vms --user backupbot@pve --role BackupOnly
pveum acl modify /storage/pbs01 --user backupbot@pve --role BackupOnly
pveum user token add backupbot@pve cron --privsep=0
| Privilegio | Para qué lo necesita la automatización |
|---|---|
VM.Allocate / VM.Clone | Crear y borrar VMs, restaurar sobre VMID nuevo, clonar plantillas |
VM.Config.* | Modificar CPU, memoria, discos, red, CDROM, cloud-init y opciones |
VM.PowerMgmt / VM.Backup | Arrancar tras crear o restaurar (obligatorio en PVE 9.2) y lanzar backups |
VM.Console | Enviar teclas al instalador. Solo lo necesita Packer |
Datastore.AllocateSpace / Datastore.Audit | Escribir volúmenes y leer la configuración del storage |
Datastore.AllocateTemplate | Crear plantillas e ISOs. Solo lo necesita Packer |
Sys.Console | Añadir guests como recursos HA al crearlos. Nuevo requisito en 9.2 |
Sys.Modify / Sys.Audit | Configuración del nodo, incluida la red, y lectura de estado |
SDN.Use, Pool.Allocate / Pool.Audit | Conectar interfaces a vnets de SDN y gestionar pools desde código |
Cinco reglas sobre los secretos. Uno. El secreto nunca en el .tf, el .pkr.hcl ni el playbook: entra por variable de entorno (PROXMOX_VE_API_TOKEN, PVE_TOKEN_SECRET) o por el gestor de secretos del CI. Dos. Un token por herramienta y por entorno, para que revocar uno no tumbe el resto. Tres. --expire con epoch UNIX en los tokens de CI, para que caduquen solos. Cuatro. Rotación con pveum user token modify ... --regenerate 1, que en 9.2 preserva las ACL; antes había que borrar, recrear y reasignar permisos. Cinco. El estado de Terraform contiene datos sensibles: backend remoto cifrado, nunca el repositorio.
Errores comunes y diagnóstico
| Síntoma | Causa probable | Diagnóstico y solución |
|---|---|---|
El token autentica en /version pero falla en todo lo demás | Creado con --privsep 1 sin ACL propias | pveum user token permissions <user> <tokenid>; asignar con pveum acl modify ... --tokens 'user@realm!id' --roles ... |
| Error de permisos en un POST con ticket, o todo deja de funcionar tras dos horas | Falta la cabecera CSRFPreventionToken, o el ticket caducó | Reenviar el CSRF de /access/ticket y renovar el ticket enviándolo como password; mejor aún, migrar a API token |
La VM arranca sin acceso SSH y qm config muestra sshkeys | La clave se envió con -d y llegó truncada en el primer espacio | Reenviar el PUT de config con --data-urlencode 'sshkeys=...' |
| El script cree que el backup terminó bien pero no existe | Se ignoró el UPID y no se hizo polling | Sondear /nodes/{node}/tasks/{upid}/status hasta stopped y revisar exitstatus |
TypeError en proxmoxer al pasar notes-template | Guion no válido como identificador Python | Pasarlo con **{"notes-template": valor} |
Terraform bpg: ssh: handshake failed | Falta el bloque ssh en el provider | Añadir el bloque ssh con agent = true y username = "root", y cargar la clave en el agente |
Terraform recrea la VM en cada apply sin cambios | PVE normaliza campos que Terraform vuelve a comparar | lifecycle { ignore_changes = [initialization[0].user_account] } y equivalentes |
Terraform bpg: clone failed: VM is locked, o errores de ruta raros | Otra tarea sobre la plantilla, o se copió el endpoint de Telmate | Subir clone.retries y qm unlock <vmid>; dejar endpoint = "https://host:8006/", sin /api2/json |
| Ansible avisa de módulo deprecado | Los módulos se mudaron de colección | ansible-galaxy collection install community.proxmox y usar community.proxmox.proxmox_kvm |
| El inventario dinámico no resuelve las IP | Sin guest agent no hay proxmox_agent_interfaces | Instalar y arrancar qemu-guest-agent; comprobar con qm agent <vmid> ping |
| Todos los clones piden la misma IP por DHCP | El machine-id no se limpió al construir la plantilla | Añadir rm -f /etc/machine-id && touch /etc/machine-id al provisioner y reconstruir |
| El snippet personalizado rompió el acceso SSH | El user-data personalizado sustituye al generado por PVE | Incluir el bloque users: con ssh_authorized_keys dentro del YAML |
| No hay consola para una cloud image | Falta la consola serie | serial_device {} en Terraform, o qm set <vmid> --serial0 socket --vga serial0 |
Cuando nada encaja, el orden de inspección es siempre el mismo: la tarea antes que el log del demonio.
pvesh get /nodes/$(hostname)/tasks --limit 50
pvesh get /nodes/$(hostname)/tasks/<UPID>/log --output-format text
journalctl -u pvedaemon -u pveproxy -f
systemctl status pve-cluster # pmxcfs y /etc/pve
pveversion -v # resuelve la mitad de las dudas
Lo que queda operativo
Tienes un usuario automation@pve con rol de privilegios mínimos y tokens separados por herramienta, rotables in situ gracias a PVE 9.2. Tienes la API explorada con pvesh usage y scripts que lanzan tareas y esperan su UPID en lugar de asumir que un 200 significa éxito. Tienes Packer construyendo plantillas limpias, Terraform con bpg/proxmox clonándolas de forma idempotente y Ansible configurando los guests con un inventario que se actualiza solo a partir de las etiquetas que puso Terraform. Reconstruir el entorno completo deja de ser un ejercicio de memoria y pasa a ser un packer build, un terraform apply y un ansible-playbook. Falta la otra mitad del trabajo: que lo desplegado siga vivo y que sepas cuándo no lo está. En el capítulo 15 montarás el monitoreo con métricas externas, las notificaciones del datacenter, el procedimiento de actualización sin sorpresas y el runbook operativo que convierte todo esto en un servicio mantenible.