| name | ansible-review-criteria |
| description | Reference knowledge base for the ansible-review agent. Loaded by that agent on its first iteration when the host has not already injected it; not intended for direct invocation. |
Idiomatic Ansible Review Criteria
This document provides comprehensive criteria for reviewing Ansible code. It covers
style conventions, structural patterns, security practices, and common anti-patterns.
Companion document: See ansible-standards.md for detailed
coverage of Collections, Roles with argument_specs, and Molecule testing configuration.
Table of Contents
- The Zen of Ansible
- YAML Formatting
- Naming Conventions
- Task Structure
- Variables
- Conditionals
- Loops
- Handlers
- Error Handling
- Idempotency
- Role Structure
- Collections and FQCN
- Inventory Best Practices
- Security
- Performance
- Testing
- Common Anti-patterns
The Zen of Ansible
Core principles that guide idiomatic Ansible:
- Ansible is not Python — YAML is challenging for coding
- Playbooks are not for programming — most users are not programmers
- Clear is better than cluttered
- Concise is better than verbose
- Simple is better than complex
- Readability counts
- Practicality beats purity
Sources: Red Hat Good Practices,
Ansible Best Practices Guide
YAML Formatting
Indentation
- Use 2 spaces for indentation (never tabs)
- Be consistent throughout all files
- Configure editor to enforce this
Boolean Values
enabled: true
disabled: false
enabled: yes
disabled: no
Key-Value Spacing
name: Install package
name:Install package
name: Install package
File Extensions
- All Ansible YAML files:
.yml (not .yaml, .YML)
- All Jinja2 templates:
.j2
Blank Lines
- One blank line before
vars, pre_tasks, roles, tasks, handlers
- One blank line between each task
Multi-line Strings
description: |
This is a multi-line
description that keeps
each line separate.
description: >
This is a long description
that will be folded into
a single line.
content: |+
content: |-
Sources: Ansible YAML Syntax,
Jeff Geerling YAML Best Practices
Naming Conventions
Variables
http_port: 8080
database_host: localhost
max_connections: 100
httpPort: 8080
database-host: localhost
Role Names
- Use lowercase with underscores (snake_case) and singular form
- Examples:
nginx_server, database_backup, user_management
- Note: Some style guides recommend kebab-case, but snake_case is more common
and aligns with variable naming conventions
Role Variables
- Prefix with role name to avoid collisions
- Use double underscore for internal variables
nginx_port: 80
nginx_worker_processes: auto
__nginx_config_path: /etc/nginx
__nginx_temp_dir: /tmp/nginx
Task Names
- name: Install nginx package
- name: Configure SSH daemon
- name: Create application user
- name: Ensure nginx configuration directory exists
- apt:
name: nginx
- name: INSTALL NGINX
- apt:
name: nginx
Task naming rules:
- Always provide a
name: parameter — no exceptions
- Start with an action verb (install, configure, create, remove, ensure)
- Use sentence case (capitalize first letter, lowercase rest except proper nouns)
- Keep names unique within a play
- Do not use templating in task names (e.g.,
name: "Install {{ package }}")
- The
name[casing] ansible-lint rule enforces capitalization
Sources: Ansible Lint name rules,
Ansible Junky Standards
Task Structure
Task Ordering
- name: Ensure nginx package is installed
ansible.builtin.package:
name: nginx
state: present
become: true
when: install_nginx | bool
notify: Restart nginx
tags:
- nginx
- packages
Recommended key order within a task:
name
- Module name (FQCN)
- Module parameters
become / become_user
when
register
changed_when / failed_when
notify
tags
loop / with_*
One Task Per Action
- name: Install required packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- nginx
- certbot
- name: Start nginx service
ansible.builtin.service:
name: nginx
state: started
- name: Install and configure nginx
block:
Variables
Variable Precedence (Lowest to Highest)
- Role defaults (
roles/x/defaults/main.yml)
- Inventory file or script group vars
group_vars/all
group_vars/group_name
- Inventory file or script host vars
host_vars/hostname
- Host facts / cached set_facts
- Play vars
- Play vars_prompt
- Play vars_files
- Role vars (
roles/x/vars/main.yml)
- Block vars
- Task vars
- include_vars
- set_facts / registered vars
- Role params
- Include params
- Extra vars (
-e)
Best Practices
myapp_port: 8080
myapp_user: myapp
myapp_log_level: info
myapp_log_level: warn
port: "{{ custom_port | default(8080) }}"
database_password: "{{ db_pass | mandatory }}"
Avoid Hardcoding
- name: Create user
ansible.builtin.user:
name: john
uid: 1001
- name: Create application user
ansible.builtin.user:
name: "{{ app_user }}"
uid: "{{ app_user_uid }}"
Sources: Ansible Variables Documentation,
Spacelift Ansible Variables Guide
Conditionals
Boolean Handling
- name: Enable feature
ansible.builtin.debug:
msg: Feature enabled
when: enable_feature | bool
- name: Skip if disabled
ansible.builtin.debug:
msg: Running
when: is_enabled
Syntax
when: ansible_os_family == "Debian"
when: "{{ ansible_os_family == 'Debian' }}"
Multiple Conditions
when:
- ansible_os_family == "RedHat"
- ansible_distribution_major_version | int >= 8
when: ansible_os_family == "Debian" or ansible_os_family == "Ubuntu"
when: >
(ansible_os_family == "RedHat" and ansible_distribution_major_version | int >= 8)
or
(ansible_os_family == "Debian" and ansible_distribution_major_version | int >= 10)
Testing Variables
when: my_var is defined
when: my_var is not defined
when: my_var | length > 0
when: previous_task is succeeded
when: previous_task is failed
when: previous_task is skipped
when: previous_task is changed
Sources: Ansible Conditionals
Loops
Prefer loop Over with_items
- name: Install packages
ansible.builtin.package:
name: "{{ item }}"
state: present
loop:
- nginx
- certbot
- python3
- name: Install packages
ansible.builtin.package:
name: "{{ item }}"
state: present
with_items:
- nginx
- certbot
Loop Control
- name: Create users
ansible.builtin.user:
name: "{{ item.name }}"
groups: "{{ item.groups }}"
loop: "{{ users }}"
loop_control:
label: "{{ item.name }}"
pause: 2
index_var: idx
Flattening Behavior
- name: Install packages
ansible.builtin.package:
name: "{{ item }}"
loop: "{{ package_lists | flatten(1) }}"
Dictionary Loops
- name: Create users with attributes
ansible.builtin.user:
name: "{{ item.key }}"
comment: "{{ item.value.comment }}"
loop: "{{ users | dict2items }}"
Sources: Ansible Loops
Handlers
Basic Handler Pattern
- name: Update nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
become: true
Handler Best Practices
- Unique names — handlers must have globally unique names
- Order matters — handlers run in definition order, not notification order
- Single execution — handlers run once regardless of how many times notified
Using listen for Handler Groups
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
listen: web server changed
- name: Clear nginx cache
ansible.builtin.file:
path: /var/cache/nginx
state: absent
listen: web server changed
- name: Update config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: web server changed
Flushing Handlers
- name: Update critical config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
notify: Restart application
- name: Flush handlers
ansible.builtin.meta: flush_handlers
- name: Run health check
ansible.builtin.uri:
url: http://localhost:8080/health
Sources: Ansible Handlers
Error Handling
Block / Rescue / Always
- name: Deploy application
block:
- name: Download application
ansible.builtin.get_url:
url: "{{ app_url }}"
dest: /tmp/app.tar.gz
- name: Extract application
ansible.builtin.unarchive:
src: /tmp/app.tar.gz
dest: /opt/app
remote_src: true
rescue:
- name: Log deployment failure
ansible.builtin.debug:
msg: "Deployment failed: {{ ansible_failed_task.name }}"
- name: Rollback to previous version
ansible.builtin.copy:
src: /opt/app.backup
dest: /opt/app
remote_src: true
always:
- name: Clean up temp files
ansible.builtin.file:
path: /tmp/app.tar.gz
state: absent
- name: Send notification
ansible.builtin.uri:
url: "{{ webhook_url }}"
method: POST
body: '{"status": "completed"}'
failed_when and changed_when
- name: Check if service is running
ansible.builtin.command: systemctl is-active nginx
register: nginx_status
changed_when: false
failed_when: nginx_status.rc not in [0, 3]
- name: Run migration
ansible.builtin.command: ./migrate.sh
register: migration_result
changed_when: "'Applied' in migration_result.stdout"
failed_when: migration_result.rc != 0 and 'already up to date' not in migration_result.stdout
ignore_errors (Use Sparingly)
- name: Check optional service
ansible.builtin.command: systemctl status optional-service
register: optional_check
ignore_errors: true
- name: Configure if available
ansible.builtin.template:
src: optional.conf.j2
dest: /etc/optional/config
when: optional_check is succeeded
Sources: Ansible Error Handling,
Ansible Blocks
Idempotency
Core Principle
Running a playbook multiple times should produce the same result. The first run
makes changes; subsequent runs report "ok" with no changes.
Command/Shell Module Idempotency
- name: Create directory
ansible.builtin.command: mkdir -p /opt/myapp
- name: Create directory
ansible.builtin.file:
path: /opt/myapp
state: directory
- name: Initialize database
ansible.builtin.command: /opt/db/init.sh
args:
creates: /opt/db/.initialized
- name: Check current hostname
ansible.builtin.command: hostname
register: current_hostname
changed_when: false
- name: Set hostname
ansible.builtin.command: hostnamectl set-hostname "{{ target_hostname }}"
when: current_hostname.stdout != target_hostname
Idempotent Patterns
- name: Ensure nginx is installed
ansible.builtin.package:
name: nginx
state: present
- name: Ensure config exists
ansible.builtin.copy:
src: app.conf
dest: /etc/app/app.conf
- name: Ensure service is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
Sources: Ansible Idempotency
Role Structure
Standard Directory Layout
roles/
└── my_role/
├── defaults/
│ └── main.yml # Default variables (lowest precedence)
├── files/
│ └── app.conf # Static files for copy module
├── handlers/
│ └── main.yml # Handler definitions
├── meta/
│ └── main.yml # Role metadata, dependencies
├── tasks/
│ └── main.yml # Main task list
├── templates/
│ └── config.j2 # Jinja2 templates
├── tests/
│ ├── inventory
│ └── test.yml # Test playbook
├── vars/
│ └── main.yml # Role variables (high precedence)
└── README.md # Documentation
Role Design Principles
- Single responsibility — one role, one purpose
- Self-contained — include all necessary tasks, templates, files
- Configurable — expose variables in defaults/
- Documented — README with examples
Role Dependencies (meta/main.yml)
---
galaxy_info:
author: Your Name
description: Role description
license: MIT
min_ansible_version: "2.10"
platforms:
- name: Ubuntu
versions:
- focal
- jammy
- name: EL
versions:
- "8"
- "9"
dependencies:
- role: common
- role: firewall
vars:
firewall_allowed_ports:
- 80
- 443
Galaxy Versioning
- Use strict X.Y.Z format (e.g., 1.0.0, 2.1.3)
- Follow semantic versioning
Sources: Ansible Roles,
Galaxy Developer Guide
Collections and FQCN
Always Use Fully Qualified Collection Names
- name: Install package
ansible.builtin.package:
name: nginx
state: present
- name: Gather AWS EC2 facts
amazon.aws.ec2_instance_info:
region: us-east-1
- name: Install package
package:
name: nginx
collections:
- amazon.aws
Common Collection Namespaces
ansible.builtin — core Ansible modules
ansible.posix — POSIX modules (acl, cron, etc.)
ansible.netcommon — network common modules
community.general — community maintained modules
community.mysql — MySQL modules
community.postgresql — PostgreSQL modules
amazon.aws — AWS modules
google.cloud — GCP modules
azure.azcollection — Azure modules
Installing Collections
---
collections:
- name: community.general
version: ">=6.0.0"
- name: amazon.aws
version: ">=5.0.0"
Sources: Ansible FQCN Lint Rule,
Using Collections
Inventory Best Practices
Directory Structure
inventory/
├── production/
│ ├── hosts.yml
│ ├── group_vars/
│ │ ├── all.yml
│ │ ├── webservers.yml
│ │ └── databases.yml
│ └── host_vars/
│ └── web01.example.com.yml
└── staging/
├── hosts.yml
├── group_vars/
│ └── all.yml
└── host_vars/
YAML Inventory Format
---
all:
children:
webservers:
hosts:
web01.example.com:
web02.example.com:
vars:
http_port: 80
databases:
hosts:
db01.example.com:
mysql_port: 3306
Variable Organization
---
ntp_servers:
- 0.pool.ntp.org
- 1.pool.ntp.org
---
nginx_worker_processes: auto
nginx_worker_connections: 1024
---
nginx_worker_processes: 4
Best Practices
- Use version control for inventory
- Separate environments (production, staging, development)
- Use dynamic inventory for cloud providers
- Set common defaults in
group_vars/all.yml
- Host vars override group vars
Sources: Ansible Inventory Guide
Security
Ansible Vault for Secrets
ansible-vault create secrets.yml
ansible-vault encrypt vars/credentials.yml
ansible-vault edit secrets.yml
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass
no_log for Sensitive Tasks
- name: Set database password
ansible.builtin.mysql_user:
name: app_user
password: "{{ db_password }}"
priv: "app_db.*:ALL"
no_log: true
Privilege Escalation (become)
- hosts: webservers
become: true
become_user: root
tasks:
- name: Install package
ansible.builtin.package:
name: nginx
- name: Read user file
ansible.builtin.slurp:
src: /home/app/config
become: true
become_user: app
Security Best Practices
- Never commit secrets — use Vault
- Minimal become — only escalate when necessary
- Avoid shell/command — prefer idempotent modules
- Validate input — especially from external sources
- Secure file permissions — use mode parameter
- name: Create sensitive config
ansible.builtin.template:
src: database.conf.j2
dest: /etc/app/database.conf
owner: app
group: app
mode: "0600"
Sources: Ansible Vault,
Privilege Escalation
Performance
Fact Gathering
- hosts: webservers
gather_facts: false
tasks:
- name: Quick task
ansible.builtin.ping:
- hosts: webservers
gather_subset:
- network
- virtual
Fact Caching
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_facts
fact_caching_timeout = 86400
SSH Pipelining
[ssh_connection]
pipelining = True
Parallelism
[defaults]
forks = 20
Strategy
- hosts: webservers
strategy: free
tasks:
- name: Independent task
ansible.builtin.package:
name: nginx
Sources: Ansible Tuning,
Spacelift Best Practices
Testing
Ansible Lint
pip install ansible-lint
ansible-lint playbook.yml
ansible-lint roles/my_role/
Lint Configuration (.ansible-lint)
---
profile: production
skip_list:
- yaml[line-length]
warn_list:
- experimental
exclude_paths:
- .cache/
- .github/
Molecule for Role Testing
pip install molecule molecule-docker
molecule init role my_role
molecule test
molecule converge
molecule verify
molecule destroy
Check Mode (Dry Run)
ansible-playbook site.yml --check
ansible-playbook site.yml --check --diff
Assert Module for Validation
- name: Validate configuration
ansible.builtin.assert:
that:
- app_port > 1024
- app_user is defined
- app_user | length > 0
fail_msg: "Invalid configuration: port must be > 1024 and user must be defined"
success_msg: "Configuration validated successfully"
quiet: true
Sources: Ansible Lint,
Molecule Documentation
Common Anti-patterns
1. Using shell/command When a Module Exists
- name: Install package
ansible.builtin.shell: apt-get install nginx -y
- name: Install package
ansible.builtin.apt:
name: nginx
state: present
2. Not Using FQCN
- name: Copy file
copy:
src: file.txt
dest: /tmp/file.txt
- name: Copy file
ansible.builtin.copy:
src: file.txt
dest: /tmp/file.txt
3. Hardcoding Values
- name: Create user
ansible.builtin.user:
name: admin
uid: 1000
- name: Create application user
ansible.builtin.user:
name: "{{ app_user }}"
uid: "{{ app_user_uid }}"
4. Missing Task Names
- ansible.builtin.package:
name: nginx
state: present
- name: Install nginx web server
ansible.builtin.package:
name: nginx
state: present
5. Using import_tasks with Loops
- import_tasks: user.yml
loop: "{{ users }}"
- include_tasks: user.yml
loop: "{{ users }}"
6. Storing Secrets in Plain Text
vars:
database_password: "supersecret123"
vars_files:
- vault/secrets.yml
7. Ignoring Errors Without Handling
- name: Run script
ansible.builtin.command: /opt/script.sh
ignore_errors: true
- block:
- name: Run script
ansible.builtin.command: /opt/script.sh
rescue:
- name: Recover from script failure
ansible.builtin.fail:
msg: "Script failed: {{ ansible_failed_result.stderr | default('unknown') }}"
- name: Run script (rc 0 or 2 are acceptable)
ansible.builtin.command: /opt/script.sh
register: script_result
failed_when: script_result.rc not in [0, 2]
Note: failed_when: false is suppression, not handling — it hides every
non-zero rc. Only use it when a non-zero rc is genuinely expected, and ALWAYS
act on the result (inspect register output, then fail/assert or branch).
8. Not Making Tasks Idempotent
- name: Add line to file
ansible.builtin.shell: echo "config=value" >> /etc/app.conf
- name: Configure application
ansible.builtin.lineinfile:
path: /etc/app.conf
line: "config=value"
9. Overusing become: true
- hosts: webservers
become: true
tasks:
- name: Check free space
ansible.builtin.command: df -h
- name: Install package
ansible.builtin.package:
name: nginx
- hosts: webservers
tasks:
- name: Check free space
ansible.builtin.command: df -h
changed_when: false
- name: Install package
ansible.builtin.package:
name: nginx
become: true
10. Not Using Handlers for Restarts
- name: Update config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
- name: Restart service
ansible.builtin.service:
name: app
state: restarted
- name: Update config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app/app.conf
notify: Restart app
handlers:
- name: Restart app
ansible.builtin.service:
name: app
state: restarted
Sources: Ansible Lint Rules
Tags Best Practices
Special Tags
always — runs unless explicitly skipped with --skip-tags always
never — skipped unless explicitly requested with --tags never
- name: Always run health check
ansible.builtin.uri:
url: http://localhost/health
tags:
- always
- health
- name: Debug task (only when requested)
ansible.builtin.debug:
var: all_vars
tags:
- never
- debug
Tag Best Practices
- Always add a specific named tag alongside
always or never
- Use consistent tag names across playbooks
- Document tag meanings in README
ansible-playbook site.yml --tags "deploy,config"
ansible-playbook site.yml --skip-tags "debug"
ansible-playbook site.yml --list-tags
Sources: Ansible Tags
Register and Return Values
Common Return Values
- name: Run command
ansible.builtin.command: whoami
register: result
- name: Show results
ansible.builtin.debug:
msg: |
stdout: {{ result.stdout }}
stderr: {{ result.stderr }}
rc: {{ result.rc }}
changed: {{ result.changed }}
stdout_lines: {{ result.stdout_lines }}
Best Practices
- name: Check status
ansible.builtin.command: systemctl is-active nginx
register: nginx_status
changed_when: false
failed_when: nginx_status.rc not in [0, 3]
- name: List files
ansible.builtin.command: ls /opt
register: files_list
changed_when: false
- name: Process each file
ansible.builtin.debug:
msg: "Found: {{ item }}"
loop: "{{ files_list.stdout_lines }}"
Sources: Ansible Return Values
Jinja2 Templates
Template Best Practices
{# templates/app.conf.j2 #}
# Managed by Ansible - DO NOT EDIT
# Generated: {{ ansible_date_time.iso8601 }}
# Host: {{ inventory_hostname }}
[application]
port = {{ app_port | default(8080) }}
host = {{ app_host | mandatory }}
debug = {{ app_debug | default(false) | lower }}
{% if app_features is defined %}
[features]
{% for feature in app_features %}
{{ feature.name }} = {{ feature.enabled | lower }}
{% endfor %}
{% endif %}
Useful Filters
"{{ variable | default('fallback') }}"
"{{ string_number | int }}"
"{{ value | bool }}"
"{{ name | upper }}"
"{{ name | lower }}"
"{{ name | capitalize }}"
"{{ list | join(', ') }}"
"{{ list | first }}"
"{{ list | last }}"
"{{ list | unique }}"
"{{ list | sort }}"
"{{ dict | to_json }}"
"{{ dict | to_nice_yaml }}"
"{{ path | basename }}"
"{{ path | dirname }}"
"{{ password | password_hash('sha512') }}"
Sources: Ansible Templating,
Ansible Filters
include_tasks vs import_tasks
Key Differences
| Feature | import_tasks | include_tasks |
|---|
| Processing | Static (parse time) | Dynamic (runtime) |
| Loops | Not supported | Supported |
| Conditionals | Applied to each task | Applied to include itself |
| Tags | Inherited by imported tasks | Not inherited |
--list-tasks | Shows tasks | Doesn't show tasks |
When to Use Each
- import_tasks: common.yml
- include_tasks: user.yml
loop: "{{ users }}"
loop_control:
loop_var: user
- include_tasks: "{{ ansible_os_family | lower }}.yml"
General Recommendation
Prefer include_tasks unless you specifically need static parsing behavior.
The limitations of import_tasks (no loops, no dynamic file names) are more
impactful than the minor overhead of dynamic includes.
Sources: Ansible Include vs Import
References
Companion Document
See also: ansible-standards.md for detailed coverage of:
- Collection structure and
galaxy.yml requirements
- Role
argument_specs.yml for input validation
- Molecule test configuration and multi-platform testing
.yamllint configuration
Official Documentation
Best Practices Guides
Style Guides
Testing
Last updated: 2026-02-06