Creating an Ansible role from a playbook: modular, reusable code

After we ran ad-hoc commands and created a monolith playbook, we will increase our level of automation. We will separate our code much better with introducing modular, reusable file structures called roles. Ansible roles will load variables, handlers and tasks automatically for us based on a defined directory and file structure.

Though Ansible is very flexible and we can use it in a lot of ways, there is a really powerful structure that will allow us to bring out the maximum of our automation.

Let’s create a new directory in our Ansible project home and call it roles. It must be in the project directory where our inventory and the playbooks directories reside.

Now we will use a new tool called ansible-galaxy to create a skeleton of the role. The reference for using the command can be found here.

$ ansible-galaxy init common
- Role common was created successfully

The command will create the proper directory hierarchy for us in the newly created common role. The directory structure looks like the following:

$ tree common
common
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── README.md
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml

A role is technically a collection of the above files and directories.

By default Ansible will look in each directory within a role for a main.yml file for relevant content (also main.yaml and main):

  • tasks/main.yml – the main list of tasks that the role executes.
  • handlers/main.yml – handlers, which may be used within or outside this role.
  • library/my_module.py – modules, which may be used within this role (see Embedding modules and plugins in roles for more information).
  • defaults/main.yml – default variables for the role (see Using Variables for more information). These variables have the lowest priority of any variables available, and can be easily overridden by any other variable, including inventory variables.
  • vars/main.yml – other variables for the role (see Using Variables for more information).
  • files/main.yml – files that the role deploys.
  • templates/main.yml – templates that the role deploys.
  • meta/main.yml – metadata for the role, including role dependencies and optional Galaxy metadata such as platforms supported.

Let’s take our play-base-config.yml playbook to the next level with separating the tasks, variables and handlers! Let’s see where are we now! We have a playbook with target host information on the top of it followed by tasks and variables. We have created a skeleton of our new common role. We call it “common” because we want to run this code on every test server we deploy.

Now we will open our play-base-config.yml playbook in VSCode and start cutting it up to pieces.

The host information and the connection meta data will remain in the playbook.

---
- name: Basic OS and user setup
  hosts: all
  become: true
  become_user: root

This information is followed by the task definition below it.

Let’s copy everything from below the tasks: keyword into our new role’s tasks/main.yml file!

Well, now we can separate the variables from the code. There are two places in our role for these variables: the vars directory and the defaults directory. The defaults has the lowest precedence, so we will put here our data.

Reference: https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_variables.html

Let’s open the defaults/main.yml file as well in our editor and start moving the variables there with taking care of the YAML data types. We name our variables carefully, putting the role name in it and its function. Months later we will thank it for us. It is even better idea to comment our code and variables. Memories fade, but the comments and documentation are everlasting.

Let’s edit the defaults/main.yml file now!

---
# defaults file for common

common_packages:
  - vim
  - tmux
  - python3
  - python3-apt
  - sshpass
  - git
  - wget
  - curl
  - zsh

common_git_repo_url: https://github.com/tmolnar0831/dotfiles.git
common_git_checkout_dest: /home/tmolnar/stuff/dotfiles
common_git_checkout_version: 4bfbaa2917844a46ab936bddba5125af16c10bca

common_root_copy_files:
  - { src: /home/tmolnar/stuff/dotfiles/.vimrc, dest: /root/.vimrc }
  - { src: /home/tmolnar/stuff/dotfiles/.tmux.conf, dest: /root/.tmux.conf }
  - { src: /home/tmolnar/stuff/dotfiles/.zshrc, dest: /root/.zshrc }

common_user_copy_files:
  - { src: /home/tmolnar/stuff/dotfiles/.vimrc, dest: /home/tmolnar/.vimrc }
  - { src: /home/tmolnar/stuff/dotfiles/.tmux.conf, dest: /home/tmolnar/.tmux.conf }
  - { src: /home/tmolnar/stuff/dotfiles/.zshrc, dest: /home/tmolnar/.zshrc }

Yes, it could be much nicer with creating variables for the users and directories as well, but it will be fine for now. We are just practicing though. We start with a working code base and iteratively build upon it.

As the next step we have to copy our tasks to the tasks/main.yml file. We have to take care of referring to the variables we have created in our variables file. In YAML and Jinja2 when we start a token with {{ we must quote or double quote it like "{{!

Here is the tasks/main.yml file with the referenced variables from the defaults/main.yml.

---
# tasks file for common

- name: Install the basic packages
  ansible.builtin.apt:
    name: "{{ common_packages }}"
    state: present

- name: Check out my dotfiles repository from Github
  ansible.builtin.git:
    repo: "{{ common_git_repo_url }}"
    dest: "{{ common_git_checkout_dest }}"
    version: "{{ common_git_checkout_version }}"
  become_user: tmolnar

- name: Copy the config files to the root user
  ansible.builtin.copy:
    src: '{{ item.src }}'
    dest: '{{ item.dest }}'
    owner: root
    group: root
    mode: '0754'
    remote_src: true
  loop: "{{ common_root_copy_files }}"

- name: Copy the config files to my home
  ansible.builtin.copy:
    src: '{{ item.src }}'
    dest: '{{ item.dest }}'
    owner: tmolnar
    group: tmolnar
    mode: '0754'
    remote_src: true
  loop: "{{ common_user_copy_files }}"
  become_user: tmolnar

- name: Change the user shell to zsh
  ansible.builtin.user:
    name: tmolnar
    shell: /usr/bin/zsh

We are almost there! We have to use this role in the playbook instead of listing these tasks. Our playbook will look like this:

---
- name: Basic OS and user setup
  hosts: vmware
  become: true
  become_user: root

  roles:
    - role: common

Instead of adding the tasks directly here, we just included the new role in the playbook file.

There is one more tweak we have to do: we moved our playbooks into the playbooks directory. As playbooks will look for roles relative to their place, they will not find our role out of the box. A configuration file adding our roles directory in the roles_path will solve this issue.

Let’s create an ansible.cfg file in the project directory with something similar content:

[defaults]
roles_path = /home/tmolnar/stuff/projects/ansible_lab/roles

Done!

With running the code we can make sure that our playbook finds the role, the tasks see the variables and everything works well.

$ ansible-playbook -i inventory playbooks/play-base-config.yml --become --ask-become-pass --limit vmware --check -vvv

Using the -vvv option gives us a super verbose output in which we can check the passed variables too.

If you have anything to share then please visit my Tom’s IT Cafe Discord Server!

2 thoughts on “Creating an Ansible role from a playbook: modular, reusable code

Leave a comment