Chapter #3: Ansible Playbooks

In the previous tutorial, you learned how to use Ansible ad-hoc commands to run a single task on your managed hosts. In this tutorial, you will learn how to automate multiple tasks on your managed hosts by creating and running Ansible playbooks.

To better understand the differences between Ansible ad-hoc command and Ansible playbooks; you can think of Ansible ad-hoc commands as Linux commands and playbooks as bash scripts.

Ansible ad-hoc commands are ideal to perform tasks that are not executed frequently such us getting servers uptime, retrieving system information, etc.

On the other hand, Ansible playbooks are ideal to automate complex tasks like system patches, application deployments, firewall configurations, user management, etc.

Please notice that I have included all the playbooks, scripts, and files that I am going to discuss in this series in this GitHub repository.

Before you follow this Ansible Playbook tutorial, you should refer to the setup mentioned in the first chapter of the Ansible series.

Creating your first Ansible playbook

Playbooks are written in YAML (Yet Another Markup Language) format. If you don’t know YAML; I have included the most important YAML syntax rules in the figure below so you can easily follow along with all the playbook examples:

You should also be aware that YAML files also must have either a .yaml or .yml extension. I personally prefer .yml because it’s less typing, and I am lazy.

Also, YAML is indentation sensitive. A two-spaces indentation is the recommended indentation to use in YAML; however, YAML will follow whatever indentation system a file uses as long as it’s consistent.

It is beyond annoying to keep hitting two spaces in your keyboard and so do yourself a favor and include the following line in ~/.vimrc file:

autocmd FileType yaml setlocal ai ts=2 sw=2 et

This will convert the tabs into two spaces whenever you are working on a YAML file. Liked this handy Vim tip? You can get this book for advanced Vim tips.

Mastering Vim Quickly - Jovica Ilic
Exiting Mastering Vim Quickly From WTF to OMG in no time

Now let’s create your first playbook. In your project directory, create a file named first-playbook.yml that has the following contents:

[elliot@control plays]$ cat first-playbook.yml 
---
- name: first play
  hosts: all
  tasks: 
    - name: create a new file
      file:
        path: /tmp/foo.conf
        mode: 0664
        owner: elliot
        state: touch

This playbook will run on all hosts and uses the file module to create a file named /tmp/foo.conf; you also set the mode: 0664 and owner: elliot module options to specify the file permissions and the owner of the file. Finally, you set the state: touch option to make sure the file gets created if it doesn’t already exist.

To run the playbook, you can use the ansible-playbook command followed by the playbook filename:

ansible-playbook first-playbook.yml

Here's the complete output of the above command:

[elliot@control plays]$ ansible-playbook first-playbook.yml 

PLAY [first play] **************************************************************

TASK [Gathering Facts] *********************************************************
ok: [node4]
ok: [node3]
ok: [node1]
ok: [node2]

TASK [create a new file] *******************************************************
changed: [node4]
changed: [node3]
changed: [node1]
changed: [node2]

PLAY RECAP *********************************************************************
node1                      : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node2                      : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node3                      : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node4                      : ok=2    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

The output of the playbook run is pretty self-explanatory. For now, pay special attention to changed=1 in the PLAY RECAP summary which means that one change was executed successfully on the managed node.

Let’s run the following ad-hoc command to verify that the file /tmp/foo.conf is indeed created on all the managed hosts:

[elliot@control plays]$ ansible all -m command -a "ls -l /tmp/foo.conf"
node4 | CHANGED | rc=0 >>
-rw-rw-r-- 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf
node1 | CHANGED | rc=0 >>
-rw-rw-r--. 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf
node2 | CHANGED | rc=0 >>
-rw-rw-r--. 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf
node3 | CHANGED | rc=0 >>
-rw-rw-r--. 1 elliot root 0 Oct 25 03:20 /tmp/foo.conf

Notice that you can also run an Ansible ad-hoc command that will do exactly the same thing as first-playbook.yml playbook:

ansible all -m file -a "path=/tmp/foo.conf mode=0664 owner=elliot state=touch"

