Ansible Starter - The missing manuals
I found it difficult to get into Ansible by reading the official documentation. This post is an attempt at addressing that by:
- providing trimmed-down examples to focus on what’s essential
- explaining the core concepts that are needed to understand what is going on
- highlighting the important entry points in the documentation to go further
It is not a complete documentation! It only provides a minimal (hopefully solid) foundation to reduce the confusion and speed up further learning. It assumes a basic knowledge of YAML. You can look at Yaml Starter if you need a refresher.
This guide will also serve as a personal cheat sheet, so it may be updated in the future.
Quick links
- Ansible’s reference documentation
- Playbook keywords page: documents the structure of
Play,Task,Block,Role. - Index of all modules
- List of special variables
ansible-doc <module-name>shows this module’s documentation. The name must be fully qualified (e.g.ansible.builtin.debug)
Getting Started
Ansible is meant for system administration of remote hosts.
That means quite a few things need to be specified even for trivial setups - let’s say, ensuring the existence of a file on a single machine.
The Most Minimal Setup
In the most bare-bone (useful) case, Ansible needs:
- A list of machines to apply operations to. Ansible calls this inventory.
This is specified in INI or YAML format, usually called
inventory.iniorinventory.yaml. - A way to connect to administered machines. Usually ssh, using whichever setup
you prefer: by default Ansible simply runs
ssh <hostname>at the beginning and relies on ssh to perform the actual authentication (key, password, etc.). - A way to gain privileges on administered machines. For example
sudoorsu. This has to be passwordless because it wouldn’t make sense to ask for a password on hundreds of machines. - A list of operations to apply. Ansible calls this a playbook.
Always in YAML format, typically called
playbook.yaml, but can be anything.
Concepts
At a very high conceptual level there are only two important YAML structures that everything revolves around:
- A
Taskis something to do on a target machine. For example ensuring that a file exists with a particular content. - A
Playis a list of Tasks that are executed in order.
A playbook is a file (not a data structure) that contains a list
of Plays. It’s essentially a synonym for “list of Plays”. It’s
what Ansible requires as input, so even if you have a single Task you
still need to define a Play with that single Task.
Invocations
Here are some common invocations. See below for detail about concepts not introduced yet.
# Apply the configuration from 'playbook.yaml'
# This is the most common thing to do.
ansible-playbook -i inventory.ini playbook.yaml
# Show all the groups of machines that have been defined
# in inventory.ini, including implicit groups.
ansible-inventory -i inventory.ini --list
# List modules in a collection
# ansible-doc -l <namespace>.<collection>
ansible-doc -l ansible.builtin
# Show the documentation for a module
ansible-doc ansible.builtin.debug
Ansible Configuration
The structure expected in the yaml files is defined implicitly by Ansible’s parser. This section explains the essentials. All the details can be found in the playbook keywords documentation page.
Note: one difficulty with Ansible’s configuration files is that the expected type of their content (e.g. list of Play, list of Task) can only be inferred from how this file is used. For example, a playbook is a playbook only because it’s used as an argument to
ansible-playbook: it’s difficult to infer the type of what’s in an Ansible YAML file by looking at its content. See section “Tasks Organization” below for how to deal with that in practice.
Play
A playbook is the top-level yaml file that is processed by the
ansible-playbook command. It expects a list of yaml mappings, each
with the structure of Play. Assuming to have “myhosts”
defined in your inventory, the example playbook below prints three
messages, in the order they appear in the file:
- name: Play 1
hosts: myhosts
tasks:
- name: Print a message
ansible.builtin.debug:
msg: "Hello world"
- name: Print another message
ansible.builtin.debug:
msg: "Hello world, again"
- name: Play 2
hosts: myhosts
tasks:
- name: Print a message
ansible.builtin.debug:
msg: "Hello world from play 2"
Often-used keys of a Play
name(string): a description meant for the user. Optional, but necessary in practice to make sense of Ansible’s logs.tasks: a list of things to do, with the structure ofTask.hosts: which hosts to apply the tasks to.become_method,become,become_user: specifying which user the tasks should be run as by default. If not specified, it’s the user that was used to connect to the hosts.vars: mapping of arbitrary key/value pairs. The values can then be injected in tasks defined as part of thisPlay. See section “Variables” below.
Tasks
The tasks key in a Play is a list of Tasks, each specifying
something to be done on the target host, or a state to be
reached. Ansible is meant to run only tasks when necessary: if the
state on the target is already correct, nothing is done. For example,
if the task is to copy a file to the target host, if the file is
already present with the right content, nothing is performed.
Tasks have a return value which can be accessed by other tasks
specified after in the same list of tasks, if that return value has
been given a name using the register key (see below).
Often-used keys of Task
name(string): description meant for the user, but that can also be used to select which task(s) to run throughansible-playbook.<name of a module>: defines what the task does. For exampleansible.builtin.debug(see example above). The value is a mapping with a structure specific to this module type, which defines precisely. what the module is supposed to do. This key is confusingly named ‘action’ in the documentation, with almost no hint that what is meant there is the name of the module itself instead of the word ‘action’.vars(arbitrary key-value pairs): same meaning as forPlay.become_method,become,become_user: (optionally) specifies which user the tasks should be run as. If not specified, it’s the user that was used to connect to the hosts. Overrides any values specified atPlaylevel.register: name under which this task’s return value can be subsequently accessed (see section “Variables” below)when: make it possible to skip the task depending on the output of a conditional expression.
Ansible ships with a lot of modules by default, you may want to start by looking at the
ansible.builtincollection first.
Each module name is composed of three parts:
<namespace>.<collection>.<module_name>, which is also how the documentation is organized.
Variables
Overview
Variables are the Ansible way of writing more generic configuration. A
common way of defining variables is through the vars object.
Quick example (as a playbook):
- name: Copy Latex files
hosts: myhosts
vars:
basename: "hello"
tasks:
- name: Copy .tex file
ansible.builtin.debug:
msg: "Copying {{ basename }}.tex"
- name: Copy .pdf file
ansible.builtin.debug:
msg: "Copying {{ basename }}.pdf"
The ‘vars’ key is present on all structures that can contain other
tasks: Play, Role, Block, Task. A variable is available to all
tasks defined lower in the hierarchy.
Another way of defining a variable is through register, which gives
a name to a task’s return value.
Example (this is a list of tasks, not a playbook):
- name: "copy existing file"
vars:
filename: "/some/local/file"
block:
- ansible.builtin.stat:
path: "{{ filename }}"
become: false
delegate_to: localhost
# Defines file_stat
register: file_stat
- ansible.builtin.copy:
src: "{{ filename }}"
dest: "/dest/dir/on/target"
owner: "root"
group: "root"
mode: '0644'
# Use file_stat here
when: file_stat.stat.exists
Note: the variable
filenamecould be defined at a higher level, to make the block of tasks fully reusable.
What this example does is:
-
runs
ansible.builtin.staton the local machine, to check whether ‘filename’ exists. theregisterkey makes it save the return value into a variable calledfile_stat. The return value is a mapping documented on theansible.builtin.statdocumentation page. -
runs
ansible.builtin.copyconditionally, based on the value offile_stat.stat.exists, through thewhenattribute. If that value is truthy, the task is run, otherwise it is ignored.
The above code is thus similar to the following Python code (but Ansible does much more than that)
filename = "/some/local/file"
file_stat = os.path.exists(filename)
if file_stat:
copy_to_remote_host(filename, "/dest/dir/on/target", remote_host=...)
Magic variables
Ansible automatically defines a lot of variables (in a way similar to
make). This is documented on the page about special variables.
The only commonly useful variable is playbook_dir: path to the
directory containing the playbook that is being run (i.e. the one
passed as argument to the ansible-playbook binary). It allows
referencing local files easily.
Tasks organization
While technically you could put all the tasks into a single play inside a single playbook, that just doesn’t work for complex setups. There are many different way of grouping tasks together in a meaningful way, and sharing them between plays.
Ways of writing tasks
‘name’ is optional: you don’t have to name your tasks, which is sometimes handy when the list of tasks is the interesting thing, or the task cannot fail (e.g. debug message), or when you include a list of tasks like below.
Example playbook (see below for more detail about builtin.import_tasks):
- name: Print some messages
hosts: myhost
tasks:
- ansible.builtin.debug:
msg: "Before running other tasks"
- ansible.builtin.import_tasks: "{{ playbook_dir }}/tasks/other_tasks.yaml"
- ansible.builtin.debug:
msg: "After running other tasks"
Tasks can be grouped using the special block task: sometimes there
are tasks that conceptually go together and for which it’s worth
having some common setup (e.g. variables). This is what the block
task provides. In particular makes it possible to name a group of
tasks.
Example playbook
- name: Print some messages
hosts: myhost
tasks:
- name: display several messages
block:
- ansible.builtin.debug:
msg: "message 1"
- ansible.builtin.debug:
msg: "message 2"
- name: one more message
ansible.builtin.debug:
msg: "another message"
Often-used keys for a Task with a block action:
- some keys also present in
Task:name,vars,become*,when. But notregister. - a block can also be specified using the word
alwaysinstead ofblock. In that case all tasks in the group are run regardless of errors.
Using multiple files
We list here some common options.
Use completely separate playbooks
Useful when you have orthogonal tasks to run, that do not share anything. For example, you could have a playbook to do some initial setup for Ansible (run once), then another one to do the long-term configuration.
Import plays from another playbook
Use the ansible.builtin.import_playbook module to include all the
plays from a playbook into another one.
The two playbooks below are exactly equivalent to the playbook at the beginning of section ‘Play’ above:
# file: playbook.yaml
- name: Play 1
hosts: myhosts
tasks:
- name: Print a message
ansible.builtin.debug:
msg: "Hello world"
- name: Print another message
ansible.builtin.debug:
msg: "Hello world, again"
- ansible.builtin.import_playbook: included.yaml
# file: included.yaml
- name: Play 2
hosts: myhosts
tasks:
- name: Print a message 2
ansible.builtin.debug:
msg: "Hello world from play 2"
Import tasks from another file
Use the ansible.builtin.import_tasks module to import a list of
Tasks instead of a list of Plays. It makes it possible to
write a list of tasks in a separate file, instead of having them in
the playbook itself. This is a good way of sharing lists of tasks
between plays, especially when combined with variables.
Example:
# file: playbook.yaml
- name: Play 1
hosts: myhosts
tasks:
- name: Print a message
ansible.builtin.debug:
msg: "Hello world"
- name: Print another message
ansible.builtin.debug:
msg: "Hello world, again"
This can be replaced by:
# file: playbook.yaml
# Contains a list of Play
- name: Play 1
hosts: myhosts
tasks:
- name: execute mytasks
ansible.builtin.import_tasks: "{{ playbook_dir }}/tasks/mytasks.yaml"
# file: tasks/mytasks.yaml
# Contains a list of Task. This is not a playbook!
- name: Print a message
ansible.builtin.debug:
msg: "Hello world"
- name: Print another message
ansible.builtin.debug:
msg: "Hello world, again"
The content of mytasks.yaml is literally the same as in the original
playbook.yaml file, just dedented.
playbook_dir is a special variable automatically defined by Ansible,
that contains the path to the playbook being executed (see the
Variables section).
Gotcha: The above is a good example of how it’s difficult to infer
the nature of a file just by inspecting the content. The file playbook.yaml
is a playbook mainly because it’s used as an argument to ansible-playbook,
a list of tasks is a list of tasks only because the file is used as
argument to the import_tasks module.
For this reason, being disciplined about where files live is
essential: follow Ansible recommended directory layout, or a
simplified version of it. At the very least group files by type of
content: tasks/, handlers/, templates/, files/, etc. so you
know right away how to interpret each file just by looking at the
directory name.
For the nitpicky out there: yes, you can infer what the content of a file by looking at its content, most of the time. In practice it’s much easier to look at the file path than the content itself.
Yoyonax