OnCallReady

Lesson 33.2 · Ansible: Configuration as Code · 28 min read

Inventory and ad-hoc commands

In plain words

An inventory is the address book of your servers, with tabs. Every server has a name and maybe a note ("this one listens on port 8080"), and you can put servers under tabs like "web" or "db", or even tabs that contain other tabs, like "prod" containing "web" and "db".

When you tell Ansible to do something, you do not list servers one by one; you say "the web tab", or "everything in prod except web-2". Ansible looks the names up, connects to each one over SSH and runs the command. A quick one-off command like that is an ad-hoc command, and it is the fastest way to ask the whole fleet a question.

The problem

Ansible can manage four hosts or four thousand, but before it can do anything it needs a list: which machines exist, how to reach them, and how they are grouped ("the web servers", "everything in production"). That list is the inventory. Get it wrong and the right playbook runs on the wrong machines - which in production is worse than not running at all.

What you need to know already: the previous lesson (control node, managed nodes, idempotency), 1.17 (SSH keys and known_hosts), 4.3 (users and groups), 7.4 (reading command output in columns).

An inventory in INI format

The simplest inventory is a text file in INI style. One host per line, groups in brackets:

# inventory.ini
[lb]
lb-1

[web]
web-1
web-2

[db]
db-1

[prod:children]      # a group made of groups
lb
web
db

Each line can also carry host variables as key=value pairs, and a [group:vars] section sets variables for a whole group:

[web]
web-1 ansible_host=10.0.5.11 nginx_port=8080
web-2 ansible_host=10.0.5.12

[web:vars]
ansible_user=learner

The ansible_ variables are the connection variables - how to reach the host:

ansible_host                 the address to connect to (default: the inventory name)
ansible_user                 the SSH user (default: remote_user in ansible.cfg, else your name)
ansible_port                 the SSH port (default 22)
ansible_ssh_private_key_file which key to offer
ansible_connection           ssh (default), local (run on the control node itself)
ansible_python_interpreter   the Python on the host

A gotcha with real consequences: on a host line, values are read as Python literals, so nginx_port=8080 is the number 8080 and debug=False is a boolean. In a [group:vars] section every value is a string: debug=False there is the non-empty string "False", which is true in a condition. Put typed variables in YAML files instead (group_vars/, next lesson but one).

The same in YAML

The other common format is YAML. It nests: all has children (groups), groups have hosts and vars:

# inventory.yml
all:
  children:
    web:
      hosts:
        web-1:
          nginx_port: 8080
        web-2:
    db:
      hosts:
        db-1:
    prod:
      children:
        web:
        db:

Both formats describe the same thing; teams pick one and stick to it. YAML is easier when variables get structured (lists, dictionaries), INI is easier to read at a glance.

Where Ansible looks

Every command takes -i FILE (or a directory of inventory files, or a comma list: -i web-1,web-2, - the trailing comma matters). Without -i, Ansible uses the inventory setting of its configuration file, and without that /etc/ansible/hosts, which does not exist on this box:

$ cd ~/oncall-lab/labs/1a-ansible/try/inventory
$ ANSIBLE_CONFIG=/dev/null ansible all --list-hosts
[WARNING]: Unable to parse /etc/ansible/hosts as an inventory source
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
[WARNING]: Could not match supplied host pattern, ignoring: all
  hosts (0):

Those two warnings mean "you have no inventory". Ansible always has one host, the implicit localhost, but it is not part of all.

ansible.cfg

The configuration file is ansible.cfg, an INI file. Ansible reads exactly one, the first it finds:

  1. the file named in $ANSIBLE_CONFIG;
  2. ./ansible.cfg in the current directory;
  3. ~/.ansible.cfg;
  4. /etc/ansible/ansible.cfg.

So a project directory with its own ansible.cfg gets its own settings whenever you run Ansible from there. A typical project file is short:

[defaults]
inventory = inventory.ini
interpreter_python = auto_silent

