Preparing the Nodes — Participant Handout

Ansible for Proxmox VE · Module 02

Author

credativ GmbH

What a cluster needs before it exists

Forming a Proxmox VE cluster fails in predictable ways when the groundwork is missing. This module removes all four causes:

Requirement Consequence if missing
Usable package sources apt fails; you cannot install or update anything
Mutual name resolution pvecm and corosync cannot address the other nodes
Synchronised clocks corosync membership becomes unstable
Identical base state problems appear on one node only and are hard to find

Each requirement becomes one group of tasks in a single playbook.

Playbook structure

A playbook is a list of plays. A play binds a host pattern to a list of tasks.

playbooks/10-node-prep.yml
---
- name: Prepare the Proxmox VE nodes
  hosts: pve
  gather_facts: true
  tasks:
    - name: Install the admin tools we want everywhere
      ansible.builtin.apt:
        name: "{{ pve_packages }}"
        state: present

Conventions worth adopting from the start:

  • Give every task a name:. It is what appears in the output, and it is what --start-at-task and reviewers rely on.
  • Use the fully qualified module name (ansible.builtin.apt, not apt). It removes any doubt about which collection a module comes from.
  • Keep the playbook free of values. Values belong in variables.

Variables and facts

Your own variables

inventory/group_vars/pve.yml
pve_packages:
  - vim
  - curl
  - jq
  - tmux

Files under inventory/group_vars/ are loaded automatically for the group named by the file. inventory/group_vars/all.yml applies to every host.

{ }: Jinja, and where it runs

Everything between {{ and }} is a Jinja expression. Ansible evaluates it on the control node, before the task is sent anywhere — which is why a fact gathered from the node can end up in a task argument in the first place.

name: "{{ pve_packages }}"                                       # a variable
suites: "{{ ansible_distribution_release }}"                     # a fact
loop: "{{ groups['pve'] | difference([inventory_hostname]) }}"   # a filter

| pipes a value through a filter. difference here returns the group minus this host — the list of peers each node has to resolve. There are several hundred filters; the two pages worth bookmarking are templating and the filter list.