To read more about the file module, check its Ansible documentation page:

[elliot@control plays]$ ansible-doc file

Running multiple plays with Ansible Playbook

You have only created one play that contains one task in first-playbook.yml playbook. A playbook can contain multiple plays and each play can in turn contains multiple tasks.

Let’s create a playbook named multiple-plays.yml that has the following content:

[elliot@control plays]$ cat multiple-plays.yml 
---
- name: first play
  hosts: all
  tasks:
    - name: install tmux
      package:
        name: tmux
        state: present
    
    - name: create an archive
      archive:
        path: /var/log
        dest: /tmp/logs.tar.gz
        format: gz

- name: second play
  hosts: node4
  tasks:
    - name: install git
      apt:
        name: git
        state: present

This playbook has two plays:

  • First play (contains two tasks) - runs on all hosts.
  • Second play (contains one task) - only runs on node4.

Notice that I used the package module on the first play as it is the generic module to manage packages and it autodetects the default package manager on the managed nodes. I used the apt module on the second play as I am only running it on an Ubuntu host (node4).

The yum and dnf modules also exists and they work on CentOS and RHEL systems.

I also used the archive module to create a gzip compressed archive /tmp/logs.tar.gz that contains all the files in the /var/log directory.

Go ahead and run the multiple-plays.yml playbook:

[elliot@control plays]$ ansible-playbook multiple-plays.yml 

PLAY [first play] **************************************************************

TASK [install tmux] ************************************************************
changed: [node4]
changed: [node2]
changed: [node3]
changed: [node1]

TASK [create an archive] *******************************************************
changed: [node2]
changed: [node3]
changed: [node1]
changed: [node4]

PLAY [second play] *************************************************************

TASK [install git] *************************************************************
changed: [node4]

PLAY RECAP *********************************************************************
node1                      : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node2                      : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node3                      : ok=2    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node4                      : ok=3    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Everything looks good. You can quickly check if the /tmp/logs.tar.gz archive exists on all nodes by running the following ad-hoc command:

[elliot@control plays]$ ansible all -m command -a "file -s /tmp/logs.tar.gz"
node4 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:46 2020, max compression
node1 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:47 2020, max compression, original size 107458560
node3 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:47 2020, max compression, original size 75560960
node2 | CHANGED | rc=0 >>
/tmp/logs.tar.gz: gzip compressed data, was "/tmp/logs.tar", last modified: Sun Oct 25 04:40:47 2020, max compression, original size 52326400

I also recommend you check the following Ansible documentation pages and check the examples section:

[elliot@control plays]$ ansible-doc package
[elliot@control plays]$ ansible-doc archive
[elliot@control plays]$ ansible-doc apt
[elliot@control plays]$ ansible-doc yum

Verifying your playbooks (before you run it)

Although I have already shown you the steps to run Ansible playbooks, it is always a good idea to verify your playbook before actually running it. This ensures that your playbook is free of potential errors.

You can use the --syntax-check option to check if your playbook has syntax errors:

[elliot@control plays]$ ansible-playbook --syntax-check first-playbook.yml 
playbook: first-playbook.yml

You may also want to use the --check option to do a dry run of your playbook before actually running the playbook:

[elliot@control plays]$ ansible-playbook --check first-playbook.yml 

PLAY [first play] **************************************************************

TASK [Gathering Facts] *********************************************************
ok: [node4]
ok: [node3]
ok: [node1]
ok: [node2]

TASK [create a new file] *******************************************************
ok: [node4]
ok: [node1]
ok: [node2]
ok: [node3]

PLAY RECAP *********************************************************************
node1                      : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node2                      : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node3                      : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
node4                      : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 

Notice that dry running a playbook will not commit any change on the managed nodes.

You can use the --list-options to list the hosts of each play in your playbook:

[elliot@control plays]$ ansible-playbook --list-hosts multiple-plays.yml