(Ansible refuses ./ansible.cfg when the directory is world-writable, with a warning, because anyone could plant settings there - keep project directories 755.)

Any setting can also come from an ANSIBLE_* environment variable, which beats the file. ansible-config dump --only-changed prints what differs from the defaults and where each value came from - the first thing to run when "my setting is ignored":

$ ansible-config dump --only-changed
CONFIG_FILE() = /home/learner/oncall-lab/labs/1a-ansible/try/inventory/ansible.cfg
DEFAULT_HOST_LIST(/home/learner/oncall-lab/labs/1a-ansible/try/inventory/ansible.cfg) = ['/home/learner/oncall-lab/labs/1a-ansible/try/inventory/inventory.ini']
INTERPRETER_PYTHON(/home/learner/oncall-lab/labs/1a-ansible/try/inventory/ansible.cfg) = 'auto_silent'

A misspelt key in ansible.cfg (host_key_cheking) is silently ignored. dump shows what really took effect.

Reading the inventory back

ansible-inventory shows what Ansible understood:

$ ansible-inventory --graph
@all:
  |--@ungrouped:
  |--@prod:
  |  |--@lb:
  |  |  |--lb-1
  |  |--@web:
  |  |  |--web-1
  |  |  |--web-2
  |  |--@db:
  |  |  |--db-1
$ ansible-inventory --host web-1
{
    "nginx_port": 8080
}

--graph draws the group tree (@ marks a group). --host prints the variables a host gets from the inventory. --list dumps everything as JSON (-y for YAML).

Patterns: choosing hosts

Every command takes a pattern that selects hosts from the inventory:

all  or  *          every host
web                 a group
web-1               one host
web:db              union: in web OR in db
prod:&web           intersection: in prod AND in web
web:!web-2          exclusion: in web but NOT web-2
web-*               a glob over host names
~web-\d             a regular expression (starts with ~)
web[0]              the first host of the group

--limit (-l) narrows any run further with another pattern. It is the safety habit for changes: run on one host first (-l web-1), look, then the rest. --list-hosts shows what a pattern matches without touching anything:

$ ansible 'prod:!db' --list-hosts
  hosts (3):
    lb-1
    web-1
    web-2

Ad-hoc commands

ansible (without -playbook) runs one module on the hosts a pattern selects - an ad-hoc command. The shape is ansible PATTERN -m MODULE -a "ARGUMENTS".

The first one everybody runs:

$ ansible all -m ping
lb-1 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.14"
    },
    "changed": false,
    "ping": "pong"
}
web-1 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.14"
    },
    "changed": false,
    "ping": "pong"
}
web-2 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.14"
    },
    "changed": false,
    "ping": "pong"
}
db-1 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.14"
    },
    "changed": false,
    "ping": "pong"
}

ping is not ICMP ping. It logs in over SSH, runs a tiny Python module and returns "pong". A SUCCESS proves the whole chain: name resolution, SSH, the key, a login shell and Python. The output format of ad-hoc runs:

web-1 | SUCCESS => {...}        ran, nothing changed (JSON result)
web-1 | CHANGED => {...}        ran and changed something
web-1 | CHANGED | rc=0 >>       a command: its exit code, then its output
web-1 | FAILED! => {...}        the module failed
web-1 | UNREACHABLE! => {...}   Ansible could not connect at all

Without -m, the module is command: it runs a program with arguments, with no shell in between. Pipes, redirection, * and ; are passed to the program as plain arguments:

$ ansible web -a 'uname -r'
web-1 | CHANGED | rc=0 >>
7.0.0-31-generic
web-2 | CHANGED | rc=0 >>
7.0.0-31-generic

When you need shell features, use the shell module (-m shell -a 'ps aux | grep nginx'). Prefer command when you can: no surprises from quoting and globbing.

Two more flags you will use constantly:

$ ansible web -b -a 'whoami'
web-1 | CHANGED | rc=0 >>
root
web-2 | CHANGED | rc=0 >>
root

