De kunst van het schrijven van Ansible roles

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:

  1. De main.yml van een role gebruik je alleen om tasks te includen of importeren
  2. Alle tasks moeten geoptimaliseerd zijn voor efficiëntie
  3. Data en code moeten volledig gescheiden zijn
  4. Tasks moeten op include-basis draaien
  5. Roles moeten check mode correct afhandelen
  6. Alle tasks moeten idempotent zijn
  7. Variabelen moeten de correct namespace en gehiërarchiseerd zijn
  8. Tags moeten geïmplementeerd én gedocumenteerd zijn
  9. Roles moeten gevalideerd worden
  10. 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.

Blijf op de hoogte
Door je in te schrijven voor onze nieuwsbrief verklaar je dat je akkoord bent met onze privacyverklaring.

Ready to improve your Ansible configuration?

michael.veentjer 1
Michael Veentjer

Let's talk!


Ready to improve your Ansible configuration?

* required

By sending this form you indicate that you have taken note of our Privacy Statement.
Privacy Overview
This website uses cookies. We use cookies to ensure the proper functioning of our website and services, to analyze how visitors interact with us, and to improve our products and marketing strategies. For more information, please consult our privacy- en cookiebeleid.