Software Engineering WikiSE Wiki

Ansible

Write idempotent Ansible playbooks, resolve inventory and variable precedence, dry-run with check mode, and debug plays that fail or change the wrong thing.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Dry run with file diffsansible-playbook site.yml --check --diff
Limit to one host or groupansible-playbook site.yml --limit web-01
Syntax check onlyansible-playbook site.yml --syntax-check
Hosts the play would targetansible-playbook site.yml --list-hosts
Tasks and tags the play would runansible-playbook site.yml --list-tasks
Start at a named taskansible-playbook site.yml --start-at-task='Install nginx'
Confirm each task interactivelyansible-playbook site.yml --step
Ad-hoc commandansible web -m ansible.builtin.command -a 'uptime'
Connectivity and Python checkansible all -m ansible.builtin.ping
Selected factsansible web -m ansible.builtin.setup -a 'filter=ansible_distribution*'
Connection debuggingansible-playbook site.yml -vvvv
Extra variablesansible-playbook site.yml -e env=prod -e @vars.yml
Inventory as Ansible resolves itansible-inventory --list --yaml
Group treeansible-inventory --graph
Edit an encrypted fileansible-vault edit group_vars/prod/vault.yml
Install collectionsansible-galaxy collection install -r requirements.yml
Lintansible-lint

Versions: ansible-core 2.21 (May 2026) and 2.20 are current; 2.19 is supported until November 2026. ansible-core 2.20 and later need Python 3.12 or newer on the control node. The ansible package bundles ansible-core with a curated set of collections. See release and maintenance.

How a play runs#

Ansible connects to each host over SSH (or another connection plugin), copies a Python module to a temporary directory, runs it with the target’s Python and reads a JSON result. Nothing needs to be installed on the target except Python and, for become, sudo or an equivalent.

By default the linear strategy runs each task on every host (up to forks, default 5) before moving to the next task. A host that fails is removed from the rest of the play. Modules report ok (already in the desired state), changed, failed or skipped, and idempotent modules make a second run report only ok.

Dry run first#

--check asks each module to predict whether it would change anything, without changing it. --diff prints the file content differences. Use both before any run against shared hosts.

ansible-playbook site.yml --check --diff --limit staging
ansible-playbook site.yml --check --diff --tags nginx

command and shell cannot predict their effect, so they are skipped in check mode unless creates or removes decides the outcome. A task that only reads state can set check_mode: false to run anyway. Check mode is a prediction: a task that depends on a file or package an earlier skipped task would have created can fail or report differently than the real run.

Inventory#

# inventory/prod.ini
[web]
web-[01:04].example.com

[db]
db-01.example.com ansible_host=192.0.2.11

[prod:children]
web
db

[prod:vars]
ansible_user=deploy
# inventory/prod.yml: the YAML form, clearer for nested group vars
all:
  children:
    web:
      hosts:
        web-01.example.com:
        web-02.example.com:
      vars:
        nginx_workers: 4
ansible-inventory -i inventory/prod.yml --graph
ansible-inventory -i inventory/prod.yml --host web-01.example.com   # merged variables for one host
ansible all -i inventory/prod.yml -m ansible.builtin.ping --limit web

group_vars/ and host_vars/ directories next to the inventory file or the playbook are loaded automatically. Dynamic inventory plugins (amazon.aws.aws_ec2, kubernetes.core.k8s, azure.azcollection.azure_rm) replace static files for cloud fleets; the config file name must end with the plugin’s suffix, such as prod.aws_ec2.yml.

ansible_python_interpreter defaults to auto, which discovers the platform Python. Set it only when discovery picks the wrong one.

Dynamic inventory#

An inventory plugin queries an API at run time and builds hosts and groups from the result. The file is a plugin configuration, not a host list, and the plugin must be enabled (amazon.aws.aws_ec2 and the other common ones are in the default enable_plugins list; check with ansible-config dump | grep INVENTORY_ENABLED).

# inventory/prod.aws_ec2.yml: file name must end in .aws_ec2.yml or .aws_ec2.yaml
plugin: amazon.aws.aws_ec2
regions: [ap-southeast-2]
filters:
  instance-state-name: running
  tag:Environment: prod
hostnames: [tag:Name, private-ip-address]   # first that exists becomes inventory_hostname
compose:
  ansible_host: private_ip_address          # connect on the private address
  ansible_user: "'ec2-user'"                # compose values are Jinja; quote literals twice
keyed_groups:
  - key: tags.Role                          # group "role_web" from tag Role=web
    prefix: role
  - key: placement.availability_zone
    prefix: az
