From Playbook to Role

Ansible for Proxmox VE · Module 04

credativ GmbH

Learning goals

  • Know what a role is and when to use one
  • Move 10-node-prep.yml into a role without changing its behaviour
  • Put values in defaults/, logic in tasks/, files in templates/
  • Understand enough about variable precedence to stay out of trouble

The problem roles solve

Your 10-node-prep.yml is 60 lines. It will not stay that way.

  • The same tasks are needed in the next project
  • Values and logic are mixed in one file
  • Two people editing one playbook produce one merge conflict

A role is a directory layout with conventions, nothing more.

The layout

roles/pve_node/
├── defaults/main.yml     # values, lowest precedence - meant to be overridden
├── vars/main.yml         # values that are NOT meant to be overridden
├── tasks/main.yml        # the tasks (no "hosts:", no "- name: play")
├── handlers/main.yml     # the handlers
├── templates/            # Jinja2 templates (.j2)
├── files/                # files copied verbatim
└── meta/main.yml         # dependencies, metadata

Ansible loads main.yml from each of these automatically. You do not wire anything up yourself.

Before and after

Before

- name: Prepare the nodes
  hosts: pve
  tasks:
    - name: Disable ...
      ansible.builtin.file:
        ...
  handlers:
    - name: Update apt cache
      ...

After

site.yml
- name: Prepare the nodes
  hosts: pve
  roles:
    - pve_node

The 60 lines move to roles/pve_node/tasks/main.yml, unchanged.

defaults/ is the role’s interface

roles/pve_node/defaults/main.yml
pve_packages: [vim, curl, jq, tmux]
pve_repo_component: pve-no-subscription
pve_keyring: /usr/share/keyrings/proxmox-archive-keyring.gpg

Anyone using your role can override any of these. That is the contract.

vars/main.yml is the opposite: values the role needs and callers should not touch. Use it sparingly, because it outranks almost everything.

Variable precedence, the short version

From weakest to strongest, the parts you will actually meet:

  1. roles/x/defaults/main.yml
  2. inventory/group_vars/
  3. inventory/host_vars/
  4. roles/x/vars/main.yml
  5. vars: in the play
  6. --extra-vars on the command line

The working rule: put values in defaults/, override them in group_vars/. That covers almost every case.

Templates

A template renders a file from inventory data and facts. The classic first one is a message of the day:

tasks/main.yml
- name: Render the message of the day from the inventory
  ansible.builtin.template:
    src: motd.j2
    dest: /etc/motd
    owner: root
    group: root
    mode: "0644"
  tags: [motd]

The template itself

templates/motd.j2
This node is managed by Ansible (role pve_node).

  node    : {{ inventory_hostname }} ({{ ansible_host }})
  cluster : {{ pve_cluster_name | default('not configured') }}
  peers   : {{ groups['pve'] | difference([inventory_hostname]) | sort | join(', ') }}

The logic leaves the YAML. Diffs become readable, and the template can be inspected on its own.

template owns the whole file

That is the difference to lineinfile or blockinfile, which own a marked region.

blockinfile template
Owns a marked region the whole file
Foreign changes survive are overwritten
Use when the file has other owners the file is yours alone

/etc/motd is yours. /etc/hosts is not — the provisioning writes it, and Proxmox VE resolves its own node name through it. Pick the file before you pick the module.

Tags belong to the role, too

- name: Disable the enterprise repositories
  ansible.builtin.file: ...
  tags: [repos]
ansible-playbook site.yml --tags repos
ansible-playbook site.yml --skip-tags packages

A tagged partial run can leave a host in a state that no full run would produce. Tags are for fast iteration while developing, not a substitute for structure.

One more habit: ansible-lint

pip install --user ansible-lint
ansible-lint site.yml roles/

It catches the things reviewers would otherwise catch for you: missing name:, shell where command suffices, deprecated syntax, unsafe file permissions.

Exercise 04 — 10 minutes

Convert 10-node-prep.yml into the role pve_node and prove that nothing changed: the first run after the conversion must report changed=0.

→ Exercise sheet: From playbook to role

Exercise 04 · Solutions

1 — The defaults

Uncomment roles/pve_node/defaults/main.yml, then delete pve_packages from inventory/group_vars/pve.yml.

grep -rn pve_packages inventory/ roles/
# roles/pve_node/defaults/main.yml:5:pve_packages:

Leave it in group_vars and group_vars keeps outranking the role default — the interface is then decorative.

2 — Scope, the trap

ansible pve01 -m ansible.builtin.debug -a 'var=pve_packages'
pve01 | SUCCESS => {
    "pve_packages": "<< error 1 - 'pve_packages' is undefined >>"
}

Correct behaviour, not a broken refactoring: role defaults are in scope only while the role runs.

3 — The proof

# first real run
pve01 : ok=8  changed=1  unreachable=0  failed=0
# second run
pve01 : ok=8  changed=0  unreachable=0  failed=0

The one changed is /etc/motd — the flat playbook never wrote it. Everything else was already in the state the role describes.