Preparing the Nodes

Ansible for Proxmox VE · Module 02

credativ GmbH

Learning goals

  • Write and run your first playbook
  • Fix the repositories, install packages, and make the nodes resolve each other
  • Use variables, facts, loops and handlers where they belong
  • Try a change safely with --check --diff before you mean it

Why this module exists

Before three Proxmox VE nodes can become a cluster, they need:

Requirement Why
Working package sources the enterprise repo fails without a subscription
Name resolution for each other corosync and pvecm resolve node names
Synchronised time corosync membership is time-sensitive
The same base state “it works on pve01” is not a plan

Four requirements, four groups of tasks. That is the playbook.

Playbook structure

A play, in full

playbooks/10-node-prep.yml
---
- name: Prepare the Proxmox VE nodes
  hosts: pve              # which machines (a group from the inventory)
  gather_facts: true      # collect facts first (distribution, IPs, ...)
  tasks:

    - name: Install the admin tools we want everywhere
      ansible.builtin.apt:
        name: "{{ pve_packages }}"
        state: present
        update_cache: true
        cache_valid_time: 3600
  • name: on every task. It is what you read in the output and in the logs.
  • Module arguments are YAML, not a command line

Variables live in group_vars

inventory/group_vars/pve.yml
pve_packages:
  - vim
  - curl
  - jq
  - tmux
  • Applies to every host in the group pve
  • Referenced as { pve_packages }
  • Change the list, re-run, done. No playbook edit.

Facts: what Ansible found out

gather_facts: true collects a few hundred variables per host before the first task:

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

Use them instead of hard-coding:

suites: "{{ ansible_distribution_release }}"

Your lab runs Proxmox VE 9 on Debian 13. We use the fact instead of hardcoding trixie.

The four jobs

1. Repositories

- 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

Handlers: react, but only once

  handlers:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
  • A handler runs only if notified, and only once per play
  • By default it runs at the end of the play

We need the cache before installing packages, so we force it:

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

2. Name resolution: check, do not configure

Name resolution comes from the provisioning, together with DNS and /etc/hosts. The playbook confirms it instead of rewriting 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]) }}"
  • groups['pve'] is the inventory, read at runtime
  • difference([inventory_hostname]) drops the node itself from the loop

Why not just write /etc/hosts?

Because something else already owns it.

  • Proxmox VE resolves its own node name through that file
  • the provisioning sets it, together with the real domain
  • a template that “helpfully” rewrites it replaces the node’s FQDN with whatever domain you invented

Automating a file that another system owns is how you break a platform quietly. Check the state you depend on; configure only what is yours.

3. Packages · 4. 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     # this only reads, so never report a change
  check_mode: false       # ... so it is safe to run it in --check too

- 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"

Run it

# dry run first: what WOULD change?
ansible-playbook playbooks/10-node-prep.yml --check --diff

# for real
ansible-playbook playbooks/10-node-prep.yml

# again - and now read the recap line
ansible-playbook playbooks/10-node-prep.yml
# first run
pve01 : ok=8  changed=4  unreachable=0  failed=0  skipped=0
# second run
pve01 : ok=7  changed=0  unreachable=0  failed=0  skipped=0

changed=0 on the second run is your proof that the playbook is idempotent. One task fewer, too: the handler only runs when something notifies it.

Flags you will use every day

--check --diff     # dry run, and show the difference
--limit pve02       # only this host
--tags repos       # only tasks carrying this tag
-v / -vvv          # more output when something is odd
--start-at-task "Install the admin tools"

--check is not perfect: a task that depends on an earlier change cannot know the future. It still catches most mistakes before they reach three nodes at once.

Exercise 02 — 15 minutes

Write playbooks/10-node-prep.yml, run it in check mode, then for real, then again.

→ Exercise sheet: Preparing the nodes