Quote the value whenever it begins with {{. Unquoted, YAML reads the opening brace as the start of a dictionary and the playbook fails to parse.

loop: and item

- name: Disable the enterprise repositories
  ansible.builtin.file:
    path: "{{ item }}"
    state: absent
  loop:
    - /etc/apt/sources.list.d/pve-enterprise.sources
    - /etc/apt/sources.list.d/ceph.sources

The task runs once per entry, and the current entry is always called item. When the entries are long or are dictionaries, loop_control: {label: "..."} decides what the output shows instead of the whole value — the cluster playbook in module 03 uses that.

Older material writes with_items:. It still works and means the same thing; loop: is what current documentation uses.

Facts, gathered from the machine

With gather_facts: true, Ansible collects a few hundred variables per host before the first task runs:

ansible_distribution          = "Debian"
ansible_distribution_release  = "trixie"        # Debian 13 = Proxmox VE 9
ansible_default_ipv4.address  = "192.168.0.1"
ansible_hostname              = "pve01"
ansible_memtotal_mb           = 16384

Your lab nodes run Proxmox VE 9, based on Debian 13 (“trixie”). Using { ansible_distribution_release } instead of hardcoding trixie keeps the playbook working when the next Debian release arrives. Inspect everything a node reports with:

ansible pve01 -m ansible.builtin.setup

Job 1: repositories

A fresh Proxmox VE installation has the enterprise repository enabled, which requires a subscription. Without one, every apt update ends in an error. In the lab we disable it and enable the no-subscription repository instead.

- name: Disable the enterprise repositories
  ansible.builtin.file:
    path: "{{ item }}"
    state: absent
  loop:
    - /etc/apt/sources.list.d/pve-enterprise.sources
    - /etc/apt/sources.list.d/ceph.sources
  notify: Update apt cache

- name: Enable the no-subscription repository
  ansible.builtin.deb822_repository:
    name: pve-no-subscription
    types: [deb]
    uris: http://download.proxmox.com/debian/pve
    suites: "{{ ansible_distribution_release }}"
    components: [pve-no-subscription]
    signed_by: /usr/share/keyrings/proxmox-archive-keyring.gpg
  notify: Update apt cache

deb822_repository writes the deb822 .sources format that Debian 13 and Proxmox VE 9 use natively.

Note

The no-subscription repository is intended for testing and evaluation. Production systems belong on the enterprise repository with a valid subscription. We use it here because the lab has no subscription, and because it is a realistic first automation task.

WarningVerify the keyring path

The file name of the Proxmox signing key has changed between releases. Check what your nodes actually have before you run the playbook:

ansible pve01 -m ansible.builtin.command -a 'ls /usr/share/keyrings/'

Put the correct path into group_vars instead of repeating it in the playbook.

Handlers

A handler is a task that runs only when notified, and only once per play no matter how many tasks notified it.

  handlers:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true

By default handlers run at the end of the play. Here we need the refreshed cache before installing packages, so we flush them explicitly:

    - name: Apply pending handlers now
      ansible.builtin.meta: flush_handlers

This is the standard pattern whenever a later task depends on a handler’s effect.

Is there an API equivalent?

For this module: mostly no, and that is the point. Package sources, installed packages and /etc/hosts are Debian concerns, and the Proxmox VE API does not model them. The one exception is the subscription status, which is why the enterprise repository exists at all:

TOKEN='PVEAPIToken=root@pam!ansible=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'   # pveum user token add root@pam ansible --privsep 0
PVE=https://192.168.0.1:8006/api2/json

# what the node thinks about its subscription
curl -sk -H "Authorization: $TOKEN" "$PVE/nodes/pve01/subscription" | jq

# available updates, as the GUI shows them
curl -sk -H "Authorization: $TOKEN" "$PVE/nodes/pve01/apt/update" | jq '.data[] | {Package, Version}'

# the repository status the GUI warns about
curl -sk -H "Authorization: $TOKEN" "$PVE/nodes/pve01/apt/repositories" | jq '.data.files[].path'

API reference: /nodes/{node}/apt and /nodes/{node}/subscription.

Useful for reading state; the actual base configuration happens in files on the node. From module 03 onwards, cluster and workload management moves to the API side.

Job 2: name resolution, verified

Every cluster node has to resolve the others, but that is not the playbook’s job here: DNS and /etc/hosts come from the provisioning, including the real domain of your lab. What the playbook does is confirm the assumption before the cluster depends on it.

- name: Verify that every node resolves the other cluster nodes
  ansible.builtin.command: "getent hosts {{ item }}"
  register: pve_resolve
  changed_when: false
  failed_when: pve_resolve.rc != 0
  loop: "{{ groups['pve'] | difference([inventory_hostname]) }}"
  loop_control: {label: "{{ item }}"}
NoteWhy not ansible.utils.resolvable?

There is a Jinja test for this, and it reads better:

that: "{{ item is ansible.utils.resolvable }}"

It is the wrong tool here, for the reason the Jinja section above gives: a test is evaluated by the templating engine, on the control node. That would check whether your desktop resolves the node names, which proves nothing about the nodes — and it is corosync on the node that needs to resolve its peers.

getent hosts runs as a task, on the node, which is where the question is. The test is a good fit for a pre-flight check about the control node, and it needs the ansible.utils collection, which this lab’s offline bundle does not carry.

Three ideas here return later:

  • groups['pve'] is the inventory as data. The playbook does not contain a list of nodes; it asks the inventory.
  • difference([inventory_hostname]) removes the node itself from the loop, so each node only checks its peers.
  • changed_when: false marks the task as read-only, which keeps a clean run at changed=0.

Add a fourth node to inventory/hosts.yml and the check covers it automatically.

WarningDo not let Ansible own /etc/hosts here

It is tempting to generate that file from the inventory, and in a platform you build from scratch it is a reasonable thing to do. In this lab it is not: the provisioning already writes it, together with the node’s real fully qualified domain name. Proxmox VE resolves its own node name through it, so a template that replaces the FQDN with an invented domain changes the node’s identity, quietly and on all three nodes at once.

The rule generalises beyond this workshop: before you automate a file, find out whether something else already owns it.

Jobs 3 and 4: packages and time

- name: Install the admin tools
  ansible.builtin.apt:
    name: "{{ pve_packages }}"
    state: present

- name: Check whether the clock is synchronised
  ansible.builtin.command: timedatectl show -p NTPSynchronized --value
  register: ntp_state
  changed_when: false
  check_mode: false

- name: Fail early if time is not in sync
  ansible.builtin.assert:
    that: ntp_state.stdout == 'yes'
    fail_msg: "Time is not synchronised - corosync will be unhappy"

changed_when: false tells Ansible that this command only reads. Without it, every run would report a change and your changed=0 proof would be gone.

assert turns an assumption into a check. A playbook that fails here, loudly and early, is much friendlier than a cluster that misbehaves an hour later.

Running it

ansible-playbook playbooks/10-node-prep.yml --check --diff   # dry run
ansible-playbook playbooks/10-node-prep.yml                  # for real
ansible-playbook playbooks/10-node-prep.yml                  # again

The third run should end in:

PLAY RECAP
pve01 : ok=7  changed=0  unreachable=0  failed=0  skipped=0
pve02 : ok=7  changed=0  unreachable=0  failed=0  skipped=0
pve03 : ok=7  changed=0  unreachable=0  failed=0  skipped=0

changed=0 is the evidence that your description matches reality. From here on, the playbook is documentation you can execute.

Flags you will use every day

Flag Effect
--check dry run: report what would change, change nothing
--diff show the actual difference for file-based modules
--limit pve02 restrict the run to one host or pattern
--start-at-task "…" resume at a named task
-v, -vvv more detail; -vvv includes the SSH conversation
Note

--check cannot predict everything. A task whose input depends on an earlier change may report incorrectly, and command/shell tasks are skipped entirely unless they declare check_mode: false. It still catches the majority of mistakes before they reach all three nodes at once.

Further reading

Playbook concepts from this module

The modules used here

Proxmox VE

Finding things offline

ansible-doc ansible.builtin.deb822_repository
ansible-doc -s ansible.builtin.deb822_repository  # task skeleton with all options