Playbooks are the main power of ansible. I like to think of a playbook as an easy to manage script. Playbooks allow us to run multiple sets of tasks called plays, define variables and add logic such as conditions and loops.
This is an example of a very basic playbook:
---
- name: first play
hosts: web.example.com #The host must be selected from the inventory
vars:
# some variable definitions]
#configurations for example
order: # The order in which hosts are selected, options: inventory, reverse_inventory, sorted, reverse_sorted and shuffle
remote_user: root
become: true
hosts:
tasks:
- name: first task
yum:
name: httpd
status: present
- name: second task
service:
name: httpd
enabled: true
- name: second play
hosts: database.example.com
tasks:
- name: first task
service:
name: mariadb
enabled: true
...
So to clarify the terminology:
In the example we see a playbook that contains 2 plays, this playbook is a simple YAML file that the ansible command can get as an input and run. Like any languadge ansible playbooks have specific syntax we need to use when writing them, here are some points we need to keep in mind:
vertical bar (|) - newline characters within the string are preserved.
greater-than (>) - newline characters are to be converted to spaces and that leading white spaces are removed.
include_newlines: |
Example Company
123 Main Street
Atlanta, GA 30303
fold_newlines: >
This is an example
of a long string,
that will become
a single sentence once folded.
To verify that the syntax is correct:
ansible-plybook --syntax-check
** See Ansible docs for more examples and clarification.
Plays can override the user and privileges set on the configuration file. The following keys are available for configuration within a play:
To run an ansible playbook we just use the ansible-playbook command:
ansible-playbook PLAYBOOK_FILE
We can run a playbook as dry-run to see what would happen without really change anything on the hosts:
ansible-playbook -C PLAYBOOK_FILE
We can also change the verbosity level:
One of the most important features of playbooks is that it should be safe to re-running. Meaning that if a specific task needs to do something that as already been done it will not be executed, this saves us a lot of problems.
Most ansible modules are safe to re-run and that makes our life easier when writing the playbook, but we need to keep in mind that some modules are not safe like the command and shell modules. We need to be careful when writing a playbook that uses unsafe modules and add some condition to not run the task in case the desired outcome as already been reached.
Ansible variables are the same as any other language they are used to store values that can be reused, or hold outputs from previous commands. This can simplify the creation and maintenance of a playbook. In the above example we saw a very simple playbook that installed the httpd package and restarted the httpd service, we could have used a variable to hold the httpd value.
Variables can be defined in many places but the main scopes are:
ansible-playbook main.yml -e "package=apache"
Play scope - set in the play and related structures.
- hosts: all
vars:
user: joe
home: /home/joe
# playbook:
...
- hosts: all
vars_files:
- vars/users.yml
...
# cat users.yml:
user: joe
home: /home/joe
Host scope:
- hosts: web_servers
tasks:
- shell: /usr/bin/foo
register: foo_result
ignore_errors: True
- shell: /usr/bin/bar
when: foo_result.rc == 5
** ansible variables must start with a letter and can only contain letters, numbers, and underscores.
A variable can be:
vars:
user: joe #string
id: 1 # number
friends_list: # list
- cartman
- kenny
friends_dict: # dictionary
best: kenny
funnest: cartman
The difference between a list and a dictionary, is that list variables are accessed with an index like friends_list[0] and dictionary variables are accessed with a key like friends_dict[‘best’] or friends_dict.best
To use the variable in a task we wrap it with curly braces {{ }}, note that we need to use quotes "” when a variable is used as the first element:
tasks:
# No need for quotes "" since user is not the first element
- name: Creates the user {{ user }}
user:
# Need quotes "" since user is the first element
name: "{{ user }}"
From least to greatest (the last listed variables winning prioritization):
When we write a script we often need to preform some actions multiple times, pull/skip a task until a certain condition is met. Ansible lets you do this with loops and conditional tasks.
We can iterate a task over a set of items using the loop keyword. The loop keyword is added in the task level, and takes as a value a list or dictionary of items over which the task should be iterated. The variable {{ item }} holds the value used during each iteration.
Example:
# Using list
- name: firewalld and sshd are running
service:
name: "{{ item }}"
state: started
loop:
- firewalld
- sshd
# Using dictionary
- name: Users exist and are in the correct groups
user:
name: "{{ item.name }}"
state: present
groups: "{{ item.groups }}"
loop:
- name: cartman
groups: wheel
- name: kenny
groups: root
Consult the documentation for more advanced looping scenarios link.
You can register the output of a loop as a variable. For example:
- shell: "echo {{ item }}"
loop:
- "one"
- "two"
register: echo
When you use register with a loop, the data structure placed in the variable will contain a results attribute that is a list of all responses from the module.
Ansible can use conditionals to execute tasks or plays when certain conditions are met. Playbook variables, registered variables, and Ansible facts can all be tested with conditionals. Operators to compare strings, numeric data, and Boolean values are available.
The when statement is used to run a task conditionally. In the when statement we define the condition or conditions that needs to be meet the execute the task. We can use and, or, () to combine multiple conditions
---
- name: Simple Boolean Task Demo
hosts: all
vars:
s_name: my_service
tasks:
- name: "{{ s_name }} is started when there is enough memory"
service:
name: "{{ s_name }}"
state: started
when:
- ansible_memfree_mb > 8192
- ansible_distribution == "RedHat"
Equal (value is a string) | ansible_machine == "x86_64"
Equal (value is numeric) | max_memory == 512
Numeric comparessions | < | > | <= | => | !=
Variable exists | min_memory is defined
Variable does not exist | min_memory is not defined
Boolean variable is true | memory_available
Boolean variable is false | not memory_available
element in linst | ansible_distribution in supported_distros
# OR
- when: ansible_distribution == "RedHat" or ansible_distribution == "Fedora"
# And
- when: ansible_distribution_version == "7.5" and ansible_kernel == "3.10.0-327.el7.x86_64"
- when:
- ansible_distribution_version == "7.5"
- ansible_kernel == "3.10.0-327.el7.x86_64"
# Combine conditions
- when: >
( ansible_distribution == "RedHat" and
ansible_distribution_major_version == "7" )
or
( ansible_distribution == "Fedora" and
ansible_distribution_major_version == "28" )
Playbooks have a basic event system that can be used to respond to changes made by tasks in the playbook. These ‘events’ are used to notify the playbook on certain actions and they are triggered by the notify keyword. The events are triggered at the end of each block of tasks in a play, and will only be triggered once even if notified by multiple different tasks.
Each event or ‘notify‘ statment can have a handler to handle the event. Handlers are tasks that respond to a notification triggered by other tasks. Handlers can be considered as inactive tasks that only get triggered when explicitly invoked using a notify statement.
Example: Apache server is only restarted by the restart apache handler when a configuration file is updated and notifies it:
tasks:
- name: copy demo.example.conf configuration template
template:
src: /var/lib/templates/demo.example.conf.template
dest: /etc/httpd/conf.d/demo.example.conf
notify:
- restart apache
handlers:
- name: restart apache
service:
name: httpd
state: restarted
Some notes to keep in mind:
IMPORTANT Handlers are meant to perform an extra action when a task makes a change to a managed host. They should not be used as a replacement for normal tasks.
Ansible evaluates the return code of each task to determine whether the task succeeded or failed. Normally, when a task fails Ansible immediately aborts the rest of the play on that host, skipping all subsequent tasks. However, sometimes you might want to have play execution continue even if a task, there are a number of approaches to this, depending on the desired outcome:
Ignoring Task Failure: We can ignore failed tasks with the ignore_errors keyword.
name: Latest version of notapkg is installed
yum:
name: notapkg
state: latest
ignore_errors: yes
Forcing Execution of Handlers after Task Failure:
Normally when a task fails and the play aborts on that host, any handlers that had been notified by earlier tasks in the play will not run. If you set force_handlers: yes on the play, then notified handlers are called even if the play aborted because a later task failed.
hosts: all
force_handlers: yes
tasks:
- name: a task which always notifies its handler
command: /bin/true
notify: restart the database
- name: a task which fails because the package doesn't exist
yum:
name: notapkg
state: latest
handlers:
- name: restart the database
service:
name: mariadb
state: restarted
Specifying Task Failure Conditions:
You can use the failed_when keyword on a task to specify which conditions indicate that the task has failed. This is often used with command modules that may successfully execute a command, but the command’s output indicates a failure.
tasks:
- name: Run user creation script
shell: /usr/local/bin/create_users.sh
register: command_result
failed_when: "'Password missing' in command_result.stdout"
The fail module can also be used to force a task failure. The above scenario can alternatively be written as two tasks:
tasks:
- name: Run user creation script
shell: /usr/local/bin/create_users.sh
register: command_result
ignore_errors: yes
- name: Report script failure
fail:
msg: "The password is missing in the output"
when: "'Password missing' in command_result.stdout"
You can use the fail module to provide a clear failure message for the task. This approach also enables delayed failure, allowing you to run intermediate tasks to complete or roll back other changes.
Specifying When a Task Reports “Changed” Results:
The changed_when keyword can be used to control when a task reports that it has changed.
- name: get Kerberos credentials as "admin"
shell: echo "{{ krb_admin_pass }}" | kinit -f admin
changed_when: false
# changed_when: false == only reports ok or failed.
tasks:
- shell:
cmd: /usr/local/bin/upgrade-database
register: command_result
changed_when: "'Success' in command_result.stdout"
notify:
- restart_database
handlers:
- name: restart_database
service:
name: mariadb
state: restarted
Ansible Blocks and Error Handling:
In playbooks, blocks can be used to control how tasks are executed. For example, a task block can have a when keyword to apply a conditional to multiple tasks:
- name: block example
hosts: all
tasks:
- name: installing and configuring Yum versionlock plugin
block:
- name: package needed by yum
yum:
name: yum-plugin-versionlock
state: present
- name: lock version of tzdata
lineinfile:
dest: /etc/yum/pluginconf.d/versionlock.list
line: tzdata-2016j-1
state: present
when: ansible_distribution == "RedHat"
Blocks also allow for error handling in combination with the rescue and always statements. If any task in a block fails, tasks in its rescue block are executed in order to recover.
tasks:
- name: Upgrade DB
block:
- name: upgrade the database
shell:
cmd: /usr/local/lib/upgrade-database
rescue:
- name: revert the database upgrade
shell:
cmd: /usr/local/lib/revert-database
always:
- name: always restart the database
service:
name: mariadb
state: restarted
Note that a condition on a block also applies to its rescue and always if present.