credativ hands-on workshop
formorer)alexander.wirt@credativ.de| min | |||
|---|---|---|---|
| 01 | Ansible in 30 minutes | inventory, ad-hoc, idempotency | 30 |
| 02 | Preparing the nodes | repositories, packages, time | 30 |
| 03 | Forming the cluster | quorum, serial joins | 40 |
| Break | 10 | ||
| 04 | From playbook to role | defaults, handlers, templates | 25 |
| 05 | Operating the cluster | storage, users, a cloud-init guest | 45 |
| 06 | Dynamic inventory | the plugin, tags as groups | 10 |
| 07 | Where to go next | 5 |
/etc/pve is cluster-wide — root’s authorized_keys includedchanged=0 as a test resultansible-doc, the module pagesThose are topics for dedicated follow-ups.
| Machine | Role | Address |
|---|---|---|
desktop |
Ansible control node (your desktop) | 192.168.0.254 |
pve01 |
Proxmox VE node 1 → cluster primary | 192.168.0.1 |
pve02 |
Proxmox VE node 2 | 192.168.0.2 |
pve03 |
Proxmox VE node 3 | 192.168.0.3 |
Log in as root with the password password123 — the same on all three nodes, for SSH and for the web GUI on :8006.
Every participant has an identical, isolated set.
Everything is on the share already mounted on your desktop. Run it once:
~/ansible-proxmox — the project you work in~/ansible-venv — Ansible with the Proxmox collection, put on your PATH~/workshop-slides — these slidesThen open a new terminal, or the PATH change is not in effect.
In the bookmark bar of your browser, put there by deploy.sh:
| Handouts, Exercises | one entry per module |
| Workshop | the page you start on: access, setup, all the PDFs |
| Material | the same documents as a site, with search |
| pve01–03, API viewer | the web GUIs of your own nodes |
Every module has a handout — the reference, with more detail than these slides — and an exercise sheet.
On desktop, in a terminal:
No password and no host key question: deploy.sh handled both. If any of these fail, say so now.
ansible --version looks wrongAnsible runs from a virtualenv on this desktop, and it should already be on your PATH. If the version is older than 2.17, or community.proxmox is missing, you are using the system Ansible instead:
Why a virtualenv: the collection community.proxmox 2.x needs a newer proxmoxer than Debian ships, and the packaged collection is missing two modules we use.
~/ansible-proxmox is not an empty directory:
TODO where the content goesWriting the files yourself works too. The exercise sheets contain everything either way.
ansible-core; the managed nodes only require Python.Which machines
What to do
The plan
Everything else (roles, handlers, variables, templates) is structure on top of these three.
all:
children:
pve: # group: all Proxmox VE nodes
hosts:
pve01: {ansible_host: 192.168.0.1}
pve02: {ansible_host: 192.168.0.2}
pve03: {ansible_host: 192.168.0.3}
vars:
ansible_user: root
pve_primary: # group with exactly one member
hosts: {pve01: null}
pve_secondary:
hosts: {pve02: null, pve03: null}A host may be in several groups. We will use exactly that in module 03.
Check that Ansible reads it the way you meant it:
ansible.cfg: stop typing the same flagshost_key_checking = False disables SSH host key verification. Acceptable in a throwaway lab, never in production. There you pre-seed known_hosts.
One module, one run, no file:
pve is the group from the inventory (all, pve01, pve* also work)-m names the module-a passes its argumentsYour key is on the nodes, so nothing asks for a password. Where it is not, --ask-pass asks once instead of once per task.
Useful for looking. For changing, write a playbook.
Ansible reads ansible.cfg from the current working directory, and that file is what points at your inventory. From anywhere else:
Three warnings, no error, nothing ran. Almost always a wrong cd, not a broken inventory.
A module that returns data:
SUCCESS means nothing changed, CHANGED means it did. The hosts come back in whatever order they finish — ad-hoc runs them in parallel.
command and shell answer differently: a header line, then raw output.
And when it fails, the return code comes with it:
| Word | Meaning |
|---|---|
SUCCESS |
already in the desired state, nothing done |
CHANGED |
Ansible changed something |
FAILED |
the task ran and did not succeed |
UNREACHABLE |
Ansible never got to the host |
You describe the target state, not the steps.
changedokA playbook that reports only ok still did its job: it checked every statement and found reality already matching.
command is the last resortAnsible cannot know what your command does, so it reports changed even for reading a version number. You saw that in exercise 01.
Look for a module first. Nearly everything you would type on a Proxmox VE node has one, or is reachable through the API.
Then the guard is yours to write:
creates: — skip the task when that path already existswhen: — a condition of your ownchanged_when: / failed_when: — decide yourself what countscommand, with every guard listed: https://docs.ansible.com/ansible/latest/collections/ansible/builtin/command_module.htmlEvery module page online has the same content as ansible-doc <module> on your desktop, which is offline and matches the version you are actually running.
Optional block at the end if you get there. → Exercise sheet: Ansible in 30 minutes
--check --diff before you mean itBefore 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.
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: 3600name: on every task. It is what you read in the output and in the logs.group_varspve{ pve_packages }{ } is JinjaAnything inside { } is an expression, evaluated on the control node before the task is sent anywhere.
| pipes a value through a filter — here: this group, minus myself{{, or YAML reads the brace as a dictgather_facts: true collects a few hundred variables per host before the first task:
Use them instead of hard-coding:
Your lab runs Proxmox VE 9 on Debian 13. We use the fact instead of hardcoding trixie.
loop: takes a list; the task runs once per entryitemloop_control: {label: "{{ item.name }}"} keeps the output readable when the entries are longLoops · offline: ansible-doc -t lookup items
- 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 cacheWe need the cache before installing packages, so we force it:
It comes from the provisioning, with DNS and /etc/hosts. The playbook confirms it instead of rewriting it:
register: stores the result in a variable — the next line reads its exit codechanged_when: false because reading changes nothing, and command would otherwise report changed every rungroups['pve'] | difference([inventory_hostname]): the inventory at runtime, minus this node/etc/hosts?Because something else already owns it.
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.
- 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"Your 10-node-prep.yml is still a skeleton — filling it in is the exercise:
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.
--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.
Optional block at the end if you get there. → Exercise sheet: Preparing the nodes
community.proxmoxOne shared configuration filesystem (/etc/pve), one GUI for all three, quorum.
Works everywhere · you own the guards · parsing output is on you
We use the collection module. The CLI command path is in the handout as a fallback.
community.proxmox ships around sixty modules plus an inventory plugin.
The Proxmox modules used to live in community.general. They moved to their own collection and were removed from community.general in version 11. Old examples on the internet still say community.general.proxmox_kvm.
proxmoxer and requests must exist where the task runsdelegate_to: localhostThe play still iterates over pve02 and pve03. You keep the per-host variables and only move the execution.
In inventory/group_vars/pve.yml:
playbooks/20-cluster.yml
- name: Create the cluster on the primary node
hosts: pve_primary
gather_facts: false
tasks:
- name: Ensure the cluster exists
community.proxmox.proxmox_cluster:
state: present
api_host: "{{ ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
cluster_name: "{{ pve_cluster_name }}"
link0: "{{ ansible_host }}"
delegate_to: localhostlink0 pins corosync to the management network. Be explicit; do not let it guess.
- name: Join the remaining nodes
hosts: pve_secondary
gather_facts: false
serial: 1 # one node after the other
vars:
primary: "{{ groups['pve_primary'][0] }}"
tasks:
- name: Read the join information from the primary
community.proxmox.proxmox_cluster_join_info:
api_host: "{{ hostvars[primary].ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
delegate_to: localhost
register: join_info - name: Join this node to the cluster
community.proxmox.proxmox_cluster:
state: present
api_host: "{{ ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
master_ip: "{{ hostvars[primary].ansible_host }}"
fingerprint: "{{ (join_info.cluster_join.nodelist
| selectattr('name', 'equalto', primary)
| first).pve_fp }}"
link0: "{{ ansible_host }}"
delegate_to: localhostThe fingerprint is how the joining node checks it is talking to the right cluster.
join_info is whatever the module returned. To find out what is in it, print it:
var: prints a variable, msg: prints a string you build yourself. Between them and the module’s RETURN VALUES section in ansible-doc, you never have to guess what a registered result contains.
serial: 1 mattersWithout it, all plays run in parallel and two nodes try to join the same cluster at the same moment.
serial: 1 turns a race condition into a queue. It costs a few seconds and removes a class of bug that is unpleasant to debug later.
The API accepts the join and returns immediately. The node is not a member yet.
- name: Wait until this node is a quorate cluster member
community.proxmox.proxmox_cluster_status_info:
api_host: "{{ ansible_host }}"
api_user: root@pam
api_password: "{{ pve_root_password }}"
validate_certs: false
delegate_to: localhost
register: cl
until: cl.cluster_status | default([])
| selectattr('type', 'equalto', 'cluster')
| selectattr('quorate', 'equalto', true) | list | count > 0
retries: 30
delay: 5default([]) matters: the node’s API restarts during the join, and without it a single failed call aborts the loop instead of retrying.
- name: Wait until the primary reports a quorate cluster
community.proxmox.proxmox_cluster_status_info:
...
register: primary_status
until: primary_status.cluster_status | default([])
| selectattr('type', 'equalto', 'cluster')
| selectattr('quorate', 'equalto', true) | list | count > 0
retries: 30
delay: 5Skip this and the first join fails with cluster not ready - no quorum?. Waiting for port 8006 does not help: pveproxy never went away.
Your 20-cluster.yml is still a stub — filling it in is the exercise. This is what it does once it is not:
Then check, from Ansible and from the node:
Run the playbook a second time: everything ok, nothing changed.
Three nodes, three votes, quorum at 2.
| Situation | Quorate | Effect |
|---|---|---|
| 3 of 3 online | yes | normal operation |
| 2 of 3 online | yes | full operation, no redundancy left |
| 1 of 3 online | no | /etc/pve becomes read-only |
A non-quorate node cannot start guests or change configuration. That is a feature: it prevents two halves of a split cluster from both believing they are in charge.
/etc/pveRoot’s authorized keys are not a per-node file. They live in pmxcfs, which makes them cluster-wide.
A joining node receives the primary’s /etc/pve. Keys that existed only on that node are gone afterwards.
Your key is on all three nodes since deploy.sh, so the join changes nothing for you. On a cluster you build for someone else, it decides whether you can still log in.
Optional block at the end if you get there. → Exercise sheet: Forming the cluster
10-node-prep.yml into a role without changing what the nodes end up asdefaults/, logic in tasks/, files in templates/Your 10-node-prep.yml is 60 lines. It will not stay that way.
A role is a directory layout with conventions, nothing more.
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, metadataAnsible loads main.yml from each of these automatically. You do not wire anything up yourself.
defaults/ is the role’s interfaceroles/pve_node/defaults/main.yml
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.
From weakest to strongest, the parts you will actually meet:
roles/x/defaults/main.ymlinventory/group_vars/inventory/host_vars/roles/x/vars/main.ymlvars: in the play--extra-vars on the command lineThe working rule: put values in defaults/, override them in group_vars/. That covers almost every case.
A template renders a file from inventory data and facts. The classic first one is a message of the day:
templates/motd.j2
The logic leaves the YAML. Diffs become readable, and the template can be inspected on its own.
template owns the whole fileThat 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.
A tag is a label on a task. Once the tasks carry them, you can run a subset instead of the whole play.
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.
ansible-lintIt catches the things reviewers would otherwise catch for you: missing name:, shell where command suffices, deprecated syntax, unsafe file permissions.
Optional block at the end if you get there. → Exercise sheet: From playbook to role
module_defaultsOne API call, one cluster-wide change. You address any node; pmxcfs distributes the result.
Every node serves the full API reference, for exactly the version you are running:
Use it when a module lacks an option. The page tells you whether the API has it at all — which decides between “file a feature request” and “use ansible.builtin.uri”.
POST /nodes/{node}/qemu in the API browser lists vmid, cores, memory, scsihw — the same names. That correspondence is what makes the browser the fastest way to find out what a guest option is actually called.
module_defaultsEvery community.proxmox module in this play inherits those four lines.
The node is a Debian machine and a Proxmox VE node.
The backup directory is local to each node. An NFS library is shared, and it is where images and templates live in a real environment:
/mnt/pve/nfs-sharedcontent decides what may live there; no images, so nothing gets allocated on it- community.proxmox.proxmox_group:
name: automation
comment: Service accounts
- community.proxmox.proxmox_user:
name: automation@pve
groups: [automation]
comment: Ansible service account
enable: true
- community.proxmox.proxmox_access_acl:
path: /
type: group
ugid: automation
roleid: PVEVMAdmin
propagate: 1Realms matter: @pam are Linux users on the node, @pve exist only in Proxmox VE.
automation_user.secretsprivsep: false gives the token the user’s permissions; true restricts it further with its own ACLNever print a secret with debug in a shared session. Write it somewhere safe, ideally straight into an encrypted file.
A token can be revoked on its own, is scoped by ACL, and never unlocks an SSH session. The root password can do none of those things.
- community.proxmox.proxmox_kvm:
node: "{{ pve_vm_node }}"
vmid: "{{ pve_vm_id }}"
name: "{{ pve_vm_name }}"
cores: 1
memory: 1024
ostype: l26
scsihw: virtio-scsi-single
scsi:
scsi0: "{{ pve_vm_storage }}:0,import-from={{ pve_cloud_image_path }}"
ide:
ide2: "{{ pve_vm_storage }}:cloudinit"
net:
net0: virtio,bridge=vmbr0
boot: "order=scsi0"
timeout: 300 # NFS import is slower than the 30 s default
state: present:0 means “size taken from the source image”import-from reads the qcow2 straight off the NFS libraryide2: …:cloudinit adds the cloud-init drivecan't find fileWaiting for the path does not help: nothing has asked the node to mount it yet.
The guest configures its user, its SSH key and its address on first boot. No installer, no answer file, no template maintenance.
cipassword alone will not get you in: the Debian cloud image ships with PasswordAuthentication no. Set sshkeys.
ciuser, sshkeys, ipconfig is the whole of what Proxmox VE generates. Packages, files, commands come from a cloud-config file on a storage with snippets content:
Important
vendor= is applied in addition to what Proxmox VE generates. user= replaces it — same syntax, and your key is gone.
Import, boot and cloud-init together take well under a minute on this lab.
The template has to be on the storage first; proxmox_template downloads it. Never hard-code the file name; ask the node which ones exist:
state covers the whole life of a guest, not just its creation:
started stopped restarted |
power |
paused hibernated |
suspend to RAM, suspend to disk |
present absent |
the guest exists, or it does not |
template |
freeze it as a template to clone from |
current |
read the state, change nothing |
No node: — for an existing guest the cluster already knows where it runs. stopped is a graceful ACPI shutdown; force: true pulls the plug.
Declaring a state, not performing an action. Same for started, absent.
absent on a running guest neither deletes it nor fails:
Important
ok, changed: false, failed=0 in the recap — and the guest is still running. The only sign that nothing happened is the msg. Set state: stopped first, or add force: true and the module stops it for you.
15 seconds on this lab, running guest, no interruption. A second run reports VM 9001 is already on pve01.
Drop with_local_disks and the node refuses before it starts:
The module does not see that. It polls until timeout and then says Reached timeout while waiting for migrating VM — ten minutes of waiting for a job that died in the first second.
Note
Why the disks matter: local-lvm lives on one node, so a migration has to copy it. On shared storage there is nothing to copy. When a migration hangs, the reason is in the node’s task log — GUI, or pvesh get /nodes/pve01/tasks.
Not everything has a module, and some things are simply faster to write directly. A second bridge, over SSH:
- name: Add a second bridge
hosts: pve
tasks:
- name: Define vmbr1 in /etc/network/interfaces
ansible.builtin.blockinfile:
path: /etc/network/interfaces
marker: "# {mark} ANSIBLE MANAGED - vmbr1"
block: |
auto vmbr1
iface vmbr1 inet manual
bridge-ports none
bridge-stp off
bridge-fd 0
notify: Reload network configurationifreload -a applies the change immediately. A mistake in the file for vmbr0 takes the node off the network, including your own SSH session. Always keep a console (VDI, IPMI) available, and never touch the management interface of all three nodes in one run.
/etc/network/interfaces.new, the same pending state the GUI showsifreload -aBoth routes end in the same file. Only one of them is idempotent and reviewable before it takes effect.
blockinfile over SSH |
proxmox_node_network |
|
|---|---|---|
| Idempotent | you build the guard | yes |
| Visible in the GUI as pending | no, it is already in the live file | yes |
| Works without the collection | yes | no |
| Risk of locking yourself out | high | lower, but real |
Use the module when it covers your case. Reach for the file when it does not, and then go carefully: one node at a time, --limit, console at hand.
wait: true makes the task finish when the backup finishes, not when the API has accepted the request.
Creating a scheduled backup job is not covered by a module today. proxmox_backup_schedule only adds guests to a job that already exists.
ansible.builtin.uri against the REST API works for anything. You lose idempotency, so you add the guard yourself, the same way you do with command.
Optional block at the end if you get there. → Exercise sheet: Operating the cluster
community.proxmox.proxmox inventory pluginThe cluster already knows all of this. Ask it.
inventory/lab.proxmox.yml
The file name must end in .proxmox.yml or .proxmox.yaml. Otherwise the plugin politely ignores it and you debug an empty inventory.
Groups per node, per type, per status, per pool. Generated, never maintained.
With want_facts: true every guest carries its Proxmox configuration:
Tag a guest in Proxmox VE, group on it in Ansible:
The person creating the VM decides its role, in the GUI, with a tag, and your playbooks follow.
Or set both in ansible.cfg:
The nodes stay static, because they have to exist before anything is dynamic. The guests come from the cluster.
Optional block at the end if you get there. → Exercise sheet: Dynamic inventory
~/ansible-proxmox/
├── ansible.cfg
├── requirements.yml
├── site.yml
├── inventory/
│ ├── hosts.yml # the three nodes
│ ├── lab.proxmox.yml # everything else, from the API
│ └── group_vars/
├── playbooks/
│ ├── 20-cluster.yml # forms the cluster
│ └── 30-operations.yml # storage, users, guests, backup
└── roles/pve_node/ # the reusable partProxmox VE
/etc/pve is cluster-wide. pmxcfs distributes it — root’s authorized_keys includedAnsible, as far as that needed
changed=0 is a test result| Topic | Where it belongs |
|---|---|
| Ceph, HA, SDN, QDevice | Proxmox VE Clustering & Shared Storage |
| Cloud-init images, golden templates | a VM lifecycle workshop |
| AWX / Ansible Automation Platform, Semaphore | an Ansible operations workshop |
| Molecule, CI for roles | an Ansible testing workshop |
| Ansible for the guests inside the VMs | a general Ansible course |
Your playbooks currently run from your laptop. That works exactly as long as it is only you.
ansible-lint and --check on every merge requestIn that order.
The bonus exercises go further than the workshop had time for:
ansible.builtin.uriSolutions included. They assume the state you have right now.
https://<node>:8006/pve-docs/api-viewer/ansible-doc <module>Bookmark the third and the fifth. Those are the two you will actually use.
Afterwards, and for anything longer: alexander.wirt@credativ.de
Questions, discussion, and code:
code/ are yours to keepcredativ · Ansible for Proxmox VE