← All TILs · ansible

The Proxmox inventory plugin: guests as hosts, filtered by tag and status

ansible - 2026-10-03

Seventeenth entry in the Ansible inventory from scratch series. Item 13 made the VMs hosts of the inventory instead of a list in a variable, but wrote them by hand. When the VMs already exist on a hypervisor, the hypervisor is the place that knows them, and a dynamic inventory plugin asks it each time Ansible runs instead. Item 11 used one such plugin, constructed, which builds groups from hosts other sources added; this entry uses one that adds hosts itself, from Proxmox VE. Everything below ran with ansible-core 2.21.4, community.proxmox 2.0.0 and community.general 13.4.0.

Proxmox VE and its guests

Proxmox VE is a virtualization platform. A node is one physical server running it; nodes join into a cluster. A guest is what runs on a node, of two types: a QEMU virtual machine (qemu) or an LXC container (lxc). Each guest has a numeric ID, the VMID, a name, a status (running, stopped), optional tags (free labels, stored as one string such as infra;pxe), and may belong to a pool, a named set of guests. A guest can also be a template, a frozen VM that others are cloned from. Everything is reachable through a REST API under /api2/json.

The example doesn't need a cluster: a small Python server answers the API calls the plugin makes, which its source lists, with recorded responses. The guests are the ones of part 4 and part 12 of the data-shaping series, with tags added:

VMID Type Name Node Status Tags Pool
100 qemu pxe.home.arpa pve running infra;pxe
101 qemu test1 pve2 stopped test pool1
102 lxc test-lxc.home.arpa pve running test;web
103 lxc test1-lxc.home.arpa pve2 stopped test pool1
104 lxc test-lxc.home.arpa pve2 stopped web pool1
105 lxc (empty) pve2 stopped pool1
9000 qemu debian-13-tmpl pve template template

Two guests share a name, and one has none. Both are allowed by Proxmox, and both matter below.

The plugin and its configuration file

An inventory plugin is configured by a YAML file passed to -i like any inventory. The Proxmox one is community.proxmox.proxmox, from the community.proxmox collection, and it needs the Python library requests in Ansible's own Python. The file name must end in proxmox.yml or proxmox.yaml, or the plugin skips it:

# inventory/guests.proxmox.yml
plugin: community.proxmox.proxmox
url: http://127.0.0.1:18170
user: ansible@pve
filters:
  - proxmox_name != ''

The password isn't in the file: the plugin reads PROXMOX_PASSWORD from the environment when the file has none, and the same goes for PROXMOX_URL, PROXMOX_USER, and PROXMOX_TOKEN_ID with PROXMOX_TOKEN_SECRET for an API token. Item 10 covers keeping secrets out of an inventory. Nothing has to be enabled: Ansible's default auto plugin reads the plugin: key and loads the plugin it names.

Guests and nodes as hosts, in the plugin's groups

ansible-inventory --graph, from item 6, on that file:

Those groups combine with the host patterns of item 11, as the want_facts section shows.

Two guests, one name: one host

Inventory hosts are keyed by name, and the plugin names hosts after guests. VMIDs 102 and 104 both became the single host test-lxc.home.arpa. Its variables are those of the last guest read, 104, stopped on pve2, while its groups are those of both: it's in proxmox_all_running and proxmox_all_stopped, proxmox_pve_lxc and proxmox_pve2_lxc. Nothing warns. A play on proxmox_all_running would reach it with the address of the stopped container, as the next sections show. The plugin has no option to name hosts by VMID; unique guest names in Proxmox are the fix.

Filters: by status and by tag

filters: is a list of Jinja expressions; a guest becomes a host only if all are true. Without want_facts, they can only use the variables above:

filters:
  - proxmox_name != ''
  - proxmox_status == 'running'
  - "'test' in (proxmox_tags | default('')).split(';')"

That kept only test-lxc.home.arpa. proxmox_tags is a string, so the expression splits it at ; before testing membership: 'test' in proxmox_tags alone would also match a tag such as latest, since in on a string looks for a substring. default('') covers guests with no tags, where the variable is absent.

The trap is a filter on a variable that doesn't exist yet. proxmox_tags_parsed, the tags as a list, comes only with want_facts (next section). Without it, 'test' in proxmox_tags_parsed printed five warnings, Could not evaluate host filter … 'proxmox_tags_parsed' is undefined, and kept every guest: the plugin's source ignores a filter that errors, unless strict: true, which turns the error into a failure.

want_facts: the configuration as variables, and ansible_host

With want_facts: true, the plugin also fetches each guest's status, configuration and snapshots, and adds them as proxmox_* variables: proxmox_cores, proxmox_memory, proxmox_net0 split into a dictionary, proxmox_tags_parsed, and, for a VM with the QEMU guest agent enabled, proxmox_agent_interfaces, the addresses the agent reports from inside the guest. For running containers it adds proxmox_lxc_interfaces.

The plugin doesn't set ansible_host for guests, so Ansible connects to the guest's name. compose:, an option inventory plugins share with constructed, sets variables from expressions; keyed_groups: makes a group per value:

want_facts: true
exclude_nodes: true
filters:
  - proxmox_name != ''
  - not proxmox_template
compose:
  # The guest agent's first address if it reports one, else the static IP in
  # the configuration: cloud-init's ipconfig0 for a VM, net0 for a container.
  ansible_host: >-
    (proxmox_agent_interfaces | default([]) | rejectattr('name', 'eq', 'lo')
     | map(attribute='ip-addresses') | flatten | first
     | default(proxmox_ipconfig0.ip | default(proxmox_net0.ip)))
    | split('/') | first
keyed_groups:
  - key: proxmox_tags_parsed | default([])
    prefix: tag

cloud-init is the tool that configures a VM's network at first boot; Proxmox stores its settings as ipconfig0. The result: pxe.home.arpa at 192.168.1.100 from its agent, test1 at .101 from cloud-init, the containers from net0. The groups tag_infra, tag_pxe, tag_test and tag_web appeared. The duplicate name showed up again: test-lxc.home.arpa had ansible_host 192.168.1.104 and status stopped, yet tag_test:&proxmox_all_running matched it, from guest 102's tag and status. Its variables were a mix, too: the plugin sets them one by one, so 104 overwrote the ones both guests have, and proxmox_lxc_interfaces, which only the running 102 has, stayed from 102.

The facts cost requests. The mock counts them: 8 without facts, 31 with want_facts, because the plugin fetches the facts of every guest before filtering, the template and the unnamed one included. want_post_filter_facts: true instead of want_facts fetches them only for the guests the filters keep: 25 requests, and the same inventory, as long as the filters use only the basic variables.

Three empty inventories that exit 0

The example

The series' companion repository, abdelhousni/ansible-inventory-series, holds the mock (mock/server.py and its recorded responses in mock/api.json), the four plugin configurations and the three pitfalls. run.sh starts the mock on port 18170, prints each result above, including the request counts from the mock's log, and stops it. Its CI runs it on every push and compares the output with the expected one.

Sources

Created 2026-10-03T14:31:38+02:00 · Edit