Applying design patterns to write maintainable Ansible

Writing Ansible code typically involves a procedural mindset. However, as the codebase expands, the lack of adequate abstractions can slow down modifications. Drawing from design pattern lessons can enhance the codebase’s resilience and adaptability, allowing for quicker responses to business requirements. In this article, I’ll discuss how adopting the principles of three design patterns has improved our Ansible codebase, accommodating an increasing diversity of hardware, operating systems, network configurations, and external services.
First, let’s review three design patterns for better understanding and application.

Strategy Pattern

The Strategy Pattern enables the dynamic selection of algorithms at runtime. For example, consider reaching the fourth floor of a building with both staircases and an elevator. This pattern offers the flexibility to choose the elevator on days when your legs are sore, or opt for the stairs to get some cardio in if you’ve skipped the gym recently.

Dependency Injection

Dependency Injection is a technique where dependencies are provided (injected) at runtime rather than being hardcoded within the component itself. This promotes loose coupling, making the software easier to extend, maintain, and test. A relatable example is home renovation; instead of directly managing individual contractors for electrical, plumbing, and carpentry work, you entrust a general contractor with the task. This contractor then coordinates all the necessary resources, akin to how dependency injection manages component dependencies.

Dependency Inversion

Dependency Inversion emphasizes that high-level modules should not depend on low-level modules, but both should rely on abstractions. Moreover, abstractions should not be dependent on details, but rather, details should depend on abstractions. To illustrate, consider the electrical system in a house: the power outlets or the appliances you plug in do not need to be modified if you change your electricity provider. The outlets are designed to a universal standard (an abstraction), not to the specifics of any single provider.

Applying Design Pattern Lessons in Ansible

Let’s envision a scenario where we’re tasked with provisioning a web development stack. Initially, we create a straightforward Ansible playbook to install Apache and MySQL:

- name: Setup a webserver
  hosts: localhost
  tasks:
    - name: Install Apache
      debug:
        msg: "Apache will be installed"
      tags: apache
    - name: Install MySQL
      debug:
        msg: "MySQL will be installed"
      tags: mysql

Later, a request arrives to support a project using a MERN stack, necessitating the setup of MongoDB and Nginx. One approach could be to create an additional playbook:

- name: Setup a webserver
  hosts: localhost
  tasks:
    - name: Install Apache
      debug:
        msg: "Apache will be installed"
      when: web_server_type == "apache"
      # Additional task details...
    - name: Install Nginx
      debug:
        msg: "Nginx will be installed"
      when: web_server_type == "nginx"
      tags: Install nginx

However, as projects evolve to support multiple operating systems, data centers, software versions across various environments, and the management of numerous roles, it becomes clear that our approach needs refinement.

Refactoring with Design Patterns in Mind

Before we proceed with refactoring, let’s consider how design pattern lessons can be applied in Ansible:

  • Reduce Specificity: Instead of relying on detailed checks, aim to build abstractions that encapsulate the variability.
  • Depend on Abstractions: Ensure that abstractions do not hinge on specific details, but rather, that details derive from these abstractions.
  • Runtime Flexibility: Allow for the selection of specific implementations at runtime to accommodate varying requirements.
  • Externalize Dependencies: Move dependency management from tasks or roles to a higher level, utilizing variables for greater control and flexibility.
  • Component Swappability: Enable easy replacement of components with alternatives, minimizing the need for extensive refactoring.

Leveraging Ansible Constructs for Design Patterns

Let’s delve into how Ansible’s features support the application of design pattern principles, making our automation solutions more adaptable, maintainable, and scalable.

Ansible Inventory

Ansible Inventory enables the organization of your infrastructure into logical groups and distributes configuration data hierarchically through group or host variables. This structure allows for precise control without the need to specify conditions for each usage explicitly.

Consider the following inventory structure as an example:

all:
  children:
    mean_stack:
      hosts:
        mean_server_1:
          ansible_host: 192.168.1.101
          ansible_user: ubuntu
          ansible_ssh_private_key_file: /path/to/ssh/key
        mean_server_2:
          ansible_host: 192.168.1.102
          ansible_user: ubuntu
          ansible_ssh_private_key_file: /path/to/ssh/key
    lamp_stack:
      hosts:
        lamp_server_1:
          ansible_host: 192.168.1.103
          ansible_user: ubuntu
          ansible_ssh_private_key_file: /path/to/ssh/key

For each group, we define a corresponding variable file. Note that the variable names are consistent across different implementations, promoting abstraction and reusability.

# mean_stack.yml
web_server_type: nginx
web_server_version: 9.1
db_server_type: postgres
db_server_version: 9.1
# lamp_stack.yml
web_server_type: apache
web_server_version: 2.1
db_server_type: mysql
db_server_version: 9.1

By using the ansible-inventory command, we can observe how Ansible parses and merges these variables, providing a clear, unified view of the configuration for each host within the specified groups:

(venv) ➜  homelab ansible-inventory -i inventory/dev --list -y
all:
  children:
    lamp_stack:
      hosts:
        lamp_server_1:
          ansible_host: 192.168.1.103
          ansible_ssh_private_key_file: /path/to/ssh/key
          ansible_user: ubuntu
          db_server_type: mysql
          db_server_version: 9.1
          web_server_type: apache
          web_server_version: 2.1
    mean_stack:
      hosts:
        mean_server_1:
          ansible_host: 192.168.1.101
          ansible_ssh_private_key_file: /path/to/ssh/key
          ansible_user: ubuntu
          db_server_type: mongodb
          db_server_version: 11
          web_server_type: nginx
          web_server_version: 9.1
        mean_server_2:
          ansible_host: 192.168.1.102
          ansible_ssh_private_key_file: /path/to/ssh/key
          ansible_user: ubuntu
          db_server_type: mongodb
          db_server_version: 11
          web_server_type: nginx
          web_server_version: 9.1