groups:
  db: "'db' in tags.Role"                   # conditional group from an expression
cache: true                                 # reuse results between runs
cache_plugin: ansible.builtin.jsonfile
cache_connection: ~/.cache/ansible-inventory
cache_timeout: 600

ansible-inventory -i inventory/prod.aws_ec2.yml --graph proves the plugin works before a play depends on it; --list --yaml shows every host variable the plugin exposed. The ansible.builtin.constructed plugin adds compose, groups and keyed_groups on top of any other inventory, so a static file can gain fact-derived groups. Several inventory sources combine when -i is a directory or repeated: hosts merge and later sources override earlier variables. Dynamic sources that fail return no hosts rather than an error unless any_unparsed_is_failed = True is set in ansible.cfg, which is the setting that stops an expired cloud token from turning --limit prod into an empty run.

Variable precedence#

Later in the list wins. The documented order has 22 levels; these are the ones that usually matter, lowest first:

SourceNotes
Role defaults (defaults/main.yml)Lowest; intended to be overridden
Inventory file group vars[group:vars] or vars: in the inventory
group_vars/all, then group_vars/<group>Inventory directory first, then playbook directory; a child group beats its parent
Inventory host vars, then host_vars/<host>Beat every group variable
Facts and cached set_fact
Play vars, vars_prompt, vars_files
Role vars (vars/main.yml)Beat play vars; hard to override from inventory
Block vars, then task varsScoped to the block or task
include_vars
set_fact and register
Role and include_role params
Extra vars (-e)Always win

Put tunables in defaults/, environment values in group_vars/, and reserve role vars/ for constants the role’s own tasks depend on. See variable precedence.

Playbook structure and rolling updates#

- name: Configure web tier
  hosts: web
  become: true
  serial: "25%"                 # a quarter of the group at a time
  max_fail_percentage: 0        # any failure in a batch stops the play
  pre_tasks:
    - name: Drain from the load balancer
      ansible.builtin.uri:
        url: "https://lb.example.com/drain/{{ inventory_hostname }}"
        method: POST
      delegate_to: localhost

  roles:
    - role: nginx
      tags: [nginx]

  tasks:
    - name: Deploy configuration
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        owner: root
        mode: "0644"
        validate: nginx -t -c %s      # refuse to install a config that fails its own check
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

serial with max_fail_percentage: 0 limits the damage of a bad change: the first failing batch stops the play before it reaches the rest of the fleet.

Handlers run only when a task that notified them reported changed, once per play section, after pre_tasks, after roles and tasks, and after post_tasks. ansible.builtin.meta: flush_handlers runs pending handlers immediately when a later task depends on the reload. If a later task fails, pending handlers are lost unless force_handlers: true is set, so the next run finds the config already in place and never reloads.

Handlers can subscribe to a topic with listen, so several tasks notify one name and several handlers react without the tasks knowing which. A handler runs once per play no matter how many tasks notified it, and handlers in one role can be notified from another role by name, which is how a shared restart nginx in a base role gets triggered from an application role.

  handlers:
    - name: Reload nginx
      ansible.builtin.service: { name: nginx, state: reloaded }
      listen: nginx config changed
    - name: Purge nginx cache
      ansible.builtin.file: { path: /var/cache/nginx, state: absent }
      listen: nginx config changed
  tasks:
    - name: Install site
      ansible.builtin.template: { src: site.conf.j2, dest: /etc/nginx/conf.d/site.conf }
      notify: nginx config changed

Roles#

A role is a directory whose subdirectories are loaded by convention; only tasks/main.yml is required. ansible-galaxy role init my_role scaffolds the tree.

roles/nginx/
  defaults/main.yml       # tunables, lowest precedence
  vars/main.yml           # constants the role relies on; hard to override
  tasks/main.yml          # entry point; include_tasks/import_tasks for the rest
  handlers/main.yml
  templates/              # referenced by bare name from template:
  files/                  # referenced by bare name from copy:
  meta/main.yml           # dependencies, platforms, galaxy_info
  meta/argument_specs.yml # validated role parameters (2.11+)
  molecule/default/       # tests
  README.md

argument_specs.yml turns undocumented variables into a validated interface: a role invoked with a missing required variable or a wrong type fails before any task runs, with a message naming the variable.

# roles/nginx/meta/argument_specs.yml
argument_specs:
  main:
    short_description: Install and configure nginx
    options:
      nginx_workers:
        type: int
        default: 4
        description: Worker processes
      nginx_sites:
        type: list
        elements: dict
        required: true
        options:
          name: { type: str, required: true }
          port: { type: int, default: 80 }

