← All TILs · ansible

ansible.builtin.constructed: keyed_groups, groups and compose on top of another source

ansible - 2026-10-03

Eighteenth entry in the Ansible inventory from scratch series. The previous entry turned the guests of a Proxmox VE cluster into hosts with the community.proxmox.proxmox inventory plugin. Its groups follow Proxmox's own layout: one per node, per guest type, per status and per pool. This entry builds the groups a playbook wants from what the plugin returns, with the ansible.builtin.constructed plugin, and compares that with setting the same options on the proxmox plugin itself. Everything below ran with ansible-core 2.21.4 and community.proxmox 2.0.0.

The setup

Item 11 introduced constructed: an inventory plugin that reads the hosts the earlier sources of the same inventory added, and creates groups and variables from their variables. Its keyed_groups option makes one group per value of an expression, and item 11 showed it on an env variable, with the strict: true pitfall. This entry covers the rest of its options.

The other source here is the proxmox plugin. Proxmox VE is a hypervisor that runs guests, QEMU virtual machines and LXC containers, on cluster members called nodes; each guest can carry tags, free-form labels, which Proxmox stores as one string separated by ;. The example points the plugin at a small mock of the Proxmox API, with five guests modelled on those of part 12 of the data-shaping series, each given tags and a static IP:

Guest Type Status Tags OS type Pool
pxe.home.arpa qemu running infra, dhcp l26
test1 qemu stopped web, staging (none) pool1
test-lxc.home.arpa lxc running web, prod debian
test1-lxc.home.arpa lxc stopped db, prod debian pool1
test2-lxc.home.arpa lxc stopped (none) alpine

The inventory is a directory with two sources, read in name order, and a group_vars/ directory:

Alone, the proxmox source builds proxmox_all_lxc, proxmox_all_qemu, proxmox_all_running, proxmox_all_stopped, proxmox_pool_pool1 and one group per node and type, such as proxmox_pve2_lxc. Each guest gets variables prefixed proxmox_; for test1:

proxmox_ipconfig0 = {"gw":"10.0.10.1","ip":"10.0.10.101/24"}
proxmox_status = "stopped"
proxmox_tags = "web;staging"
proxmox_tags_parsed = ["web","staging"]

proxmox_tags_parsed, the tags as a list, and the IP settings come from the guest's configuration, so only with want_facts: true.

keyed_groups

plugin: ansible.builtin.constructed
strict: false
use_vars_plugins: true
leading_separator: false
keyed_groups:
  - key: proxmox_tags_parsed | default([''])
    prefix: tag
    default_value: untagged
  - key: proxmox_status
    prefix: status
  - key: "{'os': proxmox_ostype | default('')}"
    trailing_separator: false
  - key: owner
    prefix: owner

A group's name is the prefix, the separator (_ by default) and the value; the result is cleaned into a valid group name, which is how team-a became owner_team_a. What each entry gave:

groups: conditions

groups maps a group name to a condition, a Jinja2 expression that is true or false; the host joins the group when it's true. A Jinja2 test, written value is test(argument), is a check that returns true or false:

groups:
  web_prod: "['web', 'prod'] is subset(guest_tags)"
  prod_down: "'prod' in guest_tags and proxmox_status is eq('stopped')"
  local_domain: inventory_hostname is search('\\.home\\.arpa$')

web_prod got test-lxc, prod_down got test1-lxc, the prod guest that is stopped, and local_domain got the four guests whose name ends in .home.arpa. guest_tags is a variable the same source sets with compose, below.

compose: variables

compose sets a host variable to the result of an expression, here ansible_host, the address Ansible connects to, which item 4 introduced:

compose:
  guest_tags: proxmox_tags_parsed | default([])
  ansible_host: (proxmox_ipconfig0 | default(proxmox_net0)).ip | split('/') | first

A QEMU guest keeps its IP in ipconfig0 (cloud-init settings), an LXC container in net0; the plugin splits both into dicts. Each guest got its address, test1 10.0.10.101. In constructed.py, compose runs before groups and keyed_groups for each host, which is why groups can use guest_tags: it names an expression once instead of repeating proxmox_tags_parsed | default([]) in each condition.

One display detail: the proxmox plugin marks the values it reads from the API as unsafe, strings Ansible will never render as a template, in case they contain {{. ansible-inventory --list prints such values, and values composed from them, as {"__ansible_unsafe": "10.0.10.101"}, where --host test1 prints "10.0.10.101". A script reading --list has to unwrap them.

The same options on the proxmox plugin

The proxmox plugin is Constructable: it accepts keyed_groups, groups, compose, strict and leading_separator itself, from the same documentation fragment. direct/ sets the options above on it, with no constructed source and the same group_vars/. Every group came out the same, but one: owner_team_a was missing, although ansible-inventory --host test1 showed owner: team-a.

The proxmox plugin evaluates the options as it adds each guest, from the guest's own variables. group_vars/ are read by vars plugins, and the constructed documentation says those normally run only after every source is parsed; constructed runs them early when use_vars_plugins: true, for the groups its host is already in, proxmox_pool_pool1 here. With strict: false, the missing variable dropped the group silently.

So the options on the dynamic plugin are enough while the groups depend only on what it returns. A separate constructed source is needed when they depend on:

The example

The series' companion repository, abdelhousni/ansible-inventory-series, holds the mock Proxmox API, the inventory, the variant with the defaults and the direct/ inventory. run.sh starts the mock, prints the groups and variables of the proxmox source alone, the groups and ansible_host values constructed adds, the keyed groups with the defaults, and the groups of direct/. Its CI runs it on every push and compares the output with the expected one.

Sources

Created 2026-10-03T14:34:21+02:00 · Edit