Terraform and Ansible
The public API reads and writes across every cluster a token can reach, so infrastructure-as-code tools can drive the whole fleet through one endpoint, one token and one audit trail. There is no dedicated Terraform provider or Ansible collection: both tools call the REST API with their generic HTTP building blocks. This page gives a tested starting point for each.
Before you start
- A token created in Settings > API, see Creating a token. The examples below need:
| Scope | Used for |
|---|---|
vms:read | Looking the template up in GET /api/v1/vms |
vms:write | Cloning the template and sizing the clone |
vms:power | Starting and stopping the clone |
vms:delete | Destroying the clone |
firewall:write | Managing a firewall alias (Terraform example) |
In the creation dialog, set VMs and containers to Write and select Delete as well, and turn on Firewall under Nodes and cluster. Restrict Connections to the clusters the code manages.
- The token in the environment of the machine that runs the tool, never in the code:
export PROXCENTER_TOKEN=pxc_REPLACE_WITH_YOUR_TOKEN
export TF_VAR_proxcenter_token=$PROXCENTER_TOKEN
- The id of the connection to manage.
GET /api/v1/inventory(nodes:read) lists each cluster with itsidandname, and every guest inGET /api/v1/vmscarries itsconnId.
Two behaviours of the API shape both examples:
- Writes on guests are asynchronous. A clone, a start or a delete answers with the Proxmox UPID of its task, which only means the task was accepted. Poll
GET /api/v1/tasks/{connectionId}/{node}/{upid}?summary=1untilstatusisstopped, then check thatexitstatusisOK, see Tasks. - Each token has a read and a write quota, 600 reads and 60 writes per minute by default. Polling uses the read quota, so poll every few seconds rather than in a tight loop, see Quotas.
Terraform
The example uses two providers from the public registry:
Mastercard/restapimanages objects that the API creates synchronously and reads back as they were sent, such as a firewall alias. Terraform then detects and corrects drift like for any other resource.hashicorp/httpreads data, here the guest list, to find a template by name.
Guests are created by tasks and their configuration reads back with many more keys than were sent, so a generic REST resource cannot track them. The example manages the clone with terraform_data and a small script that calls the API and waits for each task. Any change to the clone's inputs replaces it, which suits clones of a template.
main.tf:
terraform {
required_providers {
restapi = {
source = "Mastercard/restapi"
version = "~> 3.0"
}
http = {
source = "hashicorp/http"
version = "~> 3.6"
}
}
}
variable "proxcenter_url" { type = string }
variable "proxcenter_token" {
type = string
sensitive = true
}
variable "connection_id" { type = string }
variable "template_name" { type = string }
provider "restapi" {
uri = "${var.proxcenter_url}/api/v1"
bearer_token = var.proxcenter_token
write_returns_object = false
timeout = 60
}
# Find the template by name in the guest list.
data "http" "guests" {
url = "${var.proxcenter_url}/api/v1/vms"
request_headers = {
Authorization = "Bearer ${var.proxcenter_token}"
}
}
locals {
template = one([
for g in jsondecode(data.http.guests.response_body).data.vms : g
if g.connId == var.connection_id && g.name == var.template_name && g.template
])
}
# A firewall alias of the cluster: created, updated and deleted declaratively.
resource "restapi_object" "web_alias" {
path = "/firewall/aliases/${var.connection_id}"
id_attribute = "name"
data = jsonencode({
name = "web"
cidr = "192.0.2.80"
comment = "Managed by Terraform"
})
read_search = {
search_key = "name"
search_value = "{id}"
}
ignore_server_additions = true
}
# A VM cloned from the template. Changing any value replaces the clone.
locals {
web01 = {
url = var.proxcenter_url
connection = var.connection_id
node = local.template.node
template = local.template.vmid
vmid = 9101
name = "web01"
cores = 2
memory = 2048
}
}
resource "terraform_data" "web01" {
input = local.web01
triggers_replace = local.web01
provisioner "local-exec" {
command = "${path.module}/proxcenter-guest.sh create"
environment = { for k, v in self.input : upper(k) => tostring(v) }
}
provisioner "local-exec" {
when = destroy
command = "${path.module}/proxcenter-guest.sh destroy"
environment = { for k, v in self.input : upper(k) => tostring(v) }
}
}
proxcenter-guest.sh, next to main.tf and executable (chmod +x). It needs curl and jq, and reads PROXCENTER_TOKEN from the environment Terraform was started in:
#!/usr/bin/env bash
# Usage: proxcenter-guest.sh create|destroy
# Reads PROXCENTER_TOKEN, URL, CONNECTION, NODE, TEMPLATE, VMID, NAME, CORES, MEMORY from the environment.
set -euo pipefail
api() { curl -fsS -H "Authorization: Bearer $PROXCENTER_TOKEN" -H 'Content-Type: application/json' "$@"; }
guests="$URL/api/v1/connections/$CONNECTION/guests/qemu/$NODE"
wait_task() {
local upid task
upid=$(jq -rn --arg u "$1" '$u|@uri')
while :; do
task=$(api "$URL/api/v1/tasks/$CONNECTION/$NODE/$upid?summary=1")
[ "$(jq -r .status <<<"$task")" = stopped ] && break
sleep 3
done
if [ "$(jq -r .exitstatus <<<"$task")" != OK ]; then
jq -r '.failureReason // .exitstatus' <<<"$task" >&2
exit 1
fi
}
case "$1" in
create)
body=$(jq -n --argjson id "$VMID" --arg name "$NAME" '{newid: $id, name: $name, full: false}')
wait_task "$(api -X POST -d "$body" "$guests/$TEMPLATE/clone" | jq -r .data)"
body=$(jq -n --argjson cores "$CORES" --argjson mem "$MEMORY" '{cores: $cores, memory: $mem}')
api -X PUT -d "$body" "$guests/$VMID/config" > /dev/null
wait_task "$(api -X POST "$guests/$VMID/start" | jq -r .data)"
;;
destroy)
wait_task "$(api -X POST "$guests/$VMID/stop" | jq -r .data)"
wait_task "$(api -X DELETE "$guests/$VMID?purge=1" | jq -r .data)"
;;
esac
terraform.tfvars:
proxcenter_url = "https://proxcenter.example.com"
connection_id = "CONNECTION_ID"
template_name = "debian-13-template"
Then run terraform init and terraform apply. A second terraform plan reports no changes; editing the alias updates it in place, editing web01 stops and destroys the clone before cloning it again, and terraform destroy removes both. The DELETE sends purge=1, so the destroyed clone is also removed from the backup jobs that listed it.
The same restapi_object pattern fits the other objects that the API writes synchronously, such as firewall rules, IP sets and resource pools. Check the request body and the read response of each one in the API reference of Settings > API: read_search must find the object in a list, and the fields you send must come back under the same names.
Ansible
The playbook runs on the control node and calls the API with the ansible.builtin.uri module, so it needs no collection beyond ansible-core. It looks the VM up first, so a second run changes nothing, and vm_state=absent stops and destroys it.
clone-vm.yml:
- name: Clone a VM from a template through ProxCenter
hosts: localhost
gather_facts: false
vars:
proxcenter_url: https://proxcenter.example.com
proxcenter_token: "{{ lookup('ansible.builtin.env', 'PROXCENTER_TOKEN') }}"
connection_id: CONNECTION_ID
node: NODE_NAME
template_vmid: 9000
vmid: 9101
vm_name: web01
vm_state: present
api_headers:
Authorization: "Bearer {{ proxcenter_token }}"
guests: "{{ proxcenter_url }}/api/v1/connections/{{ connection_id }}/guests/qemu/{{ node }}"
tasks_url: "{{ proxcenter_url }}/api/v1/tasks/{{ connection_id }}/{{ node }}"
tasks:
- name: List the guests
ansible.builtin.uri:
url: "{{ proxcenter_url }}/api/v1/vms"
headers: "{{ api_headers }}"
register: inventory
- name: Look the VM up
ansible.builtin.set_fact:
vm_exists: >-
{{ inventory.json.data.vms
| selectattr('connId', 'equalto', connection_id)
| selectattr('vmid', 'equalto', vmid | string)
| list | length > 0 }}
- name: Create the VM
when: vm_state == 'present' and not vm_exists
block:
- name: Clone the template
ansible.builtin.uri:
url: "{{ guests }}/{{ template_vmid }}/clone"
method: POST
headers: "{{ api_headers }}"
body_format: json
body: {newid: "{{ vmid }}", name: "{{ vm_name }}", full: false}
register: clone
changed_when: true
- name: Wait for the clone
ansible.builtin.uri:
url: "{{ tasks_url }}/{{ clone.json.data | urlencode }}?summary=1"
headers: "{{ api_headers }}"
register: task
until: task.json.status == 'stopped'
retries: 60
delay: 5
failed_when: task.json.status == 'stopped' and task.json.exitstatus != 'OK'
- name: Size the VM
ansible.builtin.uri:
url: "{{ guests }}/{{ vmid }}/config"
method: PUT
headers: "{{ api_headers }}"
body_format: json
body: {cores: 2, memory: 2048}
changed_when: true
- name: Start the VM
ansible.builtin.uri:
url: "{{ guests }}/{{ vmid }}/start"
method: POST
headers: "{{ api_headers }}"
register: start
changed_when: true
- name: Wait for the start
ansible.builtin.uri:
url: "{{ tasks_url }}/{{ start.json.data | urlencode }}?summary=1"
headers: "{{ api_headers }}"
register: task
until: task.json.status == 'stopped'
retries: 30
delay: 2
failed_when: task.json.status == 'stopped' and task.json.exitstatus != 'OK'
- name: Delete the VM
when: vm_state == 'absent' and vm_exists
block:
- name: Stop the VM
ansible.builtin.uri:
url: "{{ guests }}/{{ vmid }}/stop"
method: POST
headers: "{{ api_headers }}"
register: stop
changed_when: true
- name: Wait for the stop
ansible.builtin.uri:
url: "{{ tasks_url }}/{{ stop.json.data | urlencode }}?summary=1"
headers: "{{ api_headers }}"
register: task
until: task.json.status == 'stopped'
retries: 30
delay: 2
failed_when: task.json.status == 'stopped' and task.json.exitstatus != 'OK'
- name: Destroy the VM and remove it from backup jobs
ansible.builtin.uri:
url: "{{ guests }}/{{ vmid }}?purge=1"
method: DELETE
headers: "{{ api_headers }}"
register: destroy
changed_when: true
- name: Wait for the destroy
ansible.builtin.uri:
url: "{{ tasks_url }}/{{ destroy.json.data | urlencode }}?summary=1"
headers: "{{ api_headers }}"
register: task
until: task.json.status == 'stopped'
retries: 30
delay: 2
failed_when: task.json.status == 'stopped' and task.json.exitstatus != 'OK'
Run it, then remove the VM:
ansible-playbook clone-vm.yml
ansible-playbook clone-vm.yml -e vm_state=absent
vDC tenant tokens
A token created for a vDC tenant runs the same code on a provider connection, with three differences:
GET /api/v1/vmslists only the guests of the vDC, so the template must be one of them: cloning a guest outside the vDC answers 403Guest outside your vDC scope.- The clone stays in the vDC's pool, storages and quota.
- Cluster-level objects, such as a cluster firewall alias, answer 403
Cluster-level object not available to a vDC tenant token: drop therestapi_objectfrom the Terraform example.
See Confinement.