Ansible – from tasks to roles
In this post I am going to introduce Ansible roles and walk you through a process of iterative refinement of an Ansible project.
The goal here will be to automatically deploy and configure a simple repository serving static files. I will start from a playbook with multiple tasks and slowly build towards a better, more versatile and reusable structure. By the end of this article you will have acquired the knowledge about Ansible roles and some of the best practices regarding Ansible project structure.
It is assumed that you know the basics of Ansible. In case you do not, please read the previous post first: Introduction to Ansible.
Task based playbook
Our repository will be a simple HTTP server that serves files from specific directory on a given TCP port.
So, what we need to do is:
- install HTTP server
- configure the HTTP server to serve files from a specified directory
- open TCP port in firewall to allow remote access to the server
- create files in the repository directory
The port of our choosing is 8080
and the files directory is /var/repositories/repository1/
. We will create file1
, file2
and file3
in that directory.
Let’s see how we can do it with Ansible playbook deploy/play.yml
at first:
- hosts: every
become: yes
tasks:
- name: configuring Nginx repository
copy:
dest: /etc/yum.repos.d/nginx.repo
content: |
[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/centos/7/$basearch/
gpgcheck=0
enabled=1
- name: installing http server
yum:
name: nginx
state: installed
- name: removing default http server config file
file:
path: /etc/nginx/conf.d/default.conf
state: absent
- name: setting permissive mode for SELinux
selinux:
policy: targeted
state: permissive
- name: creating file repository directory
file:
dest: /var/repositories/repository1
state: directory
- name: stopping http server
service:
name: nginx
state: stopped
- name: configuring file repository http access
template:
src: repository.conf.j2
dest: /etc/nginx/conf.d/repository1.conf
vars:
port: 8080
directory: /var/repositories/repository1
- name: starting http server
service:
name: nginx
state: started
- name: opening firewall port
firewalld:
port: 8080/tcp
permanent: yes
state: enabled
- name: creating static files
copy:
dest: "/var/repositories/repository1/{{ item }}"
content: |
some content of {{ item }}...
with_items:
- file1
- file2
- file3
apt
, so the task will have to use apt
module instead.All that is left is to tell Ansible to play that playbook:
$ ansible-playbook deploy/play.yml -i inventory
After Ansible successfully finishes the deployment, the server will be up and running. We can try fetching some static files to test it:
$ curl http://{ip_address}:8080/file1
some content of file1...
Great! We have our repository. That was quite easy. We have just used several Ansible modules: yum
, copy
, file
, template
, service
, firewalld
and selinux
.
Notice that in the task that is using template
module, we are referencing repository.conf.j2
template file. This file is placed in the root folder of the project and it specifies Nginx HTTP server configuration. Here is its content:
server {
listen {{ port }};
server_name localhost;
location / {
autoindex on;
root {{ directory }};
}
}
{{ }}
.If you analyse that playbook in depth, you will notice that there are several places with repeated information, i.e. /var/repositories/repository1
and 8080
port. This is not the best solution, because changing these values in the future requires more diligence than needed in case a variable was used. To improve this, we can use variables file or Ansible facts feature, which allows us to capture data in temporary variables.
The /var/repositories
string seems like a root directory for the actual repository directory, so we can place this value in a variable, e.g. in group_vars/every.yml
file (notice that every
is the group name of hosts for which all tasks are run):
---
repositories_dir: /var/repositories
For the repository name and port we can use the facts feature.
Let’s see how the playbook looks now:
- hosts: every
become: yes
tasks:
- set_fact:
repository_name: repository1
repository_port: 8080
# ... (omitted tasks)
- name: creating file repository directory
file:
dest: "{{ repositories_dir }}/{{ repository_name }}"
state: directory
# ... (omitted tasks)
- name: configuring file repository http access
template:
src: repository.conf.j2
dest: "/etc/nginx/conf.d/{{ repository_name }}.conf"
vars:
port: "{{ repository_port }}"
directory: "{{ repositories_dir }}/{{ repository_name }}"
# ... (omitted tasks)
- name: opening firewall port
firewalld:
port: "{{ repository_port }}/tcp"
permanent: yes
state: enabled
- name: creating static files
copy:
dest: "{{ repositories_dir }}/{{ repository_name }}/{{ item }}"
content: |
some content of {{ item }}...
with_items:
- file1
- file2
- file3
That looks good. We have used captured variables using Jinja2 templates3 notation.
But, what if we need to create other repositories or add security measures to the same or another group of hosts? We can of course start copying some tasks to achieve this, but it is not a good approach for obvious reasons (the Don’t Repeat Yourself rule). What can we do?
Introducing Ansible roles
As you saw above, Ansible allows you to define playbooks in which you specify a sequence of tasks that are acted out on the hosts of your choosing. This is sufficient, provided there are not many things to accomplish.
However, when your servers require a lot of software components with complex configuration, specifying all these tasks in a single playbook can clutter it significantly. On top of that, you might need to use files with variables or configuration file templates that will be spread all over the place. This makes the playbook and the project itself hard to maintain and expand. To address these issues Ansible provides us with a role mechanism.
Ansible role is a concept of bringing related tasks, variables and files into a well-structured component. This component can then be invoked from a playbook or from another role and it can be parameterized. All these characteristics make roles very versatile.
A role is basically a directory with multiple specific subdirectories and files, resembling the one below:
roles/
http_server/
defaults/
main.yml
tasks/
main.yml
files/
file1.txt
file2.csv
...
templates/
config.conf.j2
...
vars/
main.yml
In the above example you can see a http_server
role in roles
folder with several subfolders: defaults
, tasks
, files
, templates
and vars
. These folder names are based on convention established by Ansible1.
The main.yml
file is the default file that Ansible looks for when processing subfolders. For example, the tasks/main.yml
file specifies default tasks which the role performs. You may also create files with other names (e.g. to improve a large role’s structure), but to use them you have to reference them explicitly from the default files.
The other folders are:
defaults
– this folder is for default variables used by the role (these variables can be easily overridden; Ansible defines variable precedence in their documentation2),files
– here you place miscellaneous files, which might be copied to a remote server,templates
– here you place Jinja2 template files3, which Ansible will process and send to the server,vars
– here you place miscellaneous variables used by the role
Having this knowledge, we can start refactoring our playbook.
Moving tasks into roles
As you probably noticed, some tasks are related purely to installing and configuring the HTTP server, namely:
- configuring Nginx repository
- installing http server
- removing default http server config file
Let’s then move these tasks into a new role – http_server_install
:
deploy/
roles/
http_server_install/
tasks/
main.yml
And the content of tasks’ main file becomes:
---
- name: configuring Nginx repository
copy:
dest: /etc/yum.repos.d/nginx.repo
content: |
[nginx]
name=nginx repo
baseurl=http://nginx.org/packages/centos/7/$basearch/
gpgcheck=0
enabled=1
- name: installing http server
yum:
name: nginx
state: installed
- name: removing default http server config file
file:
path: /etc/nginx/conf.d/default.conf
state: absent
- name: setting permissive mode for SELinux
selinux:
policy: targeted
state: permissive
Now we can further refine the playbook:
- hosts: every
become: yes
roles:
- { role: http_server_install }
tasks:
# ... (omitted tasks)
Here is another form with a different way of invoking the role:
- hosts: every
become: yes
tasks:
- import_role:
name: http_server_install
# ... (omitted tasks)
To further improve the playbook, we might consider moving or grouping other tasks.
Notice we have a task that stops the HTTP server and another one that starts it. Let’s move these into separate roles to hide the detail that Nginx is the server.
Setting up the repository requires several steps – creating its directory, preparing HTTP server config file for given TCP port and directory, and opening the port in firewall. As these steps are related to defining a repository, let’s move them into a role.
There is another activity lurking in the playbook – creating repository files. Seems like a nicely cut responsibility, so a role can be created for handling it.
Opening a firewall port can be placed in a separate role for convenience, too.
Let’s define the below roles:
http_server_start
http_server_stop
repository_setup
repository_files_setup
open_firewall_port
The http_server_start/tasks/main.yml
file becomes:
---
- name: starting http server
service: name=nginx state=started
The server stopping role is analogous, having state=stopped
.
The open_firewall_port/tasks/main.yml
file becomes:
---
- name: opening firewall port {{ port }}
firewalld: port="{{ port }}/tcp" permanent=yes state=enabled
I have parameterized the above role with port
parameter. You will see later on how to invoke such a role.
Notice that you can reference a variable in task name, which makes it easier to troubleshoot issues while running the playbook.
The repository_files_setup/tasks/main.yml
file becomes:
---
- name: creating static files for repository '{{ name }}'
copy:
dest: "{{ repositories_dir }}/{{ name }}/{{ item.name }}"
content: "{{ item.content }}"
with_items: "{{ files }}"
In the above role I have used Ansible’s with_items
feature which allows iterating over list of values.
This role is also parameterized. It requires name
and files
params. The first one specifies the directory name, the latter one a list of file
dictionary objects (“file” is just an abstract name here). These file
objects require name
and content
attributes, first being the file name, second being the content of the file.
Lastly, let’s see how the repository_setup
role looks like:
---
- name: creating file repository directory
file:
dest: "{{ repositories_dir }}/{{ name }}"
state: directory
- name: configuring file repository http access
template:
src: repository.conf.j2
dest: /etc/nginx/conf.d/{{ name }}.conf
vars:
port: "{{ port }}"
directory: "{{ repositories_dir }}/{{ name }}"
- include_role:
name: open_firewall_port
vars:
port: "{{ port }}"
Apart from the above, I also moved the repository.conf.j2
Jinja2 template3 to roles/repository_setup/templates/
folder. Thanks to this you do not have to provide an absolute or relative path to the template file.
Notice that there is an invocation of open_firewall_port
role with its parameter port
. This is great, we just reused a role in another role.
Alright, we have a nicely defined project structure now:
inventory
deploy/
play.yml
roles/
http_server_install
http_server_start
http_server_stop
open_firewall_port
repository_files_setup
repository_setup
And so, our playbook becomes much more concise:
- hosts: every
become: yes
roles:
- { role: http_server_install }
- { role: http_server_stop }
- { role: repository_setup,
name: repository1,
port: 8080
}
- { role: http_server_start }
- { role: repository_files_setup,
name: repository1,
files: "{{ repository1.files }}"
}
Let’s see what happens. Firstly, we install the HTTP server. Then, we stop it to configure our repository1
. Later on, we start the server and create files in the same repository by invoking repository_files_setup
role.
The best gain is that we can now create another repository just by invoking repository_setup
and repository_files_setup
with different parameters.
Best practices summary
Here is a concise list of the best practices which I used above:
- parameterize roles
- reference variables in task names to aid troubleshooting playbook issues
- encapsulate low-level details in roles
- reuse roles in other roles
Summary
In this post I have presented Ansible roles – what they are and how you can use them in your projects. We have performed several iterations of an automation project for deploying and configuring static files serving repositories. We have learned the best practices for creating roles and structuring Ansible projects.
For your convenience, I have created a repository on GitHub with the above code and some documentation. Enjoy! https://github.com/danielpmichalski/ansible-from-tasks-to-roles