import_role is static: tasks are inlined at parse time, so --list-tasks, tags and --start-at-task see them, and a when on the import applies to every task inside. include_role is dynamic: resolved at run time, so it can loop and take a variable role name, but tags on the include do not propagate to the tasks inside unless apply: { tags: [...] } is used. meta/main.yml dependencies run before the role and are deduplicated per play unless allow_duplicates: true. Task files are found by name in tasks/, templates in templates/, files in files/, and a role’s vars/ and defaults/ are visible to every play that uses it once loaded.

ansible-galaxy role init roles/nginx                               # scaffold
ansible-doc -t role -l                                             # roles with argument_specs on the roles path
ansible-doc -t role roles/nginx                                    # render the spec as documentation
ansible-galaxy collection install -r requirements.yml -p collections/   # vendor collections into the project

Writing idempotent tasks#

Use the module that describes the desired state, not the command that changes it. Use fully qualified collection names (ansible.builtin.copy), which ansible-lint enforces.

- name: Package present
  ansible.builtin.package:
    name: nginx
    state: present

- name: Line in a config file
  ansible.builtin.lineinfile:
    path: /etc/security/limits.conf
    regexp: '^\*\s+soft\s+nofile'
    line: '*    soft    nofile 65535'

- name: Run a migration exactly once
  ansible.builtin.command: /opt/my-app/bin/migrate
  args:
    creates: /var/lib/my-app/.migrated        # skipped when this path exists

- name: Script with an explicit change test
  ansible.builtin.command: /usr/local/bin/sync-data
  register: sync
  changed_when: "'updated' in sync.stdout"
  failed_when: sync.rc not in [0, 2]
KeywordEffect
creates / removesSkip the command when a path exists / does not exist
changed_whenDefine what counts as a change (false for read-only commands)
failed_whenDefine what counts as a failure
check_mode: falseRun even under --check (read-only tasks only)
run_once: trueRun on the first host in the batch; the result applies to all
delegate_toRun the task on another host (load balancer, localhost)
until / retries / delayPoll until a condition holds
throttleCap concurrency for this task, for rate-limited APIs
no_log: trueHide arguments and results, for tasks that handle secrets

Prefer ansible.builtin.command over shell unless pipes, redirects or globbing are needed; shell passes arguments through /bin/sh and invites quoting and injection bugs.

Templates, facts and conditionals#

