Understand Ansible Inventory, Facts and Ad-Hoc Commands

Learn how Ansible decides which hosts to manage, how to run ad-hoc commands against groups, and how to use facts to answer real operational questions.

Share
Understand Ansible Inventory, Facts and Ad-Hoc Commands

In the first article you built the lab and ran ./lab ping. Ansible connected to four hosts and reported pong. Useful, but the interesting question is how Ansible knew which hosts to contact at all.

The answer is the inventory, and it is the single most important idea in Ansible. It is also the part I ask people to slow down on: before you write bigger playbooks, you should be able to read an inventory, target the right hosts with confidence, and ask hosts questions without logging into them.

By the end of this article, you will be able to:

  • Read the lab inventory and explain every line.
  • Run ad-hoc commands against one host, one group or everything.
  • Limit a run to a subset of hosts.
  • Gather facts and pull out the values you care about.
  • Answer real operational questions without SSHing into a single machine.

As in the rest of the series, run the ./lab commands as .\lab.ps1 if you are on Windows without WSL. Start the lab first with ./lab up if it is not already running.

The Operational Problem

I have been asked a version of the same question on every team I have joined: "Which of our servers are on Ubuntu 24.04, and what Python do they run?"

The manual answer is to SSH into every server and check. That works for four machines and fails for forty. The Ansible answer is one command that asks every machine at once and returns structured results. That is what this article builds towards.

The Inventory Is a Contract

The inventory tells Ansible which hosts exist, how they are grouped and how to reach them. The lab inventory lives at inventory/lab/hosts.yml and looks like this:

---
all:
  vars:
    # Key path as seen inside the forge control-node container.
    ansible_ssh_private_key_file: /tmp/lab-ssh/id_ed25519
  children:
    linux:
      hosts:
        atlas:
          ansible_host: atlas
        beacon:
          ansible_host: beacon
        ledger:
          ansible_host: ledger
        vaultbox:
          ansible_host: vaultbox
    web:
      hosts:
        beacon:
    database:
      hosts:
        ledger:
    secrets:
      hosts:
        vaultbox:

Read it from the top:

  • all is a built-in group that always contains every host.
  • vars under all applies to every host. Here it tells Ansible which SSH private key to use.
  • children defines groups. A group is just a named set of hosts.
  • linux contains all four managed hosts.
  • web, database and secrets are role-based groups. beacon is a web host, ledger is a database style host, vaultbox is for secrets practice.

Notice that beacon appears in two groups. That is normal and useful. Hosts belong to as many groups as make sense. In real estates you see groups like production, staging, london, web, postgres, and a host sits in several at once.

To see the group structure as a tree, run:

./lab inventory

This wraps ansible-inventory --graph and prints something like:

@all:
  |--@ungrouped:
  |--@linux:
  |  |--atlas
  |  |--beacon
  |  |--ledger
  |  |--vaultbox
  |--@web:
  |  |--beacon
  |--@database:
  |  |--ledger
  |--@secrets:
  |  |--vaultbox

When you join a team that uses Ansible, this graph command is one of the first things worth running. It shows you the shape of the estate in seconds.

How Ansible Decides How to Connect

Ansible combines the inventory with configuration. The lab's ansible.cfg sets the defaults:

[defaults]
inventory = inventory/lab
host_key_checking = False
retry_files_enabled = False
stdout_callback = default
interpreter_python = auto_silent
roles_path = ./roles

[ssh_connection]
pipelining = True

Two lines matter right now:

  • inventory = inventory/lab means you do not have to pass -i on every command.
  • host_key_checking = False is a lab-only shortcut. Lab containers are rebuilt constantly, so their host keys change constantly. On real infrastructure you should leave host key checking on and manage known hosts properly.

The connection user lives in the inventory too, one level away from hosts.yml: the file inventory/lab/group_vars/all.yml sets ansible_user: learner, and every managed host has a learner account with the lab's public key installed. group_vars is Ansible's standard place for variables that apply to a whole group, here the built-in all. So a command like ping really means "SSH to each host as learner with the lab key, run a module, return JSON".

Ad-Hoc Commands: One-Off Questions

An ad-hoc command runs a single module against selected hosts without writing a playbook. The shape is always the same:

ansible <pattern> -m <module> -a "<module arguments>"

The lab forwards any Ansible command line into the control-node container, so you can run the real thing:

./lab ansible all -m ansible.builtin.ping

Now narrow the target. One group:

./lab ansible web -m ansible.builtin.command -a "uptime"

One host:

./lab ansible ledger -m ansible.builtin.command -a "df -h /"

The <pattern> part accepts more than plain names:

  • all targets everything.
  • web targets a group.
  • web:database targets the union of two groups.
  • linux:!web targets linux hosts that are not in web.

Before running anything with side effects, check what a pattern matches:

./lab ansible 'linux:!web' --list-hosts
  hosts (3):
    atlas
    ledger
    vaultbox

Make --list-hosts a habit. It is the difference between "I think this targets the right servers" and "I know it does". Note the quotes around the pattern: ! means something to your shell, so quote patterns that contain it.