playbook: multiple-plays.yml

  play #1 (all): first play	TAGS: []
    pattern: ['all']
    hosts (4):
      node4
      node2
      node1
      node3

  play #2 (node4): second play	TAGS: []
    pattern: ['node4']
    hosts (1):
      node4

You can also list the tasks of each play in your playbook by using the --list-tasks option:

[elliot@control plays]$ ansible-playbook --list-tasks multiple-plays.yml

playbook: multiple-plays.yml

  play #1 (all): first play	TAGS: []
    tasks:
      install tmux	TAGS: []
      create an archive	TAGS: []

  play #2 (node4): second play	TAGS: []
    tasks:
      install git	TAGS: []

You can also check the ansible-playbook man page for a comprehensive list of options.

Re-using tasks and playbooks

You may find yourself writing multiple playbooks that all share a common list of tasks. In this case, it’s better to create a file that contains a list of all the common tasks and then you can reuse them in your playbooks.

To demonstrate, let’s create a file named group-tasks.yml that contains the following tasks:

[elliot@control plays]$ cat group-tasks.yml 
- name: create developers group
  group:
    name: developers
- name: create security group
  group:
    name: security
- name: create finance group
  group:
    name: finance

Now you can use the import_tasks module to run all the tasks in group-tasks.yml in your first playbook as follows:

[elliot@control plays]$ cat first-playbook.yml 
---
- name: first play
  hosts: all
  tasks: 
    - name: create a new file
      file:
        path: /tmp/foo.conf
        mode: 0664
        owner: elliot
        state: touch
    
    - name: create groups
      import_tasks: group-tasks.yml

You can also the import_playbook module to reuse an entire playbook. For example, you can create a new playbook named reuse-playbook.yml that has the following content:

[elliot@control plays]$ cat reuse-playbook.yml 
---
- name: Reusing playbooks
  hosts: all
  tasks:
    - name: Reboot the servers
      reboot:
        msg: Server is rebooting ...

- name: Run first playbook
  import_playbook: first-playbook.yml

Also notice that you can only import a playbook on a new play level; that is, you can’t import a play within another play.

You can also use the include module to reuse tasks and playbooks. For example, you can replace the import_playbook statement with the include statement as follows:

[elliot@control plays]$ cat reuse-playbook.yml 
---
- name: Reusing playbooks
  hosts: all
  tasks:
    - name: Reboot the servers
      reboot:
        msg: Server is rebooting ...

- name: Run first playbook
  include: first-playbook.yml

The only difference is that the import statements are pre-processed at the time playbooks are parsed. On the other hand, include statements are processed as they are encountered during the execution of the playbook. So, in summary, import is static while include is dynamic.

Running selective tasks and plays with Ansible playbook

You can choose not to run a whole playbook and instead may want to run specific task(s) or play(s) in a playbook. To do this, you can use tags.

For example, you can tag the install git task in the multiple-plays.yml playbook as follows:

[elliot@control plays]$ cat multiple-plays.yml 
---
- name: first play
  hosts: all
  tasks:
    - name: install tmux
      package:
        name: tmux
        state: present
    
    - name: create an archive
      archive:
        path: /var/log
        dest: /tmp/logs.tar.gz
        format: gz

- name: second play
  hosts: node4
  tasks:
    - name: install git
      apt:
        name: git
        state: present
      tags: git

Now you can use the --tags options followed by the tag name git to only run the install git task:

[elliot@control plays]$ ansible-playbook multiple-plays.yml --tags git

PLAY [first play] **************************************************************

PLAY [second play] *************************************************************

TASK [install git] *************************************************************
ok: [node4]

PLAY RECAP *********************************************************************
node4                      : ok=1    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 

As you can see, the first two plays were skipped and only the install git did run. You can also see changed=0 in the PLAY RECAP and that’s because git is already installed on node4.

You can also apply tags to a play in a similar fashion.

Alright! This takes us to the end of the Ansible playbooks guide.

Stay tuned for next tutorial as you are going to learn how to work with Ansible variables, facts, and registers. Don't forget to become a pro member of Linux Handbook.