Introductie
Tools zoals CFEngine, Puppet, Chef en Ansible hebben infrastructure automation ingrijpend veranderd door een gestructureerd framework te bieden voor het organiseren en delen van configuration management code, waarbij Ansible een van de nieuwere tools is. Ansible wordt breed gebruikt om de configuratie van je IT-omgeving te automatiseren en is zeer flexibel. Hoewel de makers Ansible positioneren als een tool die “open-source automation biedt die simpel, flexibel en krachtig is”, is het niet altijd duidelijk wat robuuste Ansible code precies robuust maakt. En zo simpel als het klinkt is het in de praktijk niet altijd—zeker niet in complexere omgevingen en setups.
Binnen Ansible zijn roles een concept waarmee je code inkapselt. In roles zitten tasks, variables, handlers en files, samengebracht in een modulaire, herbruikbare unit die, als je hem goed schrijft, eenvoudig te delen is tussen projecten en teams. Of je nu een handvol servers beheert of complexe (multi-)cloud omgevingen orkestreert: roles helpen je schonere, beter onderhoudbare code te schrijven. Tegelijkertijd zijn er veel uitdagingen, en het is lastig om een role te ontwerpen die je als robuust en “compleet” zou bestempelen. In dit blog introduceren we daarom een aantal vuistregels voor wat een robuuste Ansible role kenmerkt, zodat je betere Ansible roles kunt schrijven.
Een aantal vuistregels
De vuistregels in deze blog zijn:
- De main.yml van een role gebruik je alleen om tasks te includen of importeren
- Alle tasks moeten geoptimaliseerd zijn voor efficiëntie
- Data en code moeten volledig gescheiden zijn
- Tasks moeten op include-basis draaien
- Roles moeten check mode correct afhandelen
- Alle tasks moeten idempotent zijn
- Variabelen moeten de correct namespace en gehiërarchiseerd zijn
- Tags moeten geïmplementeerd én gedocumenteerd zijn
- Roles moeten gevalideerd worden
- Roles moeten gedocumenteerd worden
Let op: dit zijn vuistregels. Afhankelijk van het gebruik en de requirements van een role kunnen sommige regels minder relevant zijn of juist extra regels nodig zijn. Over het algemeen zijn ze in veel situaties toepasbaar. Hieronder werken we ze één voor één uit, met context en voorbeelden.
Illustraties met een role: linux_user
Om de vuistregels te illustreren maken we een role die Linux-users kan aanmaken. Dat doen we als volgt:
# Creation of a basic role structure using ansible-galaxy
ansible-galaxy role init linux_user
# Navigate into the new role
cd linux_user/
# Create a supplementary tasks file
touch tasks/linux_user.yml
Copy code
Onze directorystructuur ziet er nu als volgt uit:
# ls ./*
./README.md
./defaults:
main.yml
./files:
./handlers:
main.yml
./meta:
main.yml
./tasks:
linux_user.yml main.yml
./templates:
./tests:
inventory test.yml
./vars:
main.yml
Copy code
Dit biedt ons een basisstructuur om mee te werken. In main.yml nemen we de taken op voor alle user-gerelateerde taken:
# cat tasks/main.yml
---
- name: Include Linux user tasks
loop: "{{ linux_users }}"
loop_control:
loop_var: user
label: "{{ user }}"
tags:
- linux_user
- linux_user_authorized_keys
- linux_user_create
when:
- ansible_facts["os_family"] in linux_user_allowed_os_families
- linux_user_allowed_target_hostgroups | intersect(group_names)
ansible.builtin.include_tasks: linux_user.yml
Copy code
Het hierboven opgenomen linux_user.yml-bestand bevat de daadwerkelijke taken voor het aanmaken van users.
# cat tasks/linux_user.yml
---
- name: Create user "{{ user.name }}"
become: true
notify:
- "Send mail notification about user creation"
tags:
- linux_user
- linux_user_create
ansible.builtin.user:
# Required
name: "{{ user.name }}"
# Optional
append: "{{ user.append | default(omit) }}"
comment: "{{ user.comment | default(omit) }}"
create_home: "{{ user.create_home | default(omit) }}"
force: "{{ user.force | default(omit) }}"
group: "{{ user.group | default(omit) }}"
groups: "{{ user.groups | default(omit) }}"
hidden: "{{ user.hidden | default(omit) }}"
home: "{{ user.home | default(omit) }}"
password: "{{ user.password | default(omit) }}"
password_expire_account_disable: "{{ user.password_expire_account_disable | default(omit) }}"
password_expire_max: "{{ user.password_expire_max | default(omit) }}"
password_expire_min: "{{ user.password_expire_min | default(omit) }}"
password_expire_warn: "{{ user.password_expire_warn | default(omit) }}"
password_lock: "{{ user.password_lock | default(omit) }}"
remove: "{{ user.remove | default(omit) }}"
shell: "{{ user.shell | default(omit) }}"
state: "{{ user.state | default(omit) }}"
system: "{{ user.system | default(omit) }}"
uid: "{{ user.uid | default(omit) }}"
umask: "{{ user.umask | default(omit) }}"
update_password: "{{ user.update_password | default(omit) }}"
- name: Set authorized keys for user "{{ user.name }}"
become: true
loop: "{{ user.authorized_keys | default(linux_user_default_authorized_keys) }}"
loop_control:
loop_var: key
label: "{{ key.key }}"
tags:
- linux_user
- linux_user_authorized_keys
ansible.posix.authorized_key:
# Required
key: "{{ key.key }}"
user: "{{ user.name }}"
# Optional
comment: "{{ key.comment | default(omit) }}"
exclusive: "{{ key.exclusive | default(omit) }}"
key_options: "{{ key.key_options | default(omit) }}"
path: "{{ key.path | default(omit) }}"
state: "{{ key.state | default(omit) }}"
Copy code
In het bovenstaande takenbestand is een handler geconfigureerd met de naam ‘Send mail notification about user creation’, die er als volgt uitziet:
# cat handlers/main.yml
---
- name: Send mail notification about user creation
delegate_to: localhost
when: linux_user_smtp_enabled
community.general.mail:
body: "{{ linux_user_smtp_body }}"
from: "{{ linux_user_smtp_mail_from }}"
host: "{{ linux_user_smtp_host }}"
port: "{{ linux_user_smtp_port }}"
subject: "{{ linux_user_smtp_subject }}"
to: "{{ linux_user_smtp_mail_to }}"
Copy code
Zoals je ziet worden er veel variabelen gebruikt. Die definiëren we in defaults en vars:
# cat defaults/main.yml
---
# Users
linux_user_default_authorized_keys: []
linux_users: []
# SMTP
linux_user_smtp_enabled: false
linux_user_smtp_body: "User mutations executed on {{ ansible_facts.hostname }}"
linux_user_smtp_host: ""
linux_user_smtp_mail_from: ""
linux_user_smtp_mail_to: ""
linux_user_smtp_port: ""
linux_user_smtp_subject: "User mutations"
# cat vars/main.yml
---
# Control
linux_user_allowed_os_families:
- "RedHat"
linux_user_allowed_target_hostgroups:
- "dev_servers"
Copy code
Vuistregels in de praktijk
1. Gebruik main.yml alleen om tasks te includen/importeren
Als je main.yml direct gebruikt om veel tasks te definiëren, wordt een role al snel chaotisch en moeilijk te lezen. Een best practice is daarom om main.yml alleen te gebruiken voor het includen of importeren van andere tasks, zoals in het voorbeeld.
2. Optimaliseer tasks voor efficiëntie
Tasks moeten efficiënt zijn. In ons voorbeeld gebruiken we include_tasks in plaats van import_tasks. Importen is meestal sneller omdat Ansible tasks dan vooraf “preprocesses”, terwijl includen tijdens runtime gebeurt.
Waarom dan toch include_tasks? Omdat import_tasks geen loop op de import statement zelf ondersteunt. Als je met import_tasks werkt, moet je de loop in linux_user.yml doen. Maar omdat we óók authorized_keys configureren, moet je dan mogelijk dubbel loopen. Afhankelijk van de grootte van je runtime kan dat juist trager zijn dan één runtime-loop met include_tasks. In dit scenario is include_tasks dus efficiënter.
# cat tasks/main.yml
---
- name: Include Linux user tasks
tags:
- linux_user
- linux_user_authorized_keys
- linux_user_create
when:
- ansible_facts["os_family"] in linux_user_allowed_os_families
- linux_user_allowed_target_hostgroups | intersect(group_names)
ansible.builtin.import_tasks: linux_user.yml
# cat tasks/linux_user.yml
- name: Create user "{{ user.name }}"
become: true
loop: "{{ linux_users }}"
loop_control:
loop_var: user
label: "{{ user }}"
notify:
- "Send mail notification about user creation"
tags:
- linux_user
- linux_user_create
ansible.builtin.user:
{...}
Copy code
Dit preprocesset de user creation task en zou inderdaad sneller zijn als we alleen die taak hadden. In ons geval hebben we echter ook een taak om authorized SSH keys voor alle users te configureren. In die authorized key-taak zouden we opnieuw moeten itereren, omdat dit niet gebeurt bij de import statement. Twee keer itereren over de users, vooraf berekend, kost uiteindelijk meer tijd dan dit één keer tijdens runtime te doen (afhankelijk van de grootte van de runtime). Daarom is include_tasks in deze situatie sneller en zijn onze taken geoptimaliseerd voor efficiëntie.
3. Scheid data en code volledig
Houd user data buiten de role. Variables moeten extern worden meegegeven (via playbooks, inventory, group_vars/host_vars, of andere methoden). Bijvoorbeeld:
---
- name: Run role
hosts: localhost
vars:
linux_user_smtp_host: "smtp.sue.nl"
linux_user_smtp_mail_from: "source@sue.nl"
linux_user_smtp_mail_to: "recipient@sue.nl"
linux_user_smtp_port: "25"
linux_users:
- name: user1
state: absent
- name: user2
roles:
- role: sue.generic.linux_user
Copy code
4. Laat tasks draaien op include-basis
Automation is krachtig, maar kan ook schade veroorzaken als je per ongeluk de verkeerde machines target. In het voorbeeld includen we user tasks alleen als aan voorwaarden wordt voldaan:
when:
- ansible_facts["os_family"] in linux_user_allowed_os_families
- linux_user_allowed_target_hostgroups | intersect(group_names)
Copy code
Deze conditionals zorgen ervoor dat de taken alleen worden uitgevoerd op vooraf gedefinieerde toegestane OS-families en specifieke toegestane hostgroups van servers. Door taken conditioneel te includen waar nodig, in plaats van ze op alle machines uit te voeren en bepaalde machines uit te sluiten, voorkomen we dat users per ongeluk worden aangemaakt op systemen waarvoor ze geen toegang nodig hebben. Dit verhoogt de security.
5. Rollen moeten check mode correct afhandelen
Check mode handling moet per taak worden geïmplementeerd en check mode moet de echte run volledig benaderen: dezelfde fouten afvangen en dezelfde output geven. Om dit te bereiken moet je het check mode-gedrag voor elke afzonderlijke taak meenemen. Soms betekent dit bijvoorbeeld dat je check_mode voor een taak moet uitschakelen.
Om dit te illustreren: stel dat we verwachten dat er een lokaal bestand bestaat met het wachtwoord van onze users en dat we dit bestand willen uitlezen met de shell module. Er zijn natuurlijk betere manieren om dit te doen, en er zijn ook betere manieren om secrets op te slaan, zoals een dynamisch roterend secret zoals beschreven in onze blogpost ‘Creating Automatically Rotating Secrets Using Terraform’. Maar door hier de shell module te gebruiken, laten we goed zien waarom check mode altijd de daadwerkelijke run moet benaderen.
Er zit een kanttekening aan dit scenario: het bestand met het wachtwoord bestaat in werkelijkheid niet. We zouden het wachtwoord kunnen ophalen met iets in de trant van:
- name: Retrieve user password
changed_when: false
check_mode: false
failed_when: user_password_result.rc != 0
register: user_password_result
vars:
password_file: "/tmp/password.txt"
ansible.builtin.shell: |
if [ -f "{{ password_file }}" ]; then
cat "{{ password_file }}"
else
echo "Password file not found: {{ password_file }}"
exit 1
fi
Copy code
Let op dat check_mode hier op false is gezet, waardoor deze taak altijd wordt uitgevoerd. Waarom? Omdat de shell module niet draait in check mode, en dat kan zorgen voor een verschil tussen check mode en de daadwerkelijke run. Als het wachtwoordbestand niet bestaat en we in check mode draaien, zal de taak geen fout genereren (omdat het script niet wordt uitgevoerd en dus ook niet faalt doordat het bestand ontbreekt). Bij een run zonder check mode zal de taak wél falen, omdat het bestand niet bestaat. Wanneer we in deze taak check_mode niet expliciet op false zetten, zou check mode dus geen goede afspiegeling zijn van de daadwerkelijke run.
6. Maak tasks idempotent
Alle taken moeten meer dan één keer kunnen draaien en hetzelfde resultaat hebben als er niets verandert. Een taak met deze eigenschap noemen we idempotent. Meestal hoef je je hier geen zorgen over te maken, omdat de meeste modules idempotency al afhandelen. In edge cases — bijvoorbeeld bij het gebruik van de command, shell of lineinfile modules, of wanneer je je eigen modules schrijft — is het belangrijk om idempotency expliciet mee te nemen. In het geval van onze role willen we bijvoorbeeld een env var toevoegen aan het .bashrc-bestand van de nieuw aangemaakte users:
- name: Ensure custom environment variable is set in .bashrc for user "{{ user.name }}"
become: true
tags:
- linux_user
- linux_user_bashrc
ansible.builtin.lineinfile:
path: "{{ user.home | default('/home/' + user.name) }}/.bashrc"
regexp: '^export MY_CUSTOM_VAR='
line: 'export MY_CUSTOM_VAR="hello-world"'
state: present
create: true
owner: "{{ user.name }}"
group: "{{ user.group | default(user.name) }}"
mode: '0644'
Copy code
Let op dat door het toevoegen van regexp we de lineinfile-taak op een idempotente manier schrijven: de regel wordt nooit meer dan één keer toegevoegd. Een taak zoals hieronder is dat niet, omdat deze bij elke run opnieuw de env var aan het .bashrc-bestand van de users blijft toevoegen.
name: Always append line to .bashrc for user "{{ user.name }}" (NOT idempotent)
become: true
tags:
- linux_user
- linux_user_bashrc
ansible.builtin.lineinfile:
path: "{{ user.home | default('/home/' + user.name) }}/.bashrc"
line: 'export MY_CUSTOM_VAR="hello-world"'
insertafter: EOF
state: present
create: yes
owner: "{{ user.name }}"
group: "{{ user.group | default(user.name) }}"
mode: '0644'
Copy code
7. Variabelen moeten correct worden genamespaced en gehierarchiseerd
Alle roles moeten correct worden genamespaced om onverwachte overrides en conflicterende variabelen te voorkomen, zeker in grotere omgevingen waar veel variabelen aanwezig zijn. Dit wordt meestal gedaan door elke variabele van een role te prefixen met de naam van de role, zoals we hebben gedaan in onze role (linux_user).
Naast namespacing moeten variabelen ook correct worden gehierarchiseerd. Alle variabelen, met uitzondering van linux_user_allowed_os_family, zijn gedefinieerd in de defaults van de role. Dit is het laagste niveau in de variabelehiërarchie, waardoor ze eenvoudig te overriden zijn. Voor variabelen binnen roles die belangrijk zijn om een vaste waarde te behouden — zoals onze linux_user_allowed_os_family-variabele — moeten vars worden gebruikt in plaats van defaults. Dit maakt het moeilijker om deze variabelen onbedoeld te overriden.
Voor een volledig overzicht van de variabele precedence verwijzen we naar de officiële Ansible-documentatie over variable precedence.
8. Implementeer en documenteer tags
Tags maken targeted runs mogelijk. In het voorbeeld: linux_user_create voor alleen user creation en linux_user_authorized_keys voor alleen SSH keys. Documenteer tags altijd, zodat duidelijk is waarvoor ze dienen.
9. Valideer roles
Valideer je role altijd met linting en tests. Ansible-lint is de standaard voor linting. Voor testen zijn er meerdere opties, met Ansible Molecule als bekende keuze. (Een volledige uitwerking is buiten scope voor deze blog.)
10. Documenteer roles
Documentatie wordt vaak vergeten, maar is net zo belangrijk als de role zelf. Een duidelijke README met requirements, variables, voorbeeld playbook en tags maakt hergebruik veel makkelijker. Hieronder staat een voorbeeld-README zoals in de blog.
# cat README.md
# linux_user
Ansible role for creating Linux users
# Requirements
This role requires the following collections to be present:
- ansible.builtin
- ansible.posix
- community.general
# Role Variables
## Defaults
User related defaults:
`linux_user_default_authorized_keys`: []
`linux_users`: []
Where users can be configured as follows, where the values come from the [ansible user module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/user_module.html):
`linux_users.user.name` (required)
`linux_users.user.append`
`linux_users.user.comment`
`linux_users.user.create_home`
`linux_users.user.force`
`linux_users.user.group`
`linux_users.user.groups`
`linux_users.user.hidden`
`linux_users.user.home`
`linux_users.user.password`
`linux_users.user.password_expire_account_disable`
`linux_users.user.password_expire_max`
`linux_users.user.password_expire_min`
`linux_users.user.password_expire_warn`
`linux_users.user.password_lock`
`linux_users.user.remove`
`linux_users.user.shell`
`linux_users.user.state`
`linux_users.user.system`
`linux_users.user.uid`
`linux_users.user.umask`
`linux_users.user.update_password`
Optionally, SMTP notifications can be configured:
`linux_user_smtp_enabled`: false
`linux_user_smtp_body`: "User mutations executed on {{ ansible_facts.hostname }}"
`linux_user_smtp_host`: ""
`linux_user_smtp_mail_from`: ""
`linux_user_smtp_mail_to`: ""
`linux_user_smtp_port`: ""
`linux_user_smtp_subject`: "User mutations"
## Variables
`linux_user_allowed_os_families`: ["RedHat"]
# Example Playbook
```yaml
---
- name: Run linux_user role
hosts: localhost
vars:
linux_user_smtp_body: "A user mutation has been done in the sue.nl domain!"
linux_user_smtp_host: "smtp.sue.nl"
linux_user_smtp_mail_from: "source@sue.nl"
linux_user_smtp_mail_to: "recipient@sue.nl"
linux_user_smtp_port: "25"
linux_user_smtp_subject: "User mutations in sue.nl"
linux_users:
- name: user_1
- name: user_2
state: absent
- name: svc_user_1
create_home: false
shell: "/bin/bash"
uid: 15001
- name: svc_user_2
authorized_keys:
- "key1"
- "key2"
roles:
- role: sue.generic.linux_user
```
# Tags
This role supports a multiple of tags:
- `linux_user`: runs all plays
- `linux_user_create`: only create users
- `linux_user_authorized_keys`: only configure authorized keys for a user
# Supported
Tested and working on the following operating systems:
- AlmaLinux 9.5 (Teal Serval)
# License
MIT
# Author Information
- Nathan van Buuren (Sue B.V.)
Copy code
Conclusie
En dat waren de vuistregels. Nogmaals: dit zijn geen harde regels, maar richtlijnen die je helpen om consistentere, robuustere en beter herbruikbare Ansible roles te schrijven.
Ansible is een krachtige automation tool met veel toepassingen. Wil je meer weten over het schrijven van eigen Ansible modules, het goed inrichten van je Ansible setup, of wanneer je Ansible wel of juist niet inzet? Neem gerust contact met ons op, we denken graag mee.