Skip to main content

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:
ScopeUsed for
vms:readLooking the template up in GET /api/v1/vms
vms:writeCloning the template and sizing the clone
vms:powerStarting and stopping the clone
vms:deleteDestroying the clone
firewall:writeManaging 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 its id and name, and every guest in GET /api/v1/vms carries its connId.

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=1 until status is stopped, then check that exitstatus is OK, 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/restapi manages 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/http reads 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.

Other objects

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/vms lists only the guests of the vDC, so the template must be one of them: cloning a guest outside the vDC answers 403 Guest 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 the restapi_object from the Terraform example.

See Confinement.