The Limit Flag

The limit flag (-l) in ansible-playbook command is an effective method for specifying which host groups should be targeted when executing a playbook. In my view, this represents a shift in control from the code to the operator, streamlining the execution process. It negates the need for additional conditional statements such as when within the code, instead leveraging the data defined in the inventory to dictate behavior.

Here’s an example of using the -l flag to target a specific high-level group:

ansible-playbook -i inventories/homelab/dev -l lamp_stack deploy_stack.yml
ansible-playbook -i inventories/homelab/prod -l lamp_stack deploy_stack.yml

ansible-playbook -i inventories/homelab/dev -l mean_stack deploy_stack.yml
ansible-playbook -i inventories/homelab/prod -l mean_stack deploy_stack.yml

Note we are applying the same playbook but controlling which set of variables Ansible finally picks to pass to the playbooks and roles.

include_tasks and include_role

The include_tasks directive in Ansible allows for the segmentation of playbooks into smaller, more focused components, facilitating the separation of concerns. Similarly, include_role enables the construction of higher-level abstractions.

Consider the following example of a deploy_stack.yml playbook:

---
- name: deploy stack
hosts: all
roles:
  - role: webserver
  - role: dbserver

This playbook is designed to be generic, capable of deploying a stack without specifying the particular technologies—such as which database or web server to use. The selection of specific technologies is driven by the -l limit flag and the corresponding data in the inventory, which determines the applicable variables.

For instance, we can define a high-level webserver role that remains agnostic of the specific web server being implemented. The dbserver role follows a similar pattern. Below is an example where the webserver role dynamically includes a specific web server setup based on the web_server_type variable:

---
- name: Include web server setup role
  ansible.builtin.include_role:
  name: "{{web_server_type}}"
  tags: 
    - nginx
    - apache

Moving on to a concrete implementation, let’s examine an nginx role. The roles/nginx/tasks/main.yml file might contain tasks like the following, demonstrating the role’s specific actions and the inclusion of additional tasks:

---
- name: Task in nginx role
  ansible.builtin.debug:
    msg: nginx will be installed
  tags: nginx
- name: A group of tasks separated by duty
  include_task: demo.yml
  tags: nginx
---
- name: a task in a playbook
ansible.builtin.debug:
  msg: included to help installation of nginx

This structure allows for modular playbook design, where roles and tasks can be dynamically included based on the deployment’s requirements, enhancing flexibility and maintainability.

Putting it all together

.
├── bin
│   └── dynamic_inventory.py
├── deploy_stack.yml
├── inventory
│   └── dev
│       ├── group_vars
│       │   ├── lamp_stack
│       │   └── mean_stack
│       ├── host_vars
│       └── hosts
└── roles
    ├── apache
    │   └── tasks
    │       └── main.yml
    ├── nginx
    │   └── tasks
    │       ├── demo.yml
    │       └── main.yml
    └── webserver
        └── tasks
            └── main.yml

13 directories, 9 files

(venv) ➜  homelab ansible-playbook -i inventory/dev -l mean_stack deploy_stack.yml

PLAY [setup a webserver] ***************************************************************************************************

TASK [Gathering Facts] *****************************************************************************************************
ok: [mean_server_2]
ok: [mean_server_1]

TASK [Include web server setup role] ***************************************************************************************

TASK [nginx : Task in nginx role] ******************************************************************************************
ok: [mean_server_1] => {
    "msg": "nginx will be installed"
}
ok: [mean_server_2] => {
    "msg": "nginx will be installed"
}

TASK [nginx : include_tasks] ***********************************************************************************************
included: /Users/t/projects/ansible/homelab/roles/nginx/tasks/demo.yml for mean_server_1, mean_server_2

TASK [nginx : a task in a playbook] ****************************************************************************************
ok: [mean_server_1] => {
    "msg": "included to help installation of nginx"
}
ok: [mean_server_2] => {
    "msg": "included to help installation of nginx"
}

PLAY RECAP *****************************************************************************************************************
mean_server_1              : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
mean_server_2              : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

(venv) ➜  homelab ansible-playbook -i inventory/dev -l lamp_stack deploy_stack.yml

PLAY [setup a webserver] ***************************************************************************************************

TASK [Gathering Facts] *****************************************************************************************************
ok: [lamp_server_1]

TASK [Include web server setup role] ***************************************************************************************

TASK [apache : Task in apache role] ****************************************************************************************
ok: [lamp_server_1] => {
    "msg": "apache will be installed"
}

PLAY RECAP *****************************************************************************************************************
lamp_server_1              : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0 

Honorable mention

--tags and --skip-tags are two flexible options that allow selecting tasks across your roles and playbooks from the ansible-playbook command. An practice that has been useful is to tag every task in a role with the name of the role. This is due to the fact that ansible-playbook command does not allow you to run a role adhoc, but if the tasks in your role are tagged with the role name, it helps run multiple roles by providing a list of tags.

ansible-playbook -i inventory/dev --tags nginx,mongodb deploy_stack.yml

Concluding remarks

Design patterns are language neutral and the lessons we learn from them can be useful even beyond object oriented design paradigms. Despite Ansible being a procedural configuration management tool, being aware of those lessons help us write cleaner maintainable code that can be changed easily to respond to changing business needs. In this article I reviewed some of the lessons I have learned trying to adopt the spirit of a few useful design patterns while writing Ansible code. While the example is a bit facetious, I hope this would be useful. Leave a comment if there other patterns that you have come across that makes it easier to write cleaner Ansible.

Leave a Reply

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