The exit code of ansible itself: 0 when every host was fine, 2 when a host failed, 4 when a host was unreachable.

Host keys and the interpreter warning

The very first connection to a host shows the same known_hosts question ssh asks (1.17), once per host. Three ways to handle it, from best to worst:

  1. Pre-trust the keys when the hosts are built: ssh-keyscan web-1 >> ~/.ssh/known_hosts from the provisioning, or answer yes once.
  2. host_key_checking = False in ansible.cfg (or ANSIBLE_HOST_KEY_CHECKING=False): convenient in a throwaway lab, and it means Ansible would happily talk to a machine pretending to be your server.
  3. Ignoring the prompt in a script: the run fails with Host key verification failed.

Then, once per host per run:

[WARNING]: Host 'web-1' is using the discovered Python interpreter at '/usr/bin/python3.14', but future
installation of another Python interpreter could cause a different interpreter to be discovered.

Ansible looked for a Python on the host and found /usr/bin/python3.14. The warning says "this choice could change if someone installs another Python". Either pin it (ansible_python_interpreter=/usr/bin/python3 in the inventory) or tell Ansible discovery is fine: interpreter_python = auto_silent in ansible.cfg.

What you can now do

Why it helps

Almost every Ansible mistake in production starts with the wrong hosts: a change meant for one canary that went to every server, or a group that silently included the database. Reading an inventory, checking a pattern with --list-hosts before running anything, and knowing where ansible.cfg points are the habits that prevent it.

Ad-hoc commands are also the on-call tool: "is nginx running on every web server?", "how much disk is left on the db group?", answered for twenty hosts in one line instead of twenty SSH sessions.

Commands in this lesson

cd ansible-config ansible-inventory ansible

FAQ

INI or YAML inventory?

Both describe the same thing: hosts, groups, children and variables. INI is shorter for simple fleets and is what most examples use; YAML is clearer when groups nest deeply or variables are structured. ansible-inventory --graph and --list show what Ansible understood from either, which is the quickest way to catch a typo.

Why do I see warnings about the Python interpreter?

Ansible looks for a Python on each host and tells you which one it picked, because a later Python upgrade could change it. Setting interpreter_python = auto_silent in ansible.cfg (as the lab does) keeps the discovery and drops the warning; setting ansible_python_interpreter pins an exact path when you need one.

How do patterns combine groups?

A colon joins groups: web:db is either. web:&canary is the hosts in both, and web:!web-2 is web without web-2. Unions are applied first, then intersections, then exclusions, whatever order you write them in. Quote the pattern in bash, because ! and & mean something to the shell.

Where does Ansible find its config and inventory?

It uses the first ansible.cfg it finds: the ANSIBLE_CONFIG variable, then ./ansible.cfg in the current directory, then ~/.ansible.cfg, then /etc/ansible/ansible.cfg. The inventory comes from -i or from the inventory line of that file. ansible --version prints which config file it used.

What is the difference between command and shell?

The command module runs a program directly, without a shell, so pipes, redirects and variables like $HOME do not work, which also makes it safer. The shell module runs the line through /bin/sh and allows them. Both always report changed, because Ansible cannot know whether the command changed anything.

In an interview Junior

What is an inventory, and how do you target a subset of hosts?

The inventory lists the managed hosts, grouped: in INI a [web] section, a parent group with [prod:children], and host variables on the host line or in host_vars/ (group variables in a [group:vars] section or group_vars/). Every host is also in the group all. To target a subset you give a pattern: a group name, a host, or combinations such as web:!web-2 or prod:&canary, in an ad-hoc command (ansible web -m ansible.builtin.ping), a play's hosts:, or --limit. I check the pattern first with --list-hosts, which shows the hosts without running anything, and ansible-inventory --graph shows the whole tree.

Also asked: What is the difference between ansible and ansible-playbook? · How do you set a variable for one host only? · What does the host key prompt mean the first time Ansible connects?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.