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
All the examples in this post are specific to CentOS Linux distribution. For example, `yum` module is used to install Nginx server. On another Linux distribution there might be a different package management tool, like 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.

In case you wonder how Nginx HTTP server was installed and configured, you can find more info in references [4] and [5] at the end of this post.

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 }};
  }
}
The above file is a standard Nginx config file with Jinja2 placeholders {{ }}.

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
I have decided to move task “setting permissive mode for SELinux” to this role as well, as it is related to allowing Nginx to access files from different directories

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

References

Related Post

Leave a Reply

Your email address will not be published. Required fields are marked *