{# nginx.conf.j2 #}
worker_processes {{ ansible_facts['processor_vcpus'] }};
upstream app {
{% for host in groups['app'] %}
    server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:8080;
{% endfor %}
}

Reading hostvars for another host’s facts requires that facts for that host were gathered in this run or a fact cache.

ansible web-01 -m ansible.builtin.setup | less                        # every fact
ansible web-01 -m ansible.builtin.setup -a 'filter=ansible_mounts'
- name: Show a value at the point of use
  ansible.builtin.debug:
    var: nginx_workers
    verbosity: 1              # printed only with -v or more

Fact gathering costs a round trip and a module run per host. On large fleets use gather_facts: false and call ansible.builtin.setup with gather_subset where facts are needed, or enable a fact cache.

Filters and tests#

Filters transform a value (value | filter), tests return a boolean (value is test). Jinja2 built-ins are available plus Ansible’s own; ansible-doc -t filter -l and ansible-doc -t test -l list what is installed, and ansible-doc -t filter ansible.builtin.combine documents one.

- name: Filters that come up in every playbook
  ansible.builtin.debug:
    msg:
      - "{{ nginx_port | default(80) }}"                                   # default when undefined (add true to also cover empty/false)
      - "{{ my_dict | combine({'extra': 1}, recursive=true) }}"           # merge dicts
      - "{{ groups['web'] | map('extract', hostvars, ['ansible_facts', 'default_ipv4', 'address']) | list }}"   # facts across hosts
      - "{{ services | selectattr('enabled', 'equalto', true) | map(attribute='name') | list }}"
      - "{{ items | rejectattr('state', 'defined') | list }}"
      - "{{ users | dict2items | selectattr('value.shell', 'search', 'bash') | items2dict }}"
      - "{{ '192.0.2.10/24' | ansible.utils.ipaddr('address') }}"        # ansible.utils collection
      - "{{ lookup('ansible.builtin.env', 'HOME') }}"                      # lookups run on the control node
      - "{{ '/etc/nginx/nginx.conf' | basename }} {{ path | dirname }} {{ path | realpath }}"
      - "{{ text | regex_search('version (\\d+\\.\\d+)', '\\1') | first }}"
      - "{{ data | to_nice_yaml(indent=2) }} {{ data | to_json }} {{ raw | from_yaml }}"
      - "{{ secret | password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) }}"   # stable salt per host
      - "{{ 'abc' | b64encode }} {{ list_a | difference(list_b) }} {{ list_a | intersect(list_b) | unique }}"
      - "{{ ansible_date_time.epoch | int | strftime('%Y-%m-%d') }}"
      - "{{ my_var | type_debug }}"                                       # str, int, list, dict, AnsibleUnsafeText
- name: Tests in conditionals
  ansible.builtin.assert:
    that:
      - result is succeeded                       # also failed, changed, skipped
      - job is finished                           # async job
      - version is version('2.19', '>=')          # semantic comparison, not string
      - path is file                              # exists on the control node; also directory, exists, link
      - my_var is defined and my_var is not none
      - hostname is match('^web-\d+$')            # match anchors at start; search does not; regex takes flags
      - value is truthy                           # 'yes', 1, [x]; falsy for '', 0, []
      - item is subset(['a', 'b', 'c'])           # also superset, contains
      - "'web' in group_names"
    fail_msg: "Preconditions failed on {{ inventory_hostname }}"
    quiet: true

A filter plugin is a Python file in filter_plugins/ next to the playbook (or plugins/filter/ in a collection) exposing a FilterModule class whose filters() returns a dict of name to callable; tests use TestModule and tests(). Reach for one when a Jinja expression stops being readable.

Async and long-running tasks#

A task longer than the SSH or command_timeout budget (default 10 seconds for the connection, none for the module) should run asynchronously. async is the seconds the job may run, poll how often Ansible checks; poll: 0 returns immediately and hands you a job id.

- name: Start a long upgrade and move on
  ansible.builtin.command: /opt/my-app/bin/upgrade --all
  async: 3600                                  # kill the job after an hour
  poll: 0                                      # do not wait
  register: upgrade_job

- name: Do other work while it runs
  ansible.builtin.include_tasks: prepare.yml

- name: Wait for the upgrade to finish
  ansible.builtin.async_status:
    jid: "{{ upgrade_job.ansible_job_id }}"
  register: upgrade_result
  until: upgrade_result is finished
  retries: 360                                 # retries x delay must cover the expected runtime
  delay: 10

- name: Remove the job status file
  ansible.builtin.async_status:
    jid: "{{ upgrade_job.ansible_job_id }}"
    mode: cleanup

- name: Run on every host at once, each with a 30 minute budget, waiting in-line
  ansible.builtin.command: /usr/bin/dnf -y upgrade
  async: 1800
  poll: 15                                     # Ansible reconnects every 15 s; the SSH session can drop in between

With poll: 0 the status file under ~/.ansible_async/ on the target is not removed automatically; mode: cleanup does that. Async does not lift serial: a fire-and-forget task on a serial: 1 play still runs host by host. Avoid poll: 0 for package managers that hold a lock, because the next task on the same host will fight it. async with poll greater than zero is also the fix for a command that outlives an SSH connection or a become timeout, since each poll opens a fresh connection.

ansible-core 2.19 templating changes

Since 2.19, conditionals (when, changed_when, failed_when, until) must evaluate to a boolean. when: my_list or when: "{{ x }}" that relied on truthiness now fails. Write when: my_list | length > 0. Only templates from trusted sources (playbooks, roles, vars files) are rendered; strings from module results or external data are no longer templated. ALLOW_BROKEN_CONDITIONALS downgrades the error to a warning during migration. See the 2.19 porting guide.

Secrets with Ansible Vault#

ansible-vault create group_vars/prod/vault.yml
ansible-vault edit group_vars/prod/vault.yml
ansible-vault encrypt group_vars/prod/vault.yml                  # encrypt an existing plaintext file in place
ansible-vault encrypt_string --stdin-name db_password            # prompts on stdin; keeps the value out of shell history
ansible-playbook site.yml --vault-password-file ~/.config/ansible/vault-pass

A common layout keeps encrypted values in vault.yml as vault_db_password and references them from a plaintext vars.yml (db_password: "{{ vault_db_password }}"), so grep still finds where a variable is defined.

Decrypted values live in memory during the run and can leak through debug, -vvv output and failed-task results. Set no_log: true on tasks that handle them. For secrets fetched at runtime rather than stored in the repository, see Vault.

Vault IDs label secrets so one run can use several passwords, and a password script keeps the password out of files entirely:

ansible-vault encrypt --vault-id prod@prompt group_vars/prod/vault.yml          # header records the id: $ANSIBLE_VAULT;1.2;AES256;prod
ansible-vault encrypt --vault-id dev@~/.config/ansible/dev-pass group_vars/dev/vault.yml
ansible-playbook site.yml --vault-id prod@prompt --vault-id dev@~/.config/ansible/dev-pass
ansible-playbook site.yml --vault-id prod@./get-vault-pass.sh                  # executable: prints the password on stdout
ansible-vault rekey --vault-id prod@prompt --new-vault-id prod@prompt group_vars/prod/vault.yml   # rotate the password
ansible-vault view group_vars/prod/vault.yml                                   # decrypt to stdout without editing
ansible-vault decrypt --output - group_vars/prod/vault.yml | yq '.vault_db_password'   # one value, nothing written to disk

vault_identity_list in ansible.cfg supplies the ids automatically. Without vault_id_match = True, Ansible tries every supplied password against every vaulted value, which works but hides which id a file actually needs. Encrypted strings inside otherwise plain YAML (!vault | blocks from encrypt_string) keep diffs reviewable because only the changed value is opaque, but they cannot be rekeyed in place; re-encrypt the value and paste it.

Configuration and performance#

# ansible.cfg in the project directory
[defaults]
inventory = inventory/prod.yml
forks = 25
host_key_checking = True
callback_result_format = yaml
callbacks_enabled = ansible.posix.profile_tasks

[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s

ansible.cfg is read from ANSIBLE_CONFIG, then ./ansible.cfg, then ~/.ansible.cfg, then /etc/ansible/ansible.cfg; the first found is used and the others are ignored. Ansible ignores ./ansible.cfg in a world-writable directory. ansible-config dump --only-changed shows the effective non-default settings.

pipelining = True sends the module over the existing SSH session instead of copying a file per task, which is the largest single speed-up. It requires requiretty to be disabled in sudoers, which is the default on current distributions.

Note

The community.general.yaml callback (stdout_callback = yaml) was removed in community.general 12.0.0. Use callback_result_format = yaml with the default callback, available since ansible-core 2.13.

Testing with Molecule#

Molecule creates disposable instances, applies the role, applies it again to prove idempotence, runs a verifier and destroys the instances. Since Molecule 6 there is no molecule init role; scaffold the role with ansible-galaxy role init and add a scenario with molecule init scenario (creates molecule/default/). Drivers other than the built-in default (delegated) come from molecule-plugins; for Podman install molecule-plugins[podman] and the containers.podman collection.

# roles/nginx/molecule/default/molecule.yml
dependency:
  name: galaxy
  options: { requirements-file: requirements.yml }
driver:
  name: podman
platforms:
  - name: fedora
    image: registry.fedoraproject.org/fedora:44
    command: /sbin/init                          # systemd inside the container so service: works
    privileged: true
    pre_build_image: true
  - name: rhel9
    image: registry.access.redhat.com/ubi9/ubi-init
    pre_build_image: true
provisioner:
  name: ansible
  inventory:
    group_vars:
      all: { nginx_workers: 2 }
  config_options:
    defaults: { callback_result_format: yaml }
verifier:
  name: ansible                                  # runs verify.yml; testinfra is the alternative
scenario:
  test_sequence: [dependency, syntax, create, converge, idempotence, verify, destroy]
# molecule/default/converge.yml
- name: Converge
  hosts: all
  become: true
  roles: [{ role: nginx }]
---
# molecule/default/verify.yml
- name: Verify
  hosts: all
  gather_facts: false
  tasks:
    - name: nginx answers
      ansible.builtin.uri: { url: http://localhost/, status_code: 200 }
molecule test                          # whole sequence, destroys at the end even on failure
molecule create && molecule converge   # iterate: converge re-applies to the running instance
molecule verify
molecule login -h fedora               # shell into an instance
molecule idempotence                   # fails if a second converge reports changed
molecule destroy
molecule test -s ha                    # another scenario in molecule/ha/
molecule list

The idempotence step is the one that finds real bugs: a command without a guard, a template with a timestamp, or state: latest on a package each make the second run changed. Container instances lack a real init and kernel, so tasks touching sysctl, mounts, SELinux booleans or firewalld need when: ansible_virtualization_type != 'podman' or a VM driver.

ansible-lint#

ansible-lint checks playbooks, roles and collections against rules grouped into profiles of increasing strictness: min, basic, moderate, safety, shared, production. It also runs yamllint, and understands roles well enough to resolve variables and FQCNs.

# .ansible-lint at the project root
profile: production
exclude_paths: [.cache/, .github/, collections/]
skip_list:
  - package-latest             # state: latest is deliberate in the patching role
warn_list:
  - experimental
  - yaml[line-length]
enable_list:
  - no-log-password            # opt-in rules
  - no-same-owner
offline: true                  # do not install requirements or refresh schemas in CI
ansible-lint                                  # whole project, profile from .ansible-lint
ansible-lint --profile safety roles/nginx     # one path at a stricter or looser profile
ansible-lint --fix                            # apply autofixes (FQCN, key order, yaml formatting); review the diff
ansible-lint -L                               # list rules and their tags
ansible-lint --offline --parseable            # machine-readable path:line: [rule] message, for CI annotations
ansible-lint --generate-ignore                # write .ansible-lint-ignore with every current violation, then burn it down

Rules that fail most first runs: fqcn[action-core] (use ansible.builtin.copy), name[missing] and name[casing] (every task named, starting with a capital), yaml[truthy] (yes/no must be true/false), no-changed-when (command without changed_when), risky-file-permissions (copy/template without mode), and var-naming[no-role-prefix] (role variables must start with the role name). # noqa: rule-id on a task line silences one occurrence; prefer fixing the task.

Troubleshooting#

SymptomCauseCheck or fix
UNREACHABLESSH, not Ansiblessh -v deploy@web-01.example.com; check ansible_user, key and host key
Missing sudo password or Timeout waiting for privilege escalation promptbecome needs a password--ask-become-pass, or fix sudoers
MODULE FAILURE with a Python traceback or /usr/bin/python: not foundMissing or unsupported Python on the targetansible host -m ansible.builtin.raw -a 'command -v python3'; set ansible_python_interpreter
couldn't resolve module/actionCollection not installed, or a short name that no longer resolvesansible-galaxy collection list; use the FQCN
Conditional result ... was derived from value of type 'str'2.19+ requires boolean conditionalsRewrite the expression to return true/false
Task always reports changedcommand/shell without creates or changed_whenAdd a guard
Variable has the wrong valuePrecedenceansible-inventory --host <host>, debug: var= at the point of use
Works ad hoc, fails in the playDifferent become, user, environment or interpreterCompare with -vvv
Handler never runsThe notifying task reported ok, or a later failure dropped pending handlers--force-handlers or force_handlers: true
Slow runsFact gathering, pipelining off, low forksprofile_tasks callback, the settings above
Dynamic inventory returns no hosts, play “succeeds”Expired cloud credentials or wrong plugin file suffixansible-inventory -i inventory/prod.aws_ec2.yml --list -vvv; set any_unparsed_is_failed = True
Failed to import the required Python library on the controllerCollection module needs a library (boto3, kubernetes) in the control node’s venvpython3 -c 'import boto3' in the same interpreter as ansible --version
ansible_job_id undefined after an async taskTask skipped, or async without poll: 0 returned the final result instead of a jobCheck is skipped; with poll > 0 the register holds the result, not a job
Async task fails with could not find jobasync timeout shorter than the job, or ~/.ansible_async cleanedRaise async; check ansible_remote_tmp and home directory permissions
Role argument validation failureVariable missing or wrong type against meta/argument_specs.ymlThe message names the option; ansible-doc -t role roles/<name> shows the spec
Molecule converge works, idempotence failsA task reports changed on the second runmolecule converge twice with --diff, look for command, state: latest, templates with timestamps
Molecule create fails with permission errors on PodmanRootless Podman lacks the image or privileged needs a user namespacepodman pull <image>, molecule --debug create, drop privileged unless systemd is needed
ansible-lint passes locally, fails in CIDifferent profile, version or collection setPin ansible-lint in the CI image; ansible-lint --version; commit .ansible-lint
Template renders {{ var }} literallyValue came from an untrusted source (module result, external file) and 2.19+ no longer templates itRender explicitly in a task with ansible.builtin.template or restructure so the value is a playbook variable
Vault Decryption failedWrong password or vault id for this filehead -1 file shows the vault id; pass a matching --vault-id label@source

Oneliners#

# Hosts that are not reachable
ansible all -m ansible.builtin.ping -o | grep -v SUCCESS

# OS version per host
ansible all -m ansible.builtin.setup -a 'filter=ansible_distribution*' --tree "$TMPDIR/facts" >/dev/null && jq -r '"\(.ansible_facts.ansible_distribution) \(.ansible_facts.ansible_distribution_version)"' "$TMPDIR"/facts/*

# Package version everywhere
ansible web -m ansible.builtin.command -a 'rpm -q nginx' -o

# Every command or shell task without a guard
grep -rnE 'ansible\.builtin\.(shell|command):' roles/ playbooks/

# Variables one host resolves to
ansible-inventory --host web-01.example.com | jq 'keys'

# Vault files that are not encrypted
grep -L '^\$ANSIBLE_VAULT' group_vars/*/vault.yml

# Vault id every encrypted file expects
for f in group_vars/*/vault.yml; do printf '%s\t%s\n' "$f" "$(head -1 "$f" | cut -d';' -f4)"; done

# Hosts a play would touch, one per line, for review before a production run
ansible-playbook site.yml --limit prod --list-hosts | sed -n '/hosts (/,$p' | tail -n +2 | tr -d ' '

# Tasks that would run for a tag
ansible-playbook site.yml --tags nginx --list-tasks

# Uptime and load across the fleet, one line per host
ansible all -m ansible.builtin.command -a 'uptime' -o | sort

# Reboot required (Fedora/RHEL): non-zero exit means yes
ansible all -m ansible.builtin.command -a 'needs-restarting -r' -o | grep -v 'rc=0'

# Pending updates count per host
ansible all -m ansible.builtin.shell -a 'dnf -q check-update | grep -c . || true' -o

# Free memory in MB per host, sorted
ansible all -m ansible.builtin.setup -a 'filter=ansible_memfree_mb' -o | sed -E 's/^([^ ]+).*"ansible_memfree_mb": ([0-9]+).*/\2\t\1/' | sort -n

# Kernel version distribution
ansible all -m ansible.builtin.command -a 'uname -r' -o | awk '{print $NF}' | sort | uniq -c | sort -rn

# Effective ansible.cfg settings that differ from defaults
ansible-config dump --only-changed

# Which ansible.cfg is in use
ansible --version | grep 'config file'

# Every module a project uses, by FQCN, with counts
grep -rhoE '^\s+[a-z_]+\.[a-z_]+\.[a-z_]+:' roles/ playbooks/ | tr -d ' :' | sort | uniq -c | sort -rn

# Collections required by the project versus installed
diff <(yq -r '.collections[] | .name // .' requirements.yml | sort) <(ansible-galaxy collection list --format json | jq -r '.[] | keys[]' | sort -u)

# Variables defined in defaults/ that no task or template references
for v in $(yq -r 'keys[]' roles/nginx/defaults/main.yml); do grep -rq "$v" roles/nginx/{tasks,templates,handlers,vars} 2>/dev/null || echo "unused: $v"; done

# Role variables not prefixed with the role name (ansible-lint var-naming)
yq -r 'keys[]' roles/nginx/defaults/main.yml | grep -v '^nginx_'

# Run a single role against one host without a playbook
ansible web-01.example.com -m ansible.builtin.include_role -a name=nginx --become

# Template a file locally and print it, using a host's variables
ansible web-01.example.com -m ansible.builtin.template -a 'src=roles/nginx/templates/nginx.conf.j2 dest=/dev/stdout' --check --diff 2>/dev/null | sed -n '/^+++/,$p'

# Evaluate a Jinja expression against real inventory variables
ansible web-01.example.com -m ansible.builtin.debug -a "msg={{ groups['web'] | map('extract', hostvars, 'ansible_host') | list }}"

# Facts for one host saved as JSON for offline querying
ansible web-01.example.com -m ansible.builtin.setup --tree "$TMPDIR/facts" && jq '.ansible_facts | keys' "$TMPDIR/facts/web-01.example.com"

# Diff inventory between two branches without connecting anywhere
diff <(git show main:inventory/prod.yml | ansible-inventory -i /dev/stdin --list 2>/dev/null | jq -S .) <(ansible-inventory -i inventory/prod.yml --list | jq -S .)

# Start a long job everywhere and return immediately (job ids in the output)
ansible all -m ansible.builtin.command -a '/opt/my-app/bin/reindex' -B 3600 -P 0 --become

# Lint only files changed on this branch
git diff --name-only main... -- '*.yml' '*.yaml' | xargs -r ansible-lint

# Time per task for a run, slowest first
ANSIBLE_CALLBACKS_ENABLED=ansible.posix.profile_tasks ansible-playbook site.yml --check 2>&1 | sed -n '/^=====/,$p' | head -20

# Kill a stuck run's leftover async jobs on the targets
ansible all -m ansible.builtin.shell -a 'pkill -f ansible_async_wrapper; rm -rf ~/.ansible_async' --become

Scripts#

Verify that every host in inventory is reachable and has a usable Python and sudo before a production run, and print a table rather than 200 lines of JSON.

#!/usr/bin/env bash
# preflight.sh [limit]: connectivity, interpreter and become check for every host, tab-separated
set -euo pipefail
limit=${1:-all}
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT

ansible "$limit" -m ansible.builtin.setup -a 'gather_subset=min' --tree "$tmp/facts" >/dev/null 2>&1 || true
ansible "$limit" -m ansible.builtin.command -a 'id -un' --become -o > "$tmp/become.txt" 2>&1 || true

printf 'HOST\tREACHABLE\tPYTHON\tOS\tBECOME\n'
for host in $(ansible "$limit" --list-hosts | tail -n +2 | tr -d ' '); do
  f="$tmp/facts/$host"
  if [ -f "$f" ] && jq -e '.ansible_facts' "$f" >/dev/null 2>&1; then
    reach=yes
    py=$(jq -r '.ansible_facts.ansible_python_version // "?"' "$f")
    os=$(jq -r '"\(.ansible_facts.ansible_distribution // "?") \(.ansible_facts.ansible_distribution_version // "")"' "$f")
  else
    reach=NO py=- os=-
  fi
  if grep -q "^$host | CHANGED.*\"stdout\": \"root\"" "$tmp/become.txt"; then become=ok
  elif grep -q "^$host | " "$tmp/become.txt"; then become=FAIL
  else become=-; fi
  printf '%s\t%s\t%s\t%s\t%s\n' "$host" "$reach" "$py" "$os" "$become"
done | column -t -s $'\t'

Report configuration drift across the fleet by running the playbook in check mode and summarising which hosts would change and in which tasks, suitable for a nightly cron and a Slack post.

#!/usr/bin/env bash
# drift-report.sh PLAYBOOK [ansible-playbook args...]: hosts and tasks that would change under --check
set -euo pipefail
playbook=$1; shift
tmp=$(mktemp -d); trap 'rm -rf "$tmp"' EXIT

ANSIBLE_STDOUT_CALLBACK=ansible.builtin.json ANSIBLE_CALLBACK_RESULT_FORMAT=json \
  ansible-playbook "$playbook" --check --diff "$@" > "$tmp/run.json" || true   # non-zero when hosts are unreachable; still report

jq -r '
  .plays[] as $p
  | $p.tasks[]
  | .task.name as $t
  | .hosts | to_entries[]
  | select(.value.changed == true or .value.failed == true or .value.unreachable == true)
  | "\(.key)\t\(if .value.unreachable then "UNREACHABLE" elif .value.failed then "FAILED" else "would change" end)\t\($p.play.name) / \($t)"
' "$tmp/run.json" | sort > "$tmp/drift.tsv"

if [ ! -s "$tmp/drift.tsv" ]; then echo "no drift: every host matches $playbook"; exit 0; fi
printf 'hosts with drift: %s\n\n' "$(cut -f1 "$tmp/drift.tsv" | sort -u | wc -l)"
column -t -s $'\t' "$tmp/drift.tsv"
exit 2

Rotate a vaulted secret across environments: generate a new value, re-encrypt it with each environment’s vault id and rewrite only that key, leaving the rest of each vault file untouched.

#!/usr/bin/env python3
"""Rotate one vaulted variable in several vault files without decrypting the others to disk.

Usage: rotate-secret.py vault_db_password group_vars/prod/vault.yml group_vars/dev/vault.yml
Requires ANSIBLE_VAULT_PASSWORD_FILE or a vault_identity_list in ansible.cfg that covers every file.
"""
import secrets
import subprocess
import sys
from pathlib import Path

import yaml

key, *files = sys.argv[1:]
new_value = secrets.token_urlsafe(32)

for f in map(Path, files):
    decrypted = subprocess.run(["ansible-vault", "decrypt", "--output", "-", str(f)], check=True, capture_output=True, text=True).stdout
    data = yaml.safe_load(decrypted) or {}
    if key not in data:
        print(f"{f}: {key} not present, skipping", file=sys.stderr)
        continue
    data[key] = new_value
    vault_id = f.read_text().splitlines()[0].split(";")[3] if f.read_text().count(";") >= 3 else None
    cmd = ["ansible-vault", "encrypt", "--output", str(f)]
    if vault_id:
        cmd += ["--encrypt-vault-id", vault_id]
    subprocess.run(cmd, input=yaml.safe_dump(data, sort_keys=False), check=True, text=True)
    print(f"{f}: rotated {key}" + (f" (vault id {vault_id})" if vault_id else ""))
print("new value written; deploy with ansible-playbook and update the consuming service", file=sys.stderr)

Further reading#