Limits: Narrowing an Existing Target

--limit restricts a run to a subset of whatever the pattern matched:

./lab ansible all -m ansible.builtin.ping --limit atlas

That looks redundant here, because you could just write atlas as the pattern. The value shows up later with playbooks: a playbook hard-codes hosts: linux, but you can still run it against a single machine first with --limit atlas. Same automation, smaller blast radius.

Facts: Asking Hosts About Themselves

Facts are structured data Ansible gathers from each host: operating system, IP addresses, memory, disks, Python version and much more. Gather them with the setup module:

./lab ansible atlas -m ansible.builtin.setup

That prints a wall of JSON. Nobody reads all of it. Filter instead:

./lab ansible all -m ansible.builtin.setup -a "filter=ansible_distribution*"
atlas | SUCCESS => {
    "ansible_facts": {
        "ansible_distribution": "Ubuntu",
        "ansible_distribution_major_version": "24",
        "ansible_distribution_release": "noble",
        "ansible_distribution_version": "24.04"
    },
    "changed": false
}
...

That is the answer to the question from the start of the article, for every host, in one command. The lab also has a shortcut that runs a small facts playbook:

./lab facts

It prints the distribution, version and Python version for each host, which is the summary you usually want.

Facts matter beyond ad-hoc queries. Playbooks use them to make decisions: install one package name on Debian and another on Red Hat, tune a template based on memory, skip a task on the wrong OS family. You will use ansible_facts values inside templates in a later article.

Command vs Shell vs Modules

You will be tempted to do everything with -m ansible.builtin.command. Resist it.

  • ansible.builtin.command runs a binary with arguments. No pipes, no redirects, no shell features.
  • ansible.builtin.shell runs through a shell, so pipes work. Use it only when you genuinely need shell features.
  • Dedicated modules (apt, user, copy, service and hundreds more) know how to check current state and only change what needs changing.

Compare these two approaches to checking whether a package is installed:

./lab ansible atlas -m ansible.builtin.shell -a "dpkg -l | grep curl"
./lab ansible atlas -m ansible.builtin.apt -a "name=curl state=present" --check

The first is a text search you have to interpret. The second asks the package manager directly and reports changed: false if curl is already there. The --check flag means "report what would change without changing anything". Modules with this behaviour are the foundation of idempotence, which the next article covers properly.

The practical rule: reach for a module first, command second, shell last.

A Realistic Audit Session

Put it together. Here is a short session that answers real questions a team might ask, without logging into anything:

# Which hosts can we reach right now?
./lab ansible all -m ansible.builtin.ping

# What kernel is each host running?
./lab ansible all -m ansible.builtin.command -a "uname -r"

# How much disk is free on the database hosts?
./lab ansible database -m ansible.builtin.command -a "df -h /"

# Which hosts have pending package upgrades? (lab-friendly check)
./lab ansible linux -m ansible.builtin.command -a "apt list --upgradable"

Each command targets by group, not by memorised hostnames. That is the shift Ansible asks you to make: think in groups and roles, not individual machines.

Common Problems

Pattern Matches Nothing

[WARNING]: No hosts matched, nothing to do

Check spelling against ./lab inventory. Group names are exact.

Host Unreachable

UNREACHABLE!

Run ./lab status to confirm the containers are up, and ./lab reset if the lab is in a strange state.

Shell Ate Your Pattern

If linux:!web behaves oddly, your shell expanded the !. Quote the pattern.

Clean Up

Nothing in this article changed host state except the curl check, so there is nothing to undo. Stop the lab if you are done for now:

./lab down

What You Learnt

  • The inventory defines hosts and groups, and groups are how you should think about servers.
  • Ad-hoc commands run one module against a pattern of hosts.
  • --list-hosts and --check let you preview before you act.
  • Facts turn "log in and look" into "ask everything at once".
  • Modules beat raw shell commands because they understand state.

In the next article we write a real playbook: bringing a fresh Linux server to a known baseline, running it safely with check and diff mode, and seeing idempotence in action.

Keep Reading

If you have not built the lab yet, start with the first article in the series: Set Up an Ansible Practice Lab in Under 10 Minutes. The lab also has a home at CloudSprocket Labs, with a quick start, FAQ and the full article series.

You can find more DevOps and automation articles on the homepage at alishaikh.me.

Official Documentation

If you want to dig deeper into what we used today, these official guides are the best places to start:

  • Inventory: the inventory guide covers everything hosts.yml can express, including the INI format and dynamic inventories that build themselves from your cloud provider.
  • Targeting: the patterns guide goes beyond the unions and exclusions we used, with wildcards, regular expressions and intersections.
  • Ad-hoc commands: the ad-hoc commands guide has more one-liners across file transfer, package management and service control.
  • Facts: facts and magic variables lists everything Ansible knows about your hosts, and the setup module documents the filter syntax we used to tame it.
  • Command vs shell: the command module page spells out exactly what it does and does not support, which is the quickest way to settle the "why is my pipe not